From 634ed5235e2c43a79a81b5eff0e933736cb1a4a4 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Wed, 23 Sep 2026 23:44:15 +0800 Subject: [PATCH 001/109] =?UTF-8?q?feat(linux):=20=E6=8E=A5=E5=85=A5=20GPU?= =?UTF-8?q?I=20Kit=20=E5=AE=9E=E7=8E=B0=20Linux=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E4=B8=8E=E6=9E=84=E5=BB=BA=E6=B5=81=E6=B0=B4=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 基于 gpui-kit (0.6) 建立独立 Linux 桌面工程 linux/ - 实现 CoreClient 调度层对接 rust/lithe-core - 构建工作台基础视图:顶部工具栏、侧边栏(文件/搜索/Git)、多标签代码区与 PTY 原生终端底栏 - 新增 Linux 构建脚本 scripts/build-linux.sh 与打包脚本 scripts/package-linux.sh - 新增 Linux CI 工作流 .github/workflows/ci-linux.yml - 更新 .gitignore 排除目标产物与临时文件 --- .github/workflows/ci-linux.yml | 80 + .gitignore | 9 +- linux/Cargo.lock | 9629 +++++++++++++++++++++++++++ linux/Cargo.toml | 14 + linux/src/core/client.rs | 272 + linux/src/core/mod.rs | 3 + linux/src/lib.rs | 4 + linux/src/main.rs | 20 + linux/src/workbench/bottom_panel.rs | 197 + linux/src/workbench/editor.rs | 296 + linux/src/workbench/mod.rs | 18 + linux/src/workbench/sidebar.rs | 622 ++ linux/src/workbench/terminal.rs | 235 + linux/src/workbench/toolbar.rs | 126 + linux/src/workbench/view.rs | 162 + scripts/build-linux.sh | 65 + scripts/package-linux.sh | 78 + 17 files changed, 11829 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci-linux.yml create mode 100644 linux/Cargo.lock create mode 100644 linux/Cargo.toml create mode 100644 linux/src/core/client.rs create mode 100644 linux/src/core/mod.rs create mode 100644 linux/src/lib.rs create mode 100644 linux/src/main.rs create mode 100644 linux/src/workbench/bottom_panel.rs create mode 100644 linux/src/workbench/editor.rs create mode 100644 linux/src/workbench/mod.rs create mode 100644 linux/src/workbench/sidebar.rs create mode 100644 linux/src/workbench/terminal.rs create mode 100644 linux/src/workbench/toolbar.rs create mode 100644 linux/src/workbench/view.rs create mode 100755 scripts/build-linux.sh create mode 100755 scripts/package-linux.sh diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml new file mode 100644 index 000000000..fa099daf8 --- /dev/null +++ b/.github/workflows/ci-linux.yml @@ -0,0 +1,80 @@ +name: Linux CI + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: linux-ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + name: Build & Package Linux Frontend + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Check out source + uses: actions/checkout@v7 + + - name: Install system dependencies for GPUI + run: | + sudo apt-get update + sudo apt-get install -y \ + libxkbcommon-dev \ + libxkbcommon-x11-dev \ + libxcb-xkb-dev \ + libfontconfig1-dev \ + libfreetype6-dev \ + libx11-dev \ + libxcb1-dev \ + libx11-xcb-dev \ + libssl-dev \ + pkg-config + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Restore Cargo dependencies + uses: actions/cache@v6 + with: + path: | + ~/.cargo/git + ~/.cargo/registry + target + key: cargo-linux-${{ runner.os }}-${{ hashFiles('linux/Cargo.toml', 'rust/Cargo.lock') }} + restore-keys: | + cargo-linux-${{ runner.os }}- + + - name: Check formatting + run: | + cargo fmt --manifest-path linux/Cargo.toml -- --check + + - name: Check lithe-linux compilation + run: | + cargo check --manifest-path linux/Cargo.toml + + - name: Build lithe-linux binary + run: | + ./scripts/build-linux.sh --release + + - name: Package Linux application + run: | + ./scripts/package-linux.sh --release + + - name: Upload Linux artifact + uses: actions/upload-artifact@v4 + with: + name: lithe-linux-dist + path: dist/*.tar.gz + if-no-files-found: error + retention-days: 14 diff --git a/.gitignore b/.gitignore index 442d671a5..38786b01b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ .DS_Store .build/ -rust/target/ +target/ .swiftpm/ .idea/ +.vscode/ DerivedData/ /windows/build*/ dist/ @@ -16,3 +17,9 @@ docs/visual-qa/ reasonix.toml .codex/ /node_modules/ +*.swp +*.swo +*~ +*.tar.gz +*.deb +*.AppImage diff --git a/linux/Cargo.lock b/linux/Cargo.lock new file mode 100644 index 000000000..4732d81ce --- /dev/null +++ b/linux/Cargo.lock @@ -0,0 +1,9629 @@ +# 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.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023da0e5097f46df7092d5280b02efb9bbf8d93298daeced42652463e357d636" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "phf 0.13.1", + "serde", + "zvariant", +] + +[[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", + "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.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e156ed3802e35eefe894ef2671bc6c889303d8a7e110b5e1b48f504b91362f" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "106c2b961215864d1c2e703ee63269c25c4e80a577ffb2c1017b9c17dcdf83a1" +dependencies = [ + "accesskit", + "accesskit_consumer", + "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 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" +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 = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ammonia" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f7ed3ac4252be4d3e36fb589ed5659356842587b21a31596a9dc89d3b2f5e2a" +dependencies = [ + "cssparser", + "html5ever 0.40.1", + "maplit", + "url", +] + +[[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 = "annotate-snippets" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[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 = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[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-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" +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-channel", + "futures-util", + "getrandom 0.4.3", + "serde", + "serde_repr", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "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.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd" +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", + "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", +] + +[[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", + "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.6", +] + +[[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", + "rustc-demangle", + "windows-link 0.2.1", +] + +[[package]] +name = "base62" +version = "2.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3a647dce9631f152a99ec16cfc09267ae55fe280d5dc288964027d458076323" + +[[package]] +name = "base64" +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 = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.2", + "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.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +dependencies = [ + "bit-vec", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[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-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" +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 0.2.2", +] + +[[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" +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" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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.2", + "polling", + "rustix", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop", + "rustix", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "caseless" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" +dependencies = [ + "unicode-normalization", +] + +[[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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[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 = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[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 0.1.7", + "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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad36507aeb7e16159dfe68db81ccc27571c3ccd4b76fb2fb72fc59e7a4b1b64c" +dependencies = [ + "bitflags 2.13.2", + "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.2", + "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 = "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.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9" +dependencies = [ + "bzip2", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "comrak" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d5910408554659ed848ff469e67ec83b30f179e72cec286cfdae64d1616f466" +dependencies = [ + "caseless", + "emojis", + "entities", + "finl_unicode", + "jetscii", + "phf 0.13.1", + "phf_codegen 0.13.1", + "rustc-hash 2.1.3", + "smallvec", + "typed-arena", +] + +[[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 = "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" +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.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +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.2", + "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.2", + "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.2", + "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.2", + "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_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[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.2", + "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 = "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[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 = "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.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11119743ad110e8c1bdccd930d7f5c30c99e5fc76a7b63ec9807e84eef0c5f59" +dependencies = [ + "dtoa-short", + "itoa", + "smallvec", +] + +[[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 = "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" + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 = "digest" +version = "0.10.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", +] + +[[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 0.10.2", + "crypto-common 0.2.2", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + +[[package]] +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 0.5.3", + "windows-sys 0.61.2", +] + +[[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.2", + "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.6", +] + +[[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 = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[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" +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.6+spec-1.1.0", + "vswhom", + "winreg 0.55.0", +] + +[[package]] +name = "emojis" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a4d5d50b0b58df5173d8ff1192b4d1422ceae5d981b30d4b6f8ed1d673a2bc4" +dependencies = [ + "phf 0.13.1", +] + +[[package]] +name = "encoding_rs" +version = "0.8.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "encoding_rs_io" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "entities" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca" + +[[package]] +name = "enum-iterator" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016" +dependencies = [ + "enum-iterator-derive", +] + +[[package]] +name = "enum-iterator-derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 = "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 = "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.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" + +[[package]] +name = "finl_unicode" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2" + +[[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 = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" +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.6", +] + +[[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 = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[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" +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.6", +] + +[[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 = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "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", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + +[[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 = "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 = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + +[[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.2", + "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.2", +] + +[[package]] +name = "gpui-base" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e904ee0d351f4e85b967e26ae986d3aa195a8d9d35fcd18184a2c40989694e" +dependencies = [ + "aho-corasick", + "anyhow", + "ashpd", + "async-channel", + "chrono", + "data-url", + "futures", + "gpui-pre", + "gpui-pre-macros", + "gpui-pre-sum-tree", + "html5ever 0.27.0", + "instant", + "lsp-types", + "markdown", + "markup5ever_rcdom", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "raw-window-handle", + "regex", + "ropey", + "schemars", + "serde", + "serde_json", + "smallvec", + "smol", + "tracing", + "unicode-segmentation", + "web-time", + "windows 0.58.0", +] + +[[package]] +name = "gpui-component" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cf6c72516b44bb2e66a1bd2ccf77a122ecd1b54a75559ac299bb296bfcb5bb7" +dependencies = [ + "anyhow", + "chrono", + "core-text", + "enum-iterator", + "gpui-base", + "gpui-component-macros", + "gpui-kit-assets", + "gpui-pre", + "gpui-pre-macros", + "gpui-pre-sum-tree", + "instant", + "itertools 0.13.0", + "log", + "lsp-types", + "markdown", + "notify", + "num-traits", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "once_cell", + "paste", + "raw-window-handle", + "regex", + "resvg 0.45.1", + "ropey", + "rust-i18n", + "schemars", + "serde", + "serde_json", + "serde_repr", + "smallvec", + "smol", + "tracing", + "uuid", + "windows 0.58.0", +] + +[[package]] +name = "gpui-component-macros" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3867545508c7cf6a5f6da586f60d894a3f8e7cce94b7fe82df7fad5f868ec20e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui-kit" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42732dc20f50926278b2abf7a99d427a7741ab28bca6bfb49685f9ce3a53a1c7" +dependencies = [ + "gpui-base", + "gpui-component", + "gpui-kit-assets", + "gpui-pre", + "gpui-pre-platform", + "gpui-pre-web", +] + +[[package]] +name = "gpui-kit-assets" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "111d3d03a68454318a193f137419864ccebdc7d3dc0194b0b0b3f818a6bc3d55" +dependencies = [ + "anyhow", + "gpui-pre", + "gpui-pre-reqwest", + "log", + "rust-embed", + "wasm-bindgen", + "wasm-bindgen-futures", +] + +[[package]] +name = "gpui-pre" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0437c0b83e636a92bd1a39fa1d05fb632ae671289537497b35871ffbe231b84" +dependencies = [ + "accesskit", + "anyhow", + "async-channel", + "async-task", + "bindgen", + "bitflags 2.13.2", + "chrono", + "core-video", + "ctor", + "derive_more", + "embed-resource", + "etagere", + "futures", + "futures-concurrency", + "getrandom 0.3.4", + "gpui-pre-collections", + "gpui-pre-http-client", + "gpui-pre-macros", + "gpui-pre-refineable", + "gpui-pre-scheduler", + "gpui-pre-shared-string", + "gpui-pre-sum-tree", + "gpui-pre-util", + "gpui-pre-util-macros", + "heapless", + "image", + "inventory", + "itertools 0.14.0", + "log", + "lyon", + "num_cpus", + "objc2-core-foundation", + "objc2-core-video", + "parking", + "parking_lot", + "pin-project", + "pollster 0.4.0", + "postage", + "profiling", + "rand 0.9.5", + "raw-window-handle", + "regex", + "resvg 0.46.0", + "schemars", + "seahash", + "serde", + "serde_json", + "slotmap", + "smallvec", + "spin 0.10.1", + "strum", + "taffy", + "thiserror 2.0.20", + "ttf-parser", + "url", + "usvg 0.46.0", + "uuid", + "waker-fn", + "web-time", + "windows 0.62.2", + "zed-font-kit", + "zed-scap", +] + +[[package]] +name = "gpui-pre-apple" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3af534f0746eb9e014114bcc0ee00dc250c4b8d75df2e437180c403aa65649c1" +dependencies = [ + "anyhow", + "block", + "cbindgen", + "cocoa 0.26.1", + "core-foundation 0.10.1", + "core-video", + "derive_more", + "etagere", + "foreign-types", + "gpui-pre", + "image", + "log", + "metal", + "objc", + "objc2 0.6.4", + "parking_lot", +] + +[[package]] +name = "gpui-pre-collections" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15e7a65204039187d1f4b3d4c2e0233eeac54d4acb0b13f9dc1a3b5adad26c1f" +dependencies = [ + "gpui-pre-util", + "indexmap", + "rustc-hash 2.1.3", +] + +[[package]] +name = "gpui-pre-derive-refineable" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79dfb4a05f3cd9893d7768dbf5dba1bbbe99cab7394e89b360ccb3b2bcb6b34e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui-pre-http-client" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd5a7542c77887a5aff997d78c95884119c8463409569b63fcf060956da1682" +dependencies = [ + "anyhow", + "async-compression", + "bytes", + "derive_more", + "futures", + "http", + "http-body", + "log", + "parking_lot", + "serde", + "serde_json", + "serde_urlencoded", + "url", +] + +[[package]] +name = "gpui-pre-linux" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab655cda1d6dcd549ac869e8678babc84cf573ab6794a1fe1a39c6c70c60b48" +dependencies = [ + "accesskit", + "accesskit_unix", + "anyhow", + "as-raw-xcb-connection", + "ashpd", + "bitflags 2.13.2", + "bytemuck", + "calloop", + "calloop-wayland-source", + "filedescriptor", + "futures", + "gpui-pre", + "gpui-pre-collections", + "gpui-pre-http-client", + "gpui-pre-util", + "gpui-pre-wgpu", + "libc", + "log", + "notify-rust", + "oo7", + "open", + "parking_lot", + "raw-window-handle", + "smallvec", + "smol", + "strum", + "url", + "uuid", + "wayland-backend", + "wayland-client", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-plasma", + "wayland-protocols-wlr", + "x11-clipboard", + "x11rb", + "xkbcommon", + "zed-scap", + "zed-xim", +] + +[[package]] +name = "gpui-pre-macos" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951d17e41a72067ad2d35c60e08a62e8ad7fa9400b41c802e26cfb459f6c05da" +dependencies = [ + "accesskit", + "accesskit_macos", + "anyhow", + "async-task", + "block2 0.6.2", + "cocoa 0.26.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "core-graphics 0.24.0", + "core-text", + "ctor", + "dispatch2", + "foreign-types", + "futures", + "gpui-pre", + "gpui-pre-apple", + "gpui-pre-collections", + "gpui-pre-util", + "image", + "itertools 0.14.0", + "libc", + "log", + "mach2", + "objc", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-graphics", + "objc2-core-media", + "objc2-foundation 0.3.2", + "objc2-screen-capture-kit", + "objc2-user-notifications", + "parking_lot", + "pathfinder_geometry", + "raw-window-handle", + "semver", + "smallvec", + "strum", + "uuid", + "zed-font-kit", +] + +[[package]] +name = "gpui-pre-macros" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6324092b40ea8ec7c327a4a28427e366a489b4d5d23b5d1bd0243fc2f4ca7a9e" +dependencies = [ + "heck 0.5.0", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui-pre-perf" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94bff26ccd8105f6206144f8dab16a2dc02e7a68f39dada02cdf492f0afd940" +dependencies = [ + "gpui-pre-collections", + "serde", + "serde_json", +] + +[[package]] +name = "gpui-pre-platform" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8423d1e0a693d2f9f16c0326bb01b5e81ec0531854d596a18476934ac6880478" +dependencies = [ + "console_error_panic_hook", + "gpui-pre", + "gpui-pre-linux", + "gpui-pre-macos", + "gpui-pre-web", + "gpui-pre-windows", +] + +[[package]] +name = "gpui-pre-refineable" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93b4b0a8d296a3ee5beb1136bc48f8167a6028eb1e243b56851dcd8c9fc6d7a1" +dependencies = [ + "gpui-pre-derive-refineable", +] + +[[package]] +name = "gpui-pre-reqwest" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05be23908e707966824f8c51a609904b7f30739e8d915e120a53c1b995ba964c" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-rustls", + "tokio-socks", + "tokio-util", + "tower", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "windows-registry 0.4.0", +] + +[[package]] +name = "gpui-pre-scheduler" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7b10b3d2fc3fda24847a04436eca9531612c8414874a37ddc005f20e521e12" +dependencies = [ + "async-task", + "backtrace", + "chrono", + "flume", + "futures", + "parking_lot", + "rand 0.9.5", + "wasm_thread", + "web-time", +] + +[[package]] +name = "gpui-pre-shared-string" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe17f81bd98a0253140ea79f7f76442d7499bd8d17db302861af8e46c11d38df" +dependencies = [ + "schemars", + "serde", + "smol_str", +] + +[[package]] +name = "gpui-pre-sum-tree" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6d35e998a51d24248dad731de1f09d386cbbf46a04edde3b67f469b2ce3f16" +dependencies = [ + "gpui-pre-ztracing", + "heapless", + "log", + "rayon", + "tracing", +] + +[[package]] +name = "gpui-pre-util" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a026a30c578930d20974fd8c3d77de36223ac7ba193bd4259e7238929bc8806" +dependencies = [ + "anyhow", + "log", + "which", +] + +[[package]] +name = "gpui-pre-util-macros" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cbf79627a6afc626d4e887dad9d2d92bb069413e8cc692ba3e90bff219eb7a9" +dependencies = [ + "gpui-pre-perf", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui-pre-web" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34be433b9fcb370b1fe9a1c7fe911f0eb6058791ef8167dcf6c5a5deb624975c" +dependencies = [ + "anyhow", + "console_error_panic_hook", + "futures", + "gpui-pre", + "gpui-pre-http-client", + "gpui-pre-scheduler", + "gpui-pre-wgpu", + "js-sys", + "log", + "parking_lot", + "raw-window-handle", + "smallvec", + "unicode-properties", + "unicode-script", + "unicode-segmentation", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm_thread", + "web-sys", + "web-time", +] + +[[package]] +name = "gpui-pre-wgpu" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05515b32799b1767d69300921c1b562cd53104965211a0e8905acdc7aa18e693" +dependencies = [ + "anyhow", + "bytemuck", + "cosmic-text", + "etagere", + "gpui-pre", + "gpui-pre-collections", + "gpui-pre-util", + "itertools 0.14.0", + "log", + "parking_lot", + "profiling", + "raw-window-handle", + "smallvec", + "swash", + "unicode-bidi", + "unicode-segmentation", + "web-sys", + "wgpu", + "zed-font-kit", +] + +[[package]] +name = "gpui-pre-windows" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27662e65bfcf4445d2cccfad91f1570ec07f4b1b5de98400a9f207fd19c464cf" +dependencies = [ + "accesskit", + "accesskit_windows", + "anyhow", + "dunce", + "etagere", + "futures", + "gpui-pre", + "gpui-pre-collections", + "gpui-pre-util", + "image", + "itertools 0.14.0", + "log", + "parking_lot", + "rand 0.9.5", + "raw-window-handle", + "smallvec", + "uuid", + "windows 0.62.2", + "windows-core 0.62.2", + "windows-numerics 0.3.1", + "windows-registry 0.6.1", +] + +[[package]] +name = "gpui-pre-zlog" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a5af0b734a391a84872eb770b3ee9830091ec2487b79d06371fe14eefb2b5d" +dependencies = [ + "anyhow", + "chrono", + "gpui-pre-collections", + "log", +] + +[[package]] +name = "gpui-pre-ztracing" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a24cb19f78c5bc3aab8ddf1e8e0bd6b402fbcb9c7f112f64ddbedef8f8768a59" +dependencies = [ + "gpui-pre-zlog", + "gpui-pre-ztracing-macro", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "gpui-pre-ztracing-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1bb25850ad31b88bbdef454afb93ec4cdb81f5e200ba5b3f1a0368111780d2c" + +[[package]] +name = "granit-parser" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e20f99e46474f56bd905c56e817ebddcf377a611f94c53ac4649e4d3fa3c0cd0" +dependencies = [ + "arraydeque", + "smallvec", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[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.2", + "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.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[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 0.10.7", +] + +[[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.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456a1a377e608e555d22ddab27ac0114bc7a7b4199078108e34c2aeae6c9b130" +dependencies = [ + "log", + "markup5ever 0.40.0", + "memchr", +] + +[[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-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 = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +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", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif 0.14.2", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "qoi", + "ravif", + "rayon", + "tiff", + "zune-core 0.5.3", + "zune-jpeg 0.5.15", +] + +[[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.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[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 = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[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.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[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 = "jetscii" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e" + +[[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.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +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 = "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.2", + "libc", +] + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[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" +dependencies = [ + "spin 0.9.9", +] + +[[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.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +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.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 = "lithe-core" +version = "0.1.0" +dependencies = [ + "ammonia", + "base64 0.22.1", + "comrak", + "lithe-git-host", + "quick-xml 0.37.5", + "rand 0.8.8", + "regex", + "reqwest", + "rsa", + "serde", + "serde_json", + "serde_yaml_ng", + "sha1", + "sha2 0.10.9", + "toml 1.1.6+spec-1.1.0", + "tree-sitter", + "tree-sitter-java", + "url", + "zip", +] + +[[package]] +name = "lithe-git-host" +version = "0.1.0" +dependencies = [ + "libc", + "rand 0.8.8", + "serde", + "serde_json", + "windows-sys 0.61.2", +] + +[[package]] +name = "lithe-linux" +version = "0.1.0" +dependencies = [ + "anyhow", + "gpui-kit", + "lithe-core", + "portable-pty", + "serde", + "serde_json", + "tokio", + "uuid", +] + +[[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 = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri", + "serde", + "serde_json", + "serde_repr", +] + +[[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.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdfa8785f95e57914ddb35e3b59994aeba6f5e79e9cfd03da1c269f010f36009" +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.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b8dcf906637ecef61b3c0740c7a4e7f27caeb31257cfac0cc579ce15be6005" +dependencies = [ + "float_next_after", + "lyon_path", + "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" +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 = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "markdown" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" +dependencies = [ + "serde", + "unicode-id", +] + +[[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.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab3dc68ac4a0f5719e560136778c1ee716e296030d75dbd4484e37e39e3a842" +dependencies = [ + "log", + "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" +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 0.10.7", +] + +[[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 = "metal" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7047791b5bc903b8cd963014b355f71dc9864a9a0b727057676c1dcae5cbc15" +dependencies = [ + "bitflags 2.13.2", + "block", + "core-graphics-types 0.2.0", + "foreign-types", + "log", + "objc", + "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" +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 = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "log", + "wasi", + "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 = "multiversion" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" +dependencies = [ + "multiversion-macros", +] + +[[package]] +name = "multiversion-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 3.0.6", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "naga" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases 0.2.2", + "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 = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + +[[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 = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[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 = "normpath" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b11ce00d2594068e8a27c9146fdc5cf9f3ac38eb42c7cd34d05fea618873ac9f" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.13.2", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + +[[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 = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + +[[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.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" +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-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.2", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "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.2", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-cloud-kit", + "objc2-core-data 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2 0.6.4", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", +] + +[[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.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-foundation 0.3.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.2", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[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-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.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-core-media" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.4", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-core-video", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[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.2", + "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.2", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "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.2", + "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.2", + "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.2", + "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.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-screen-capture-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74b7c5390f477482f001bc354d6571a70db7e4f8d5288e860c45521fbce11394" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "objc2 0.6.4", + "objc2-core-graphics", + "objc2-core-media", + "objc2-foundation 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.2", + "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 = "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 0.10.7", + "endi", + "futures-lite", + "futures-util", + "getrandom 0.4.3", + "hkdf", + "hmac", + "md-5", + "num", + "num-bigint-dig 0.9.1", + "pbkdf2", + "serde", + "serde_bytes", + "sha2 0.10.9", + "subtle", + "zbus", + "zbus_macros", + "zeroize", + "zvariant", +] + +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[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 = "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 0.10.7", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[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.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_shared 0.14.0", + "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" +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_codegen" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", +] + +[[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.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", +] + +[[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.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +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 = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +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 = "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" +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.2", + "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", + "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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + +[[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 = "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" +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.15+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 = "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 = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases 0.2.2", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases 0.2.2", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.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" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[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_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "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", + "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.2", +] + +[[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.5", + "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.2", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "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.6", +] + +[[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 = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "gif 0.13.3", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes 0.15.3", + "tiny-skia", + "usvg 0.45.1", + "zune-jpeg 0.4.21", +] + +[[package]] +name = "resvg" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b563218631706d614e23059436526d005b50ab5f2d506b55a17eb65c5eb83419" +dependencies = [ + "gif 0.14.2", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes 0.16.1", + "tiny-skia", + "usvg 0.46.0", + "zune-jpeg 0.5.15", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +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 = "ropey" +version = "2.0.0-beta.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4045a00dc327d084a2bbf126976e14125b54f23bd30511d45b842eba76c52d74" +dependencies = [ + "str_indices", +] + +[[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 = "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 = "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", + "shellexpand", + "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 = "rust-i18n" +version = "4.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c43fd69c20da13756643a5bf51ae799965465c78e05c57a1242a736aaa56527" +dependencies = [ + "globwalk", + "regex", + "rust-i18n-macro", + "rust-i18n-support", + "smallvec", +] + +[[package]] +name = "rust-i18n-macro" +version = "4.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "086ec8a9eaa6afda33919b9bc1661c1f4e017c40e99bbd33ca98f6e1cb6ca91f" +dependencies = [ + "glob", + "proc-macro2", + "quote", + "rust-i18n-support", + "serde", + "serde_json", + "syn 2.0.119", +] + +[[package]] +name = "rust-i18n-support" +version = "4.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac02dcb9a01ec145d0a5534ea9554a966050ca250b346bb2a9ab0fcdafab5979" +dependencies = [ + "arc-swap", + "base62", + "globwalk", + "itertools 0.11.0", + "normpath", + "serde", + "serde-saphyr", + "serde_json", + "siphasher", + "toml 0.8.23", + "triomphe", +] + +[[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 = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.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" +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.2", + "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 = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "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.6", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[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 = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "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 = "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-saphyr" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8050abb251097357e24aff63ba2c52a6309ecb7d23a5474023df960a02694d8" +dependencies = [ + "annotate-snippets", + "base64 0.23.1", + "encoding_rs_io", + "granit-parser", + "nohash-hasher", + "num-traits", + "serde_core", + "smallvec", + "zmij", +] + +[[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.6", +] + +[[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.6", +] + +[[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_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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 = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serial2" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + +[[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" +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 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]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs 6.0.0", +] + +[[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-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "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" +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 = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[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.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[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 = "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 = "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.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +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.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffa8a5dbe8b3f0bbe29d4c3225daafaeead63afdc1b65fc4c01a1384166038e6" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.14.0", + "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.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928dcdf75e47626b3617a976ec205d9f057584c371c1f23b782129268d0e6edc" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", + "proc-macro2", + "quote", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "sval" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b81b254da21fe1fcc4e3a74fe39b46e25e3a863078f8b71c954d47f84889dbc6" + +[[package]] +name = "sval_buffer" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50be352d2822ffafb59e3e2ddac9d5ee60f2eeadbb7b5a2a951b9f3651e87a6f" +dependencies = [ + "sval", + "sval_ref", + "zerocopy", +] + +[[package]] +name = "sval_dynamic" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048ca293b998d9a45659159f94a64063791e74cdc670164943dbb434405573d" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_fmt" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b5888e40f80568733217f27b7317b845f463400ced36c424b1a804730e53b2" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_json" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17664d6bb6b74947afaab9d7c991caa9bf5638d4dee16fcbef637f440796049" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_nested" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c059969ca5ca163ea7fef6c9661758973d17691aba92abdcf5c428f4ec122c" +dependencies = [ + "sval", + "sval_buffer", + "sval_ref", +] + +[[package]] +name = "sval_ref" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d6b29ff568c85c87561807f51d2adfff4b6016c6363133f7cd1652a12548f3" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_serde" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f33ec9edc42b12764d5c90ca0a1d84189c6bde81ed27507f1e661c6e4e05853" +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.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo 0.11.3", + "siphasher", +] + +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo 0.13.1", + "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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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 = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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", + "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]] +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[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.6", +] + +[[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 0.5.15", +] + +[[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-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" +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.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[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.6", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "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" +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 = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +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 = "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.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +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 = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "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 = "tree-sitter" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081" + +[[package]] +name = "triomphe" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" +dependencies = [ + "arc-swap", + "serde", + "stable_deref_trait", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + +[[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 = "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" +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-id" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65dd75ab2d9d379dbe86d4059e91d8b5a6e7054865eaba1ddbfc7a817ff96392" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-linebreak" +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" +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 = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "fontdb", + "imagesize 0.13.0", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree 0.20.0", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes 0.15.3", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "usvg" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "fontdb", + "imagesize 0.14.0", + "kurbo 0.13.1", + "log", + "pico-args", + "roxmltree 0.21.1", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes 0.16.1", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +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.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2799ffb329a792ecfd902b71306c8a815a6ef1c0470fa9953a6aa4d4cecbe511" +dependencies = [ + "value-bag-serde1", + "value-bag-sval2", +] + +[[package]] +name = "value-bag-serde1" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0941feceafbe7a8f59ea1096d45b97002884a41306315ad797b3684b63a81d8c" +dependencies = [ + "erased-serde", + "serde_core", + "serde_fmt", +] + +[[package]] +name = "value-bag-sval2" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839752af8179287d27eb2b94164641b1ede9e60ab7424163388dc21ebd0508cd" +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 = "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 = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm_thread" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7516db7f32decdadb1c3b8deb1b7d78b9df7606c5cc2f6241737c2ab3a0258e" +dependencies = [ + "futures", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "log", + "rustix", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml 0.41.0", + "quote", +] + +[[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.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +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 = "web_atoms" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7572660c8890448ba236b7376f27e389c6a7e1c70195622faced601f855c0ada" +dependencies = [ + "phf 0.14.0", + "phf_codegen 0.14.0", + "string_cache 0.11.0", + "string_cache_codegen 0.11.2", +] + +[[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 = "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.2", + "bytemuck", + "cfg-if", + "cfg_aliases 0.2.2", + "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", + "bit-vec", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases 0.2.2", + "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", + "bitflags 2.13.2", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases 0.2.2", + "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.2", + "bytemuck", + "js-sys", + "log", + "raw-window-handle", + "web-sys", +] + +[[package]] +name = "which" +version = "8.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +dependencies = [ + "libc", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-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 0.52.6", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +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 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 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.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +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.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +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.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" +dependencies = [ + "windows-result 0.3.4", + "windows-strings 0.3.1", + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +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.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +dependencies = [ + "windows-link 0.1.3", +] + +[[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.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.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 0.52.6", +] + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "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" +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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[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_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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[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_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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[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_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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +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.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.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[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 = "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 = "x11-clipboard" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3" +dependencies = [ + "libc", + "x11rb", +] + +[[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", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566" +dependencies = [ + "bitflags 2.13.2", + "libc", + "quick-xml 0.41.0", + "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 = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac61a7062c40f3c37b6e82eeeef835d5cc7824b632a72784a89b3963c33284c" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "xim-parser" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dcee45f89572d5a65180af3a84e7ddb24f5ea690a6d3aa9de231281544dd7b7" +dependencies = [ + "bitflags 2.13.2", +] + +[[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", + "memmap2", + "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" +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 = "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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "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", + "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.6", + "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 = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3898e450f36f852edda72e3f985c34426042c4951790b23b107f93394f9bff5" +dependencies = [ + "bitflags 2.13.2", + "byteorder", + "core-foundation 0.10.1", + "core-graphics 0.24.0", + "core-text", + "dirs 5.0.1", + "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 = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b338d705ae33a43ca00287c11129303a7a0aa57b101b72a1c08c863f698ac8" +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 = "zed-xim" +version = "0.4.0-zed" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0b46ed118eba34d9ba53d94ddc0b665e0e06a2cf874cfa2dd5dec278148642" +dependencies = [ + "ahash", + "hashbrown 0.14.5", + "log", + "x11rb", + "xim-ctext", + "xim-parser", +] + +[[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.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "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.6", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.20", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[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.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core 0.5.3", +] + +[[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.6", + "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.6", + "winnow 1.0.4", +] diff --git a/linux/Cargo.toml b/linux/Cargo.toml new file mode 100644 index 000000000..b6aa6d589 --- /dev/null +++ b/linux/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "lithe-linux" +version = "0.1.0" +edition = "2021" + +[dependencies] +gpui-kit = "0.6" +lithe-core = { path = "../rust/lithe-core" } +portable-pty = "0.9" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["sync", "macros", "rt"] } +anyhow = "1" +uuid = { version = "1", features = ["v4"] } diff --git a/linux/src/core/client.rs b/linux/src/core/client.rs new file mode 100644 index 000000000..1577980b2 --- /dev/null +++ b/linux/src/core/client.rs @@ -0,0 +1,272 @@ +use gpui_kit::{BackgroundExecutor, Global, Task}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct CoreRequestEnvelope { + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + operation_id: Option, + command: String, + payload: Value, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawCoreResponseEnvelope { + #[allow(dead_code)] + id: Option, + ok: bool, + data: Option, + error: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CoreResponseError { + pub code: Value, + pub message: String, + #[serde(default)] + pub details: Option, +} + +impl std::fmt::Display for CoreResponseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.code { + Value::String(code) => write!(f, "{code}: {}", self.message), + other => write!(f, "{other}: {}", self.message), + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct CoreClient; + +impl Global for CoreClient {} + +impl CoreClient { + pub fn new() -> Self { + Self + } + + /// 执行 Core 命令并返回异步任务 Task + pub fn execute( + &self, + cx: &gpui_kit::AsyncApp, + command: &str, + payload: T, + ) -> Task> + where + T: Serialize, + R: serde::de::DeserializeOwned + Send + 'static, + { + self.execute_with_operation_id(cx, command, payload, None) + } + + /// 执行带 operation_id 的 Core 命令,方便后续取消 + pub fn execute_with_operation_id( + &self, + cx: &gpui_kit::AsyncApp, + command: &str, + payload: T, + operation_id: Option, + ) -> Task> + where + T: Serialize, + R: serde::de::DeserializeOwned + Send + 'static, + { + self.execute_with_executor(cx.background_executor(), command, payload, operation_id) + } + + /// 基于指定 BackgroundExecutor 执行 Core 命令 + pub fn execute_with_executor( + &self, + executor: &BackgroundExecutor, + command: &str, + payload: T, + operation_id: Option, + ) -> Task> + where + T: Serialize, + R: serde::de::DeserializeOwned + Send + 'static, + { + let id = Uuid::new_v4().to_string(); + let payload_value = match serde_json::to_value(payload) { + Ok(v) => v, + Err(e) => { + let err = format!("Failed to serialize command payload: {e}"); + return executor.spawn(async move { Err(err) }); + } + }; + + let request = CoreRequestEnvelope { + id: Some(id), + operation_id, + command: command.to_string(), + payload: payload_value, + }; + + let request_json = match serde_json::to_string(&request) { + Ok(j) => j, + Err(e) => { + let err = format!("Failed to serialize request envelope: {e}"); + return executor.spawn(async move { Err(err) }); + } + }; + + executor.spawn(async move { + let response_json = lithe_core::execute_json(&request_json); + Self::parse_response::(&response_json) + }) + } + + /// 取消正在执行的操作 + #[allow(dead_code)] + pub fn cancel_operation(&self, operation_id: &str) -> bool { + lithe_core::cancel_operation(operation_id) + } + + /// 取消正在执行的操作(别名) + #[allow(dead_code)] + pub fn cancel(&self, operation_id: &str) -> bool { + self.cancel_operation(operation_id) + } + + /// 获取工作区快照 + pub fn snapshot( + &self, + cx: &gpui_kit::AsyncApp, + root: &str, + ) -> Task> { + self.execute( + cx, + "workspace.snapshot", + serde_json::json!({ + "root": root, + }), + ) + } + + /// 读取文件内容 + pub fn read_file( + &self, + cx: &gpui_kit::AsyncApp, + root: &str, + path: &str, + ) -> Task> { + let task: Task> = self.execute( + cx, + "file.read", + serde_json::json!({ + "root": root, + "path": path, + }), + ); + + cx.background_executor().spawn(async move { + let value = task.await?; + let text = value + .get("text") + .and_then(|t| t.as_str()) + .unwrap_or("") + .to_string(); + Ok(text) + }) + } + + /// 写入文件内容 + pub fn write_file( + &self, + cx: &gpui_kit::AsyncApp, + root: &str, + path: &str, + text: &str, + ) -> Task> { + let task: Task> = self.execute( + cx, + "file.write", + serde_json::json!({ + "root": root, + "path": path, + "text": text, + }), + ); + + cx.background_executor().spawn(async move { + task.await?; + Ok(()) + }) + } + + /// 在工作区中搜索文本或文件 + #[allow(dead_code)] + pub fn search( + &self, + cx: &gpui_kit::AsyncApp, + root: &str, + query: &str, + ) -> Task, String>> { + let task: Task> = self.execute( + cx, + "workspace.search", + serde_json::json!({ + "root": root, + "query": query, + "caseSensitive": false, + "maxResults": 100, + }), + ); + + cx.background_executor().spawn(async move { + let value = task.await?; + let matches = value + .get("matches") + .and_then(|m| m.as_array()) + .cloned() + .unwrap_or_default(); + Ok(matches) + }) + } + + /// 获取 Git 状态 + pub fn git_status( + &self, + cx: &gpui_kit::AsyncApp, + root: &str, + ) -> Task> { + self.execute( + cx, + "git.status", + serde_json::json!({ + "root": root, + }), + ) + } + + /// 解析 Core 返回的 JSON envelope + fn parse_response(response_json: &str) -> Result { + let envelope: RawCoreResponseEnvelope = serde_json::from_str(response_json) + .map_err(|e| format!("Failed to parse core response JSON: {e}"))?; + + if envelope.ok { + let data = envelope.data.unwrap_or(Value::Null); + serde_json::from_value::(data) + .map_err(|e| format!("Failed to deserialize response data: {e}")) + } else { + let error_str = if let Some(err) = envelope.error { + match &err.details { + Some(details) if !details.is_empty() => { + format!("{err} ({details})") + } + _ => err.to_string(), + } + } else { + "Unknown error from lithe core".to_string() + }; + Err(error_str) + } + } +} diff --git a/linux/src/core/mod.rs b/linux/src/core/mod.rs new file mode 100644 index 000000000..f2702b963 --- /dev/null +++ b/linux/src/core/mod.rs @@ -0,0 +1,3 @@ +mod client; + +pub use client::CoreClient; diff --git a/linux/src/lib.rs b/linux/src/lib.rs new file mode 100644 index 000000000..7c4fe1ee7 --- /dev/null +++ b/linux/src/lib.rs @@ -0,0 +1,4 @@ +pub mod core; +pub mod workbench; + +pub use workbench::WorkbenchView; diff --git a/linux/src/main.rs b/linux/src/main.rs new file mode 100644 index 000000000..69c77ca8e --- /dev/null +++ b/linux/src/main.rs @@ -0,0 +1,20 @@ +mod core; +mod workbench; + +use gpui_kit::component::Root; +use gpui_kit::*; +use workbench::WorkbenchView; + +fn main() { + let app = gpui_kit::application().with_assets(gpui_kit::assets::Assets); + + app.run(move |cx| { + gpui_kit::init(cx); + + cx.open_window(WindowOptions::default(), |window, cx| { + let view = cx.new(|cx| WorkbenchView::new(window, cx)); + cx.new(|cx| Root::new(view, window, cx)) + }) + .expect("Failed to open window"); + }); +} diff --git a/linux/src/workbench/bottom_panel.rs b/linux/src/workbench/bottom_panel.rs new file mode 100644 index 000000000..4a5406f2a --- /dev/null +++ b/linux/src/workbench/bottom_panel.rs @@ -0,0 +1,197 @@ +use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::scroll::ScrollableElement as _; +use gpui_kit::component::{h_flex, v_flex, Sizable as _}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::{ + div, px, rgb, AppContext as _, Context, Entity, IntoElement, + ParentElement as _, Render, Styled as _, Window, +}; + +use crate::workbench::terminal::TerminalView; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BottomTab { + Terminal, + Output, + Problems, +} + +pub struct BottomPanelView { + pub active_tab: BottomTab, + pub is_collapsed: bool, + pub terminal: Entity, + pub output_logs: Vec, + pub problems: Vec, +} + +impl BottomPanelView { + pub fn new(working_dir: String, cx: &mut Context) -> Self { + let terminal = cx.new(|cx| TerminalView::new(working_dir, cx)); + + Self { + active_tab: BottomTab::Terminal, + is_collapsed: false, + terminal, + output_logs: vec!["[Lithe Linux] Initialized.".to_string()], + problems: Vec::new(), + } + } + + pub fn set_tab(&mut self, tab: BottomTab, cx: &mut Context) { + self.active_tab = tab; + self.is_collapsed = false; + cx.notify(); + } + + pub fn toggle_collapsed(&mut self, cx: &mut Context) { + self.is_collapsed = !self.is_collapsed; + cx.notify(); + } + + pub fn append_log(&mut self, log: String, cx: &mut Context) { + self.output_logs.push(log); + cx.notify(); + } +} + +impl Render for BottomPanelView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.is_collapsed { + return div() + .h(px(28.0)) + .w_full() + .bg(rgb(0x141522)) + .border_t_1() + .border_color(rgb(0x23263b)) + .flex() + .items_center() + .justify_between() + .px_3() + .child( + div() + .text_xs() + .text_color(rgb(0x8a90a2)) + .child("Panel collapsed"), + ) + .child( + Button::new("expand-panel") + .small() + .ghost() + .label("▲ Expand") + .on_click(cx.listener(|this, _event, _window, cx| { + this.toggle_collapsed(cx); + })), + ); + } + + v_flex() + .h(px(240.0)) + .w_full() + .bg(rgb(0x11121d)) + .border_t_1() + .border_color(rgb(0x23263b)) + .child( + // 顶部 Tab 切换栏 + h_flex() + .h(px(32.0)) + .w_full() + .bg(rgb(0x141522)) + .border_b_1() + .border_color(rgb(0x23263b)) + .items_center() + .justify_between() + .px_2() + .child( + h_flex() + .items_center() + .gap_1() + .child( + Button::new("tab-terminal") + .small() + .when(self.active_tab == BottomTab::Terminal, |btn| { + btn.primary() + }) + .when(self.active_tab != BottomTab::Terminal, |btn| { + btn.ghost() + }) + .label("Terminal") + .on_click(cx.listener(|this, _event, _window, cx| { + this.set_tab(BottomTab::Terminal, cx); + })), + ) + .child( + Button::new("tab-output") + .small() + .when(self.active_tab == BottomTab::Output, |btn| { + btn.primary() + }) + .when(self.active_tab != BottomTab::Output, |btn| { + btn.ghost() + }) + .label("Output") + .on_click(cx.listener(|this, _event, _window, cx| { + this.set_tab(BottomTab::Output, cx); + })), + ) + .child( + Button::new("tab-problems") + .small() + .when(self.active_tab == BottomTab::Problems, |btn| { + btn.primary() + }) + .when(self.active_tab != BottomTab::Problems, |btn| { + btn.ghost() + }) + .label(format!("Problems ({})", self.problems.len())) + .on_click(cx.listener(|this, _event, _window, cx| { + this.set_tab(BottomTab::Problems, cx); + })), + ), + ) + .child( + Button::new("collapse-panel") + .small() + .ghost() + .label("▼ Hide") + .on_click(cx.listener(|this, _event, _window, cx| { + this.toggle_collapsed(cx); + })), + ), + ) + .child( + // 内容区域根据 Tab 切换 + div().flex_1().w_full().child(match self.active_tab { + BottomTab::Terminal => div() + .size_full() + .child(self.terminal.clone()) + .into_any_element(), + BottomTab::Output => div() + .size_full() + .p_3() + .overflow_y_scrollbar() + .text_xs() + .font_family("monospace") + .text_color(rgb(0x9ca3af)) + .children( + self.output_logs + .iter() + .cloned() + .map(|log| div().child(log)), + ) + .into_any_element(), + BottomTab::Problems => div() + .size_full() + .p_3() + .overflow_y_scrollbar() + .text_xs() + .text_color(rgb(0x9ca3af)) + .child(if self.problems.is_empty() { + "No problems have been detected in the workspace.".to_string() + } else { + format!("{} problems found", self.problems.len()) + }) + .into_any_element(), + }), + ) + } +} diff --git a/linux/src/workbench/editor.rs b/linux/src/workbench/editor.rs new file mode 100644 index 000000000..09f450427 --- /dev/null +++ b/linux/src/workbench/editor.rs @@ -0,0 +1,296 @@ +use gpui_kit::component::scroll::ScrollableElement as _; +use gpui_kit::component::{h_flex, v_flex}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::{ + div, px, rgb, Context, InteractiveElement as _, IntoElement, ParentElement as _, + Render, StatefulInteractiveElement as _, Styled as _, Window, +}; + +use crate::core::CoreClient; + +#[derive(Debug, Clone)] +pub struct EditorTab { + pub path: String, + pub title: String, + pub content: String, + pub is_dirty: bool, + pub cursor_line: usize, + pub cursor_col: usize, +} + +/// 多标签代码编辑器组件 +pub struct EditorView { + pub workspace_root: String, + pub tabs: Vec, + pub active_tab_index: Option, + #[allow(dead_code)] + pub encoding: String, + client: CoreClient, +} + +impl EditorView { + pub fn new(workspace_root: String, _cx: &mut Context) -> Self { + Self { + workspace_root, + tabs: Vec::new(), + active_tab_index: None, + encoding: "UTF-8".to_string(), + client: CoreClient::new(), + } + } + + /// 打开文件,如果已在标签中则切换,否则新增标签 + pub fn open_file(&mut self, path: String, content: String, cx: &mut Context) { + if let Some(pos) = self.tabs.iter().position(|t| t.path == path) { + self.active_tab_index = Some(pos); + cx.notify(); + return; + } + + let title = path + .rsplit_once('/') + .map(|(_, name)| name.to_string()) + .unwrap_or_else(|| path.clone()); + + self.tabs.push(EditorTab { + path, + title, + content, + is_dirty: false, + cursor_line: 1, + cursor_col: 1, + }); + + self.active_tab_index = Some(self.tabs.len() - 1); + cx.notify(); + } + + /// 关闭指定索引的标签页 + pub fn close_tab(&mut self, index: usize, cx: &mut Context) { + if index < self.tabs.len() { + self.tabs.remove(index); + if self.tabs.is_empty() { + self.active_tab_index = None; + } else if let Some(current) = self.active_tab_index { + if current >= self.tabs.len() { + self.active_tab_index = Some(self.tabs.len() - 1); + } + } + cx.notify(); + } + } + + /// 保存当前活动的标签页至磁盘(对接 `file.write`) + pub fn save_active(&mut self, cx: &mut Context) { + let Some(idx) = self.active_tab_index else { + return; + }; + let Some(tab) = self.tabs.get(idx) else { + return; + }; + + let root = self.workspace_root.clone(); + let path = tab.path.clone(); + let text = tab.content.clone(); + let client = self.client.clone(); + + cx.spawn(async move |this, cx| { + let task = client.write_file(&cx, &root, &path, &text); + if task.await.is_ok() { + let _ = this.update(cx, |ed, cx| { + if let Some(active_idx) = ed.active_tab_index { + if let Some(t) = ed.tabs.get_mut(active_idx) { + if t.path == path { + t.is_dirty = false; + cx.notify(); + } + } + } + }); + } + }) + .detach(); + } + + /// 获取推断的代码语言名称 + pub fn language_name(path: &str) -> &'static str { + if path.ends_with(".rs") { + "Rust" + } else if path.ends_with(".json") { + "JSON" + } else if path.ends_with(".toml") { + "TOML" + } else if path.ends_with(".md") { + "Markdown" + } else if path.ends_with(".ts") { + "TypeScript" + } else if path.ends_with(".js") { + "JavaScript" + } else if path.ends_with(".java") { + "Java" + } else if path.ends_with(".xml") { + "XML" + } else if path.ends_with(".yaml") || path.ends_with(".yml") { + "YAML" + } else if path.ends_with(".sh") { + "Shell" + } else { + "Plain Text" + } + } +} + +impl Render for EditorView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let active_tab = self + .active_tab_index + .and_then(|idx| self.tabs.get(idx).cloned()); + + v_flex() + .size_full() + .bg(rgb(0x13141f)) + .child( + // 1. 顶部标签栏(Tab Bar) + h_flex() + .h(px(36.0)) + .w_full() + .bg(rgb(0x10111a)) + .border_b_1() + .border_color(rgb(0x23263b)) + .items_center() + .overflow_x_scrollbar() + .px_1() + .children(self.tabs.iter().enumerate().map(|(idx, tab)| { + let is_active = self.active_tab_index == Some(idx); + let title = tab.title.clone(); + let is_dirty = tab.is_dirty; + + h_flex() + .id(idx) + .h(px(32.0)) + .items_center() + .gap_2() + .px_3() + .rounded_t_sm() + .cursor_pointer() + .when(is_active, |t| { + t.bg(rgb(0x181926)).border_t_2().border_color(rgb(0x3b82f6)) + }) + .when(!is_active, |t| { + t.bg(rgb(0x10111a)).hover(|h| h.bg(rgb(0x151624))) + }) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.active_tab_index = Some(idx); + cx.notify(); + })) + .child( + div() + .text_xs() + .text_color(if is_active { + rgb(0xffffff) + } else { + rgb(0x9ca3af) + }) + .child(title), + ) + .when(is_dirty, |t| { + t.child( + div() + .text_xs() + .text_color(rgb(0x3b82f6)) + .child("●"), + ) + }) + .child( + div() + .id(("close-tab", idx)) + .text_xs() + .text_color(rgb(0x6b7280)) + .hover(|h| h.text_color(rgb(0xef4444))) + .child("×") + .on_click(cx.listener(move |this, _event, _window, cx| { + this.close_tab(idx, cx); + })), + ) + })), + ) + .child( + // 2. 中央内容编辑区 + div().flex_1().w_full().overflow_scrollbar().child( + if let Some(tab) = &active_tab { + let lines: Vec = tab.content.lines().map(|s| s.to_string()).collect(); + + h_flex() + .size_full() + .p_3() + .gap_4() + .font_family("monospace") + .text_xs() + .child( + // 行号列 + v_flex() + .flex_shrink_0() + .text_color(rgb(0x4b5563)) + .children((1..=lines.len().max(1)).map(|num| { + div().h(px(20.0)).child(format!("{num:4}")) + })), + ) + .child( + // 代码文本列 + v_flex() + .flex_1() + .text_color(rgb(0xe5e7eb)) + .children(lines.into_iter().enumerate().map(|(idx, line)| { + div() + .id(idx) + .h(px(20.0)) + .child(if line.is_empty() { " ".to_string() } else { line }) + })), + ) + } else { + // 空状态 + h_flex() + .size_full() + .items_center() + .justify_center() + .text_sm() + .text_color(rgb(0x6b7280)) + .child("No file opened. Select a file from the sidebar.") + }, + ), + ) + .child( + // 3. 底部状态栏 + h_flex() + .h(px(24.0)) + .w_full() + .bg(rgb(0x10111a)) + .border_t_1() + .border_color(rgb(0x23263b)) + .items_center() + .justify_between() + .px_3() + .text_xs() + .text_color(rgb(0x6b7280)) + .child( + h_flex().items_center().gap_3().child(if let Some(tab) = &active_tab { + format!("Ln {}, Col {}", tab.cursor_line, tab.cursor_col) + } else { + "Ready".to_string() + }), + ) + .child( + h_flex() + .items_center() + .gap_3() + .child(if let Some(tab) = &active_tab { + Self::language_name(&tab.path) + } else { + "Plain Text" + }) + .child("UTF-8") + .child("Lithe Core"), + ), + ) + } +} diff --git a/linux/src/workbench/mod.rs b/linux/src/workbench/mod.rs new file mode 100644 index 000000000..a2031fb4c --- /dev/null +++ b/linux/src/workbench/mod.rs @@ -0,0 +1,18 @@ +pub mod bottom_panel; +pub mod editor; +pub mod sidebar; +pub mod terminal; +pub mod toolbar; +pub mod view; + +#[allow(unused_imports)] +pub use bottom_panel::BottomPanelView; +#[allow(unused_imports)] +pub use editor::EditorView; +#[allow(unused_imports)] +pub use sidebar::SidebarView; +#[allow(unused_imports)] +pub use terminal::TerminalView; +#[allow(unused_imports)] +pub use toolbar::ToolbarView; +pub use view::WorkbenchView; diff --git a/linux/src/workbench/sidebar.rs b/linux/src/workbench/sidebar.rs new file mode 100644 index 000000000..66c7af64e --- /dev/null +++ b/linux/src/workbench/sidebar.rs @@ -0,0 +1,622 @@ +use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::{h_flex, v_flex, Sizable as _}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::{ + div, px, rgb, Context, EventEmitter, InteractiveElement as _, IntoElement, + ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, Window, +}; +use serde::{Deserialize, Serialize}; + +use crate::core::CoreClient; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SidebarTab { + Explorer, + Search, + Git, +} + +/// 侧边栏文件树节点,对接 `lithe-core` 的工作区快照数据模型 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FileEntry { + pub path: String, + pub name: String, + pub is_directory: bool, + #[serde(default)] + pub size: Option, + #[serde(default)] + pub children: Option>, + #[serde(default)] + pub is_expanded: bool, +} + +#[derive(Debug, Clone)] +pub struct FlatFileItem { + pub path: String, + pub name: String, + pub is_directory: bool, + #[allow(dead_code)] + pub size: Option, + pub depth: usize, + pub is_expanded: bool, + #[allow(dead_code)] + pub has_children: bool, +} + +impl FileEntry { + pub fn collect_visible(&self, depth: usize, output: &mut Vec) { + let has_children = self + .children + .as_ref() + .map_or(false, |items| !items.is_empty()); + + output.push(FlatFileItem { + path: self.path.clone(), + name: self.name.clone(), + is_directory: self.is_directory, + size: self.size, + depth, + is_expanded: self.is_expanded, + has_children, + }); + + if self.is_directory && self.is_expanded { + if let Some(children) = &self.children { + for child in children { + child.collect_visible(depth + 1, output); + } + } + } + } +} + +/// 侧边栏派发的事件 +#[derive(Debug, Clone)] +pub enum SidebarEvent { + OpenFile(String), +} + +#[derive(Debug, Clone)] +pub struct SearchResultItem { + pub path: String, + pub line: Option, + pub preview: Option, +} + +#[derive(Debug, Clone)] +pub struct GitChangeItem { + pub path: String, + pub status: String, + #[allow(dead_code)] + pub staged: bool, +} + +/// 侧边栏主视图组件 +pub struct SidebarView { + pub root_path: String, + pub active_tab: SidebarTab, + // 资源管理器状态 + pub root_node: Option, + pub selected_path: Option, + pub is_loading: bool, + pub error_message: Option, + // 搜索状态 + #[allow(dead_code)] + pub search_query: String, + pub search_results: Vec, + pub is_searching: bool, + // Git 状态 + pub git_branch: Option, + pub git_changes: Vec, + pub is_git_loading: bool, + client: CoreClient, +} + +impl EventEmitter for SidebarView {} + +impl SidebarView { + pub fn new(root_path: String, cx: &mut Context) -> Self { + let mut view = Self { + root_path, + active_tab: SidebarTab::Explorer, + root_node: None, + selected_path: None, + is_loading: false, + error_message: None, + search_query: String::new(), + search_results: Vec::new(), + is_searching: false, + git_branch: None, + git_changes: Vec::new(), + is_git_loading: false, + client: CoreClient::new(), + }; + + view.refresh(cx); + view.refresh_git(cx); + view + } + + pub fn set_tab(&mut self, tab: SidebarTab, cx: &mut Context) { + self.active_tab = tab; + cx.notify(); + } + + /// 对接 `lithe-core` 刷新工作区文件快照 + pub fn refresh(&mut self, cx: &mut Context) { + self.is_loading = true; + self.error_message = None; + cx.notify(); + + let client = self.client.clone(); + let root = self.root_path.clone(); + + cx.spawn(async move |this, cx| { + let task = client.snapshot(&cx, &root); + + match task.await { + Ok(val) => { + let mut node = val + .get("root") + .and_then(|r| serde_json::from_value::(r.clone()).ok()); + + if let Some(entry) = &mut node { + entry.is_expanded = true; + } + + let _ = this.update(cx, |sidebar, cx| { + sidebar.root_node = node; + sidebar.is_loading = false; + cx.notify(); + }); + } + Err(err) => { + let _ = this.update(cx, |sidebar, cx| { + sidebar.error_message = Some(err); + sidebar.is_loading = false; + cx.notify(); + }); + } + } + }) + .detach(); + } + + /// 执行搜索 + #[allow(dead_code)] + pub fn execute_search(&mut self, query: &str, cx: &mut Context) { + self.search_query = query.to_string(); + if query.trim().is_empty() { + self.search_results.clear(); + cx.notify(); + return; + } + + self.is_searching = true; + cx.notify(); + + let client = self.client.clone(); + let root = self.root_path.clone(); + let q = query.to_string(); + + cx.spawn(async move |this, cx| { + let task = client.search(&cx, &root, &q); + + match task.await { + Ok(matches) => { + let items: Vec = matches + .into_iter() + .map(|m| SearchResultItem { + path: m + .get("path") + .and_then(|p| p.as_str()) + .unwrap_or("") + .to_string(), + line: m + .get("line") + .and_then(|l| l.as_u64()) + .map(|l| l as usize), + preview: m + .get("preview") + .and_then(|p| p.as_str()) + .map(|s| s.to_string()), + }) + .collect(); + + let _ = this.update(cx, |sidebar, cx| { + sidebar.search_results = items; + sidebar.is_searching = false; + cx.notify(); + }); + } + Err(_) => { + let _ = this.update(cx, |sidebar, cx| { + sidebar.search_results.clear(); + sidebar.is_searching = false; + cx.notify(); + }); + } + } + }) + .detach(); + } + + /// 刷新 Git 状态 + pub fn refresh_git(&mut self, cx: &mut Context) { + self.is_git_loading = true; + cx.notify(); + + let client = self.client.clone(); + let root = self.root_path.clone(); + + cx.spawn(async move |this, cx| { + let task = client.git_status(&cx, &root); + + match task.await { + Ok(val) => { + let branch = val + .get("branch") + .and_then(|b| b.as_str()) + .map(|s| s.to_string()); + + let mut changes = Vec::new(); + if let Some(arr) = val.get("changes").and_then(|c| c.as_array()) { + for item in arr { + let path = item + .get("path") + .and_then(|p| p.as_str()) + .unwrap_or("") + .to_string(); + let status = item + .get("status") + .and_then(|s| s.as_str()) + .unwrap_or("M") + .to_string(); + let staged = item + .get("staged") + .and_then(|s| s.as_bool()) + .unwrap_or(false); + + changes.push(GitChangeItem { + path, + status, + staged, + }); + } + } + + let _ = this.update(cx, |sidebar, cx| { + sidebar.git_branch = branch; + sidebar.git_changes = changes; + sidebar.is_git_loading = false; + cx.notify(); + }); + } + Err(_) => { + let _ = this.update(cx, |sidebar, cx| { + sidebar.is_git_loading = false; + cx.notify(); + }); + } + } + }) + .detach(); + } + + pub fn toggle_directory(&mut self, path: &str, cx: &mut Context) { + if let Some(root) = &mut self.root_node { + Self::toggle_dir_in_entry(root, path); + cx.notify(); + } + } + + fn toggle_dir_in_entry(entry: &mut FileEntry, target_path: &str) -> bool { + if entry.path == target_path && entry.is_directory { + entry.is_expanded = !entry.is_expanded; + return true; + } + + if let Some(children) = &mut entry.children { + for child in children { + if Self::toggle_dir_in_entry(child, target_path) { + return true; + } + } + } + false + } +} + +impl Render for SidebarView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .size_full() + .bg(rgb(0x161724)) + .border_r_1() + .border_color(rgb(0x23263b)) + .child( + // 顶部视图切换栏 (Explorer / Search / Git) + h_flex() + .h(px(36.0)) + .w_full() + .bg(rgb(0x141522)) + .border_b_1() + .border_color(rgb(0x23263b)) + .items_center() + .justify_between() + .px_2() + .child( + h_flex() + .items_center() + .gap_1() + .child( + Button::new("tab-explorer") + .small() + .when(self.active_tab == SidebarTab::Explorer, |b| { + b.primary() + }) + .when(self.active_tab != SidebarTab::Explorer, |b| { + b.ghost() + }) + .label("Files") + .on_click(cx.listener(|this, _event, _window, cx| { + this.set_tab(SidebarTab::Explorer, cx); + })), + ) + .child( + Button::new("tab-search") + .small() + .when(self.active_tab == SidebarTab::Search, |b| { + b.primary() + }) + .when(self.active_tab != SidebarTab::Search, |b| { + b.ghost() + }) + .label("Search") + .on_click(cx.listener(|this, _event, _window, cx| { + this.set_tab(SidebarTab::Search, cx); + })), + ) + .child( + Button::new("tab-git") + .small() + .when(self.active_tab == SidebarTab::Git, |b| b.primary()) + .when(self.active_tab != SidebarTab::Git, |b| b.ghost()) + .label(format!("Git ({})", self.git_changes.len())) + .on_click(cx.listener(|this, _event, _window, cx| { + this.set_tab(SidebarTab::Git, cx); + })), + ), + ) + .child( + Button::new("refresh-btn") + .small() + .ghost() + .label("↻") + .on_click(cx.listener(|this, _event, _window, cx| { + this.refresh(cx); + this.refresh_git(cx); + })), + ), + ) + .child( + // 对应面板内容渲染 + div().flex_1().w_full().child( + match self.active_tab { + SidebarTab::Explorer => self.render_explorer(cx).into_any_element(), + SidebarTab::Search => self.render_search(cx).into_any_element(), + SidebarTab::Git => self.render_git(cx).into_any_element(), + }, + ), + ) + } +} + +impl SidebarView { + fn render_explorer(&self, cx: &mut Context) -> impl IntoElement { + let mut visible_items = Vec::new(); + if let Some(root) = &self.root_node { + root.collect_visible(0, &mut visible_items); + } + + v_flex() + .size_full() + .p_2() + .gap_1() + .when(self.is_loading, |this| { + this.child( + div() + .text_xs() + .text_color(rgb(0x8a90a2)) + .p_2() + .child("Loading workspace files..."), + ) + }) + .when_some(self.error_message.as_ref(), |this, err| { + this.child( + div() + .text_xs() + .text_color(rgb(0xef4444)) + .p_2() + .child(err.clone()), + ) + }) + .children(visible_items.into_iter().enumerate().map(|(idx, item)| { + let is_selected = self.selected_path.as_deref() == Some(&item.path); + let path_str = item.path.clone(); + let is_dir = item.is_directory; + let indent = item.depth as f32 * 12.0; + + h_flex() + .id(idx) + .h(px(24.0)) + .w_full() + .items_center() + .cursor_pointer() + .rounded_sm() + .pl(px(indent + 4.0)) + .pr_2() + .text_xs() + .when(is_selected, |row| { + row.bg(rgb(0x2a2d42)).text_color(rgb(0xffffff)) + }) + .when(!is_selected, |row| { + row.text_color(rgb(0xcfd3e0)).hover(|h| h.bg(rgb(0x1e2030))) + }) + .child(if is_dir { + if item.is_expanded { "📂 " } else { "📁 " } + } else { + "📄 " + }) + .child(item.name) + .on_click(cx.listener({ + let path = path_str.clone(); + move |this, _event, _window, cx| { + if is_dir { + this.toggle_directory(&path, cx); + } else { + this.selected_path = Some(path.clone()); + cx.emit(SidebarEvent::OpenFile(path.clone())); + cx.notify(); + } + } + })) + })) + } + + fn render_search(&self, cx: &mut Context) -> impl IntoElement { + v_flex() + .size_full() + .p_2() + .gap_2() + .child( + h_flex() + .h(px(28.0)) + .w_full() + .items_center() + .bg(rgb(0x1e2030)) + .rounded_sm() + .px_2() + .child( + div() + .text_xs() + .text_color(rgb(0x8a90a2)) + .child("Search files or text..."), + ), + ) + .when(self.is_searching, |this| { + this.child( + div() + .text_xs() + .text_color(rgb(0x8a90a2)) + .child("Searching..."), + ) + }) + .children( + self.search_results + .iter() + .enumerate() + .map(|(idx, res)| { + let path = res.path.clone(); + let line_info = res + .line + .map(|l| format!(":{}", l)) + .unwrap_or_default(); + let preview = res.preview.clone().unwrap_or_default(); + + v_flex() + .id(idx) + .p_1() + .rounded_sm() + .cursor_pointer() + .hover(|h| h.bg(rgb(0x1e2030))) + .child( + h_flex() + .items_center() + .gap_1() + .text_xs() + .text_color(rgb(0x60a5fa)) + .child(format!("{}{}", path, line_info)), + ) + .child( + div() + .text_xs() + .text_color(rgb(0x9ca3af)) + .child(preview), + ) + .on_click(cx.listener({ + let p = path.clone(); + move |this, _event, _window, cx| { + this.selected_path = Some(p.clone()); + cx.emit(SidebarEvent::OpenFile(p.clone())); + cx.notify(); + } + })) + }), + ) + } + + fn render_git(&self, cx: &mut Context) -> impl IntoElement { + let branch = self.git_branch.clone().unwrap_or_else(|| "DETACHED".to_string()); + + v_flex() + .size_full() + .p_2() + .gap_2() + .child( + h_flex() + .items_center() + .gap_2() + .pb_2() + .border_b_1() + .border_color(rgb(0x23263b)) + .text_xs() + .text_color(rgb(0x10b981)) + .child("⎇ Branch:") + .child(branch), + ) + .child( + div() + .text_xs() + .text_color(rgb(0x8a90a2)) + .child(format!("Changed Files ({})", self.git_changes.len())), + ) + .children(self.git_changes.iter().enumerate().map(|(idx, change)| { + let path = change.path.clone(); + let status = change.status.clone(); + + h_flex() + .id(idx) + .h(px(24.0)) + .w_full() + .items_center() + .justify_between() + .px_2() + .rounded_sm() + .cursor_pointer() + .hover(|h| h.bg(rgb(0x1e2030))) + .text_xs() + .child( + h_flex() + .items_center() + .gap_2() + .text_color(rgb(0xcfd3e0)) + .child(path.clone()), + ) + .child( + div() + .text_color(rgb(0xf59e0b)) + .child(status), + ) + .on_click(cx.listener({ + let p = path.clone(); + move |this, _event, _window, cx| { + this.selected_path = Some(p.clone()); + cx.emit(SidebarEvent::OpenFile(p.clone())); + cx.notify(); + } + })) + })) + } +} diff --git a/linux/src/workbench/terminal.rs b/linux/src/workbench/terminal.rs new file mode 100644 index 000000000..090eaa3cb --- /dev/null +++ b/linux/src/workbench/terminal.rs @@ -0,0 +1,235 @@ +use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::scroll::ScrollableElement as _; +use gpui_kit::component::{h_flex, v_flex, Sizable as _}; +use gpui_kit::{ + div, px, rgb, Context, InteractiveElement as _, IntoElement, ParentElement as _, Render, + Styled as _, Window, +}; +use portable_pty::{native_pty_system, CommandBuilder, PtySize}; +use std::io::{Read, Write}; +use std::sync::{Arc, Mutex}; +use std::thread; + +/// Linux 原生 PTY 会话 +pub struct TerminalSession { + writer: Arc>>, + pub output_lines: Arc>>, + #[allow(dead_code)] + input_buffer: Arc>, +} + +impl TerminalSession { + pub fn new(cols: u16, rows: u16, working_dir: &str) -> anyhow::Result { + let pty_system = native_pty_system(); + let pair = pty_system.openpty(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + })?; + + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()); + let mut cmd = CommandBuilder::new(&shell); + cmd.cwd(working_dir); + + let _child = pair.slave.spawn_command(cmd)?; + let mut reader = pair.master.try_clone_reader()?; + let writer = pair.master.take_writer()?; + + let output_lines = Arc::new(Mutex::new(vec![format!( + "Lithe Terminal (PTY Session: {shell})" + )])); + let output_lines_clone = Arc::clone(&output_lines); + + thread::spawn(move || { + let mut buf = [0u8; 1024]; + let mut line_accumulator = String::new(); + + while let Ok(n) = reader.read(&mut buf) { + if n == 0 { + break; + } + let chunk = String::from_utf8_lossy(&buf[..n]); + for ch in chunk.chars() { + if ch == '\n' { + let mut lines = output_lines_clone.lock().unwrap(); + lines.push(line_accumulator.clone()); + if lines.len() > 1000 { + lines.remove(0); + } + line_accumulator.clear(); + } else if ch == '\r' { + // ignore carriage return + } else { + line_accumulator.push(ch); + } + } + if !line_accumulator.is_empty() { + let mut lines = output_lines_clone.lock().unwrap(); + if let Some(last) = lines.last_mut() { + *last = line_accumulator.clone(); + } + } + } + }); + + Ok(Self { + writer: Arc::new(Mutex::new(writer)), + output_lines, + input_buffer: Arc::new(Mutex::new(String::new())), + }) + } + + pub fn write_input(&self, input: &str) -> anyhow::Result<()> { + let mut writer = self.writer.lock().unwrap(); + writer.write_all(input.as_bytes())?; + writer.flush()?; + Ok(()) + } + + pub fn get_lines(&self) -> Vec { + self.output_lines.lock().unwrap().clone() + } +} + +/// 终端视图组件 +pub struct TerminalView { + pub session: Option, + pub current_input: String, + pub working_dir: String, +} + +impl TerminalView { + pub fn new(working_dir: String, _cx: &mut Context) -> Self { + let session = TerminalSession::new(120, 30, &working_dir).ok(); + + Self { + session, + current_input: String::new(), + working_dir, + } + } + + pub fn send_command(&mut self, cmd: &str, cx: &mut Context) { + if let Some(session) = &self.session { + let mut full = cmd.to_string(); + full.push('\n'); + let _ = session.write_input(&full); + } + self.current_input.clear(); + cx.notify(); + } + + pub fn clear(&mut self, cx: &mut Context) { + if let Some(session) = &self.session { + if let Ok(mut lines) = session.output_lines.lock() { + lines.clear(); + lines.push("Terminal cleared.".to_string()); + } + } + cx.notify(); + } +} + +impl Render for TerminalView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let lines = self + .session + .as_ref() + .map(|s| s.get_lines()) + .unwrap_or_else(|| vec!["PTY unavailable".to_string()]); + + v_flex() + .size_full() + .bg(rgb(0x0e0f17)) + .p_2() + .child( + // 终端顶部控制栏 + h_flex() + .h(px(28.0)) + .w_full() + .justify_between() + .items_center() + .border_b_1() + .border_color(rgb(0x23263b)) + .pb_1() + .child( + h_flex() + .items_center() + .gap_2() + .text_xs() + .text_color(rgb(0x8a90a2)) + .child("Linux PTY Shell") + .child(format!("({})", self.working_dir)), + ) + .child( + Button::new("clear-term") + .small() + .ghost() + .label("Clear") + .on_click(cx.listener(|this, _event, _window, cx| { + this.clear(cx); + })), + ), + ) + .child( + // 终端输出行展示区 + div() + .flex_1() + .w_full() + .overflow_y_scrollbar() + .p_2() + .text_xs() + .text_color(rgb(0xd1d5db)) + .font_family("monospace") + .children(lines.into_iter().enumerate().map(|(idx, line)| { + div() + .id(idx) + .child(if line.is_empty() { " ".to_string() } else { line }) + })), + ) + .child( + // 命令行输入条 + h_flex() + .h(px(32.0)) + .w_full() + .bg(rgb(0x141522)) + .border_t_1() + .border_color(rgb(0x23263b)) + .items_center() + .px_2() + .gap_2() + .child( + div() + .text_xs() + .text_color(rgb(0x10b981)) + .font_family("monospace") + .child("$"), + ) + .child( + div() + .flex_1() + .text_xs() + .text_color(rgb(0xffffff)) + .font_family("monospace") + .child(if self.current_input.is_empty() { + "Type command here...".to_string() + } else { + self.current_input.clone() + }), + ) + .child( + Button::new("run-cmd") + .small() + .primary() + .label("Execute") + .on_click(cx.listener(|this, _event, _window, cx| { + let cmd = this.current_input.clone(); + if !cmd.is_empty() { + this.send_command(&cmd, cx); + } + })), + ), + ) + } +} diff --git a/linux/src/workbench/toolbar.rs b/linux/src/workbench/toolbar.rs new file mode 100644 index 000000000..20b83d076 --- /dev/null +++ b/linux/src/workbench/toolbar.rs @@ -0,0 +1,126 @@ +use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::{h_flex, Sizable as _}; +use gpui_kit::{ + div, px, rgb, Context, EventEmitter, IntoElement, ParentElement as _, + Render, Styled as _, Window, +}; + +#[derive(Debug, Clone)] +pub enum ToolbarEvent { + Save, + Run, + ToggleTerminal, +} + +pub struct ToolbarView { + pub workspace_name: String, + pub git_branch: Option, +} + +impl EventEmitter for ToolbarView {} + +impl ToolbarView { + pub fn new(workspace_root: &str) -> Self { + let name = workspace_root + .rsplit_once('/') + .map(|(_, n)| n.to_string()) + .unwrap_or_else(|| workspace_root.to_string()); + + Self { + workspace_name: name, + git_branch: None, + } + } + + #[allow(dead_code)] + pub fn set_git_branch(&mut self, branch: Option, cx: &mut Context) { + self.git_branch = branch; + cx.notify(); + } +} + +impl Render for ToolbarView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let branch = self.git_branch.as_deref().unwrap_or("main"); + + h_flex() + .h(px(40.0)) + .w_full() + .bg(rgb(0x10111a)) + .border_b_1() + .border_color(rgb(0x23263b)) + .items_center() + .justify_between() + .px_3() + .child( + // 左侧:项目名称与分支信息 + h_flex() + .items_center() + .gap_3() + .child( + div() + .text_sm() + .text_color(rgb(0xffffff)) + .child(format!("⚡ Lithe | {}", self.workspace_name)), + ) + .child( + div() + .px_2() + .py(px(2.0)) + .rounded_sm() + .bg(rgb(0x1e2030)) + .text_xs() + .text_color(rgb(0x10b981)) + .child(format!("⎇ {}", branch)), + ), + ) + .child( + // 中间:运行与调试配置 + h_flex() + .items_center() + .gap_2() + .child( + Button::new("tb-run") + .small() + .primary() + .label("▶ Run") + .on_click(cx.listener(|_this, _event, _window, cx| { + cx.emit(ToolbarEvent::Run); + })), + ) + .child( + Button::new("tb-debug") + .small() + .ghost() + .label("🐞 Debug") + .on_click(cx.listener(|_this, _event, _window, cx| { + cx.emit(ToolbarEvent::Run); + })), + ), + ) + .child( + // 右侧:保存与面板切换 + h_flex() + .items_center() + .gap_2() + .child( + Button::new("tb-save") + .small() + .ghost() + .label("💾 Save") + .on_click(cx.listener(|_this, _event, _window, cx| { + cx.emit(ToolbarEvent::Save); + })), + ) + .child( + Button::new("tb-terminal") + .small() + .ghost() + .label("⌨ Terminal") + .on_click(cx.listener(|_this, _event, _window, cx| { + cx.emit(ToolbarEvent::ToggleTerminal); + })), + ), + ) + } +} diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs new file mode 100644 index 000000000..3170f8666 --- /dev/null +++ b/linux/src/workbench/view.rs @@ -0,0 +1,162 @@ +use gpui_kit::component::{h_flex, v_flex, ActiveTheme as _}; +use gpui_kit::{ + div, px, rgb, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, + Styled as _, Subscription, Window, +}; + +use crate::core::CoreClient; +use crate::workbench::bottom_panel::BottomPanelView; +use crate::workbench::editor::EditorView; +use crate::workbench::sidebar::{SidebarEvent, SidebarView}; +use crate::workbench::toolbar::{ToolbarEvent, ToolbarView}; + +/// Linux 前端主工作台视图组件 +pub struct WorkbenchView { + /// 项目工作区根路径 + pub workspace_root: String, + /// 顶部工具栏 + pub toolbar: Entity, + /// 侧边栏视图实体 + pub sidebar: Entity, + /// 编辑器视图实体 + pub editor: Entity, + /// 底部面板视图实体 + pub bottom_panel: Entity, + client: CoreClient, + _subscriptions: Vec, +} + +impl WorkbenchView { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let workspace_root = std::env::current_dir() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + Self::with_root(workspace_root, window, cx) + } + + pub fn with_root( + workspace_root: String, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + let root_for_toolbar = workspace_root.clone(); + let root_for_sidebar = workspace_root.clone(); + let root_for_editor = workspace_root.clone(); + let root_for_bottom = workspace_root.clone(); + + let toolbar = cx.new(|_cx| ToolbarView::new(&root_for_toolbar)); + let sidebar = cx.new(|cx| SidebarView::new(root_for_sidebar, cx)); + let editor = cx.new(|cx| EditorView::new(root_for_editor, cx)); + let bottom_panel = cx.new(|cx| BottomPanelView::new(root_for_bottom, cx)); + + // 1. 订阅侧边栏事件:点击文件打开 + let sub_sidebar = cx.subscribe(&sidebar, |this, _sidebar, event: &SidebarEvent, cx| { + match event { + SidebarEvent::OpenFile(path) => { + this.open_file(path, cx); + } + } + }); + + // 2. 订阅工具栏事件:保存、运行、终端切换 + let sub_toolbar = cx.subscribe(&toolbar, |this, _toolbar, event: &ToolbarEvent, cx| { + match event { + ToolbarEvent::Save => { + let _ = this.editor.update(cx, |ed, cx| { + ed.save_active(cx); + }); + } + ToolbarEvent::ToggleTerminal => { + let _ = this.bottom_panel.update(cx, |bp, cx| { + bp.toggle_collapsed(cx); + }); + } + ToolbarEvent::Run => { + let _ = this.bottom_panel.update(cx, |bp, cx| { + bp.append_log("[Run] Executing run configuration...".to_string(), cx); + let _ = bp.terminal.update(cx, |term, cx| { + term.send_command("echo '[Lithe Run]' && ls -la", cx); + }); + }); + } + } + }); + + Self { + workspace_root, + toolbar, + sidebar, + editor, + bottom_panel, + client: CoreClient::new(), + _subscriptions: vec![sub_sidebar, sub_toolbar], + } + } + + /// 打开指定文件:对接 `lithe-core` 的 `read_file` 并更新编辑器 + pub fn open_file(&mut self, relative_path: &str, cx: &mut Context) { + let root = self.workspace_root.clone(); + let path = relative_path.to_string(); + let editor = self.editor.clone(); + let client = self.client.clone(); + + cx.spawn(async move |_this, cx| { + let task = client.read_file(&cx, &root, &path); + + match task.await { + Ok(text) => { + let _ = editor.update(cx, |ed, cx| { + ed.open_file(path, text, cx); + }); + } + Err(err) => { + let err_msg = format!("// 读取文件失败: {}\n// 错误信息: {}", path, err); + let _ = editor.update(cx, |ed, cx| { + ed.open_file(path, err_msg, cx); + }); + } + } + }) + .detach(); + } +} + +impl Render for WorkbenchView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .size_full() + .bg(cx.theme().background) + .child( + // 1. 顶部工具栏 + self.toolbar.clone(), + ) + .child( + // 2. 主体区域(左右布局) + h_flex() + .flex_1() + .w_full() + .child( + // 左侧分栏:侧边栏(Explorer / Search / Git) + div() + .w(px(280.0)) + .h_full() + .flex_shrink_0() + .border_r_1() + .border_color(rgb(0x23263b)) + .child(self.sidebar.clone()), + ) + .child( + // 右侧分栏:多标签代码编辑器 + div() + .flex_1() + .h_full() + .child(self.editor.clone()), + ), + ) + .child( + // 3. 底部面板(终端 / 日志 / 问题) + self.bottom_panel.clone(), + ) + } +} diff --git a/scripts/build-linux.sh b/scripts/build-linux.sh new file mode 100755 index 000000000..9457d86b7 --- /dev/null +++ b/scripts/build-linux.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" + +PROFILE="debug" +TARGET="" +EXTRA_ARGS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --release) + PROFILE="release" + shift + ;; + --debug) + PROFILE="debug" + shift + ;; + --target) + TARGET="$2" + shift 2 + ;; + *) + EXTRA_ARGS+=("$1") + shift + ;; + esac +done + +cd "$ROOT_DIR" + +CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT_DIR/target}" +export CARGO_TARGET_DIR + +BUILD_ARGS=(build --manifest-path "$ROOT_DIR/linux/Cargo.toml" -p lithe-linux) + +if [[ -n "$TARGET" ]]; then + BUILD_ARGS+=(--target "$TARGET") +fi + +if [[ "$PROFILE" == "release" ]]; then + BUILD_ARGS+=(--release) +fi + +if [[ ${#EXTRA_ARGS[@]} -gt 0 ]]; then + BUILD_ARGS+=("${EXTRA_ARGS[@]}") +fi + +printf '==> Building Lithe Linux frontend (%s)...\n' "$PROFILE" +cargo "${BUILD_ARGS[@]}" + +OUTPUT_BIN="$CARGO_TARGET_DIR" +if [[ -n "$TARGET" ]]; then + OUTPUT_BIN="$OUTPUT_BIN/$TARGET" +fi +OUTPUT_BIN="$OUTPUT_BIN/$PROFILE/lithe-linux" + +if [[ -f "$OUTPUT_BIN" ]]; then + printf '==> Build successful: %s\n' "$OUTPUT_BIN" +else + printf '==> Error: Output binary not found at %s\n' "$OUTPUT_BIN" >&2 + exit 1 +fi diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh new file mode 100755 index 000000000..ae0e6d1cd --- /dev/null +++ b/scripts/package-linux.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" + +PROFILE="release" +VERSION="${LITHE_VERSION:-0.1.0}" +ARCH="${LITHE_ARCH:-$(uname -m)}" +DIST_DIR="${LITHE_DIST_DIR:-$ROOT_DIR/dist}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --debug) + PROFILE="debug" + shift + ;; + --release) + PROFILE="release" + shift + ;; + --version) + VERSION="$2" + shift 2 + ;; + *) + shift + ;; + esac +done + +cd "$ROOT_DIR" + +# 1. Build lithe-linux binary +"$SCRIPT_DIR/build-linux.sh" "--$PROFILE" + +TARGET_BIN="${CARGO_TARGET_DIR:-$ROOT_DIR/target}/$PROFILE/lithe-linux" +PACKAGE_NAME="lithe-linux-${ARCH}-${VERSION}" +STAGE_DIR="$DIST_DIR/$PACKAGE_NAME" + +rm -rf "$STAGE_DIR" +mkdir -p "$STAGE_DIR/bin" +mkdir -p "$STAGE_DIR/share/applications" +mkdir -p "$STAGE_DIR/share/icons/hicolor/512x512/apps" + +# 2. Copy binary +cp "$TARGET_BIN" "$STAGE_DIR/bin/lithe" +chmod +x "$STAGE_DIR/bin/lithe" + +# 3. Create Desktop Entry +cat < "$STAGE_DIR/share/applications/lithe.desktop" +[Desktop Entry] +Name=Lithe +Comment=Lithe IDE for Linux +Exec=lithe %F +Icon=lithe +Terminal=false +Type=Application +Categories=Development;IDE; +StartupWMClass=lithe +EOF + +# 4. Copy icon if available +ICON_SRC="$ROOT_DIR/assets/icon.png" +if [[ ! -f "$ICON_SRC" ]]; then + ICON_SRC=$(find "$ROOT_DIR/windows/tauri/src/extensions" -name "linux.svg" | head -n 1 || true) +fi + +if [[ -f "$ICON_SRC" ]]; then + cp "$ICON_SRC" "$STAGE_DIR/share/icons/hicolor/512x512/apps/lithe.png" || true +fi + +# 5. Create tar.gz archive +mkdir -p "$DIST_DIR" +TARBALL="$DIST_DIR/${PACKAGE_NAME}.tar.gz" +tar -czf "$TARBALL" -C "$DIST_DIR" "$PACKAGE_NAME" + +printf '==> Linux package created: %s\n' "$TARBALL" From 0bb39ab5710d1945b1af1f430307d2b9ea8834dc Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Thu, 24 Sep 2026 00:00:17 +0800 Subject: [PATCH 002/109] =?UTF-8?q?feat(linux):=20=E5=AF=B9=E9=BD=90=20Tau?= =?UTF-8?q?ri/IntelliJ=20=E6=9E=B6=E6=9E=84=E8=AE=BE=E8=AE=A1=E4=B8=8E?= =?UTF-8?q?=E6=9A=97=E8=89=B2=E4=B8=BB=E9=A2=98=20Tab=20=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ThemeColors 集中定义深色主题调色板,并在应用入口强制开启 ThemeMode::Dark - 重构标题栏 (ToolbarView):加入左上角完整桌面菜单栏 (File/Edit/View/Run/Terminal/Help)、项目与分支选择徽章、Search Everywhere 快捷入口 - 新增垂直活动栏 (ActivityRailView):支持在 Files、Git、Search 与 Terminal 间切换/展开侧边栏 - 重构多标签代码区 (EditorView):新增面包屑路径导航条与代码行号槽 - 重写底栏 (BottomPanelView) 与侧边栏 (SidebarView) Tab 控件,修复暗色下按钮低对比度不可见问题 - 新增全局底部状态栏 (StatusBarView):统一展示分支、光标位置、语言及编码状态 --- linux/src/lib.rs | 1 + linux/src/main.rs | 4 +- linux/src/theme.rs | 85 +++++++ linux/src/workbench/activity_rail.rs | 147 ++++++++++++ linux/src/workbench/bottom_panel.rs | 119 +++++----- linux/src/workbench/editor.rs | 103 ++++----- linux/src/workbench/mod.rs | 6 + linux/src/workbench/sidebar.rs | 125 ++++++----- linux/src/workbench/status_bar.rs | 110 +++++++++ linux/src/workbench/toolbar.rs | 321 ++++++++++++++++++++++++--- linux/src/workbench/view.rs | 202 ++++++++++++++--- 11 files changed, 992 insertions(+), 231 deletions(-) create mode 100644 linux/src/theme.rs create mode 100644 linux/src/workbench/activity_rail.rs create mode 100644 linux/src/workbench/status_bar.rs diff --git a/linux/src/lib.rs b/linux/src/lib.rs index 7c4fe1ee7..f8a5e12e9 100644 --- a/linux/src/lib.rs +++ b/linux/src/lib.rs @@ -1,4 +1,5 @@ pub mod core; +pub mod theme; pub mod workbench; pub use workbench::WorkbenchView; diff --git a/linux/src/main.rs b/linux/src/main.rs index 69c77ca8e..e614fba38 100644 --- a/linux/src/main.rs +++ b/linux/src/main.rs @@ -1,7 +1,8 @@ mod core; +mod theme; mod workbench; -use gpui_kit::component::Root; +use gpui_kit::component::{Root, Theme, ThemeMode}; use gpui_kit::*; use workbench::WorkbenchView; @@ -10,6 +11,7 @@ fn main() { app.run(move |cx| { gpui_kit::init(cx); + Theme::change(ThemeMode::Dark, None, cx); cx.open_window(WindowOptions::default(), |window, cx| { let view = cx.new(|cx| WorkbenchView::new(window, cx)); diff --git a/linux/src/theme.rs b/linux/src/theme.rs new file mode 100644 index 000000000..6b9bacdf0 --- /dev/null +++ b/linux/src/theme.rs @@ -0,0 +1,85 @@ +use gpui_kit::Rgba; + +pub const fn const_rgb(hex: u32) -> Rgba { + let r = ((hex >> 16) & 0xFF) as f32 / 255.0; + let g = ((hex >> 8) & 0xFF) as f32 / 255.0; + let b = (hex & 0xFF) as f32 / 255.0; + Rgba { r, g, b, a: 1.0 } +} + +pub struct ThemeColors; + +#[allow(dead_code)] +impl ThemeColors { + pub const BG_TITLEBAR: Rgba = const_rgb(0x1e1f22); + pub const BG_ACTIVITY_RAIL: Rgba = const_rgb(0x18191c); + pub const BG_SIDEBAR: Rgba = const_rgb(0x1e1f22); + pub const BG_EDITOR: Rgba = const_rgb(0x2b2d30); + pub const BG_TAB_BAR: Rgba = const_rgb(0x1e1f22); + pub const BG_TAB_ACTIVE: Rgba = const_rgb(0x2b2d30); + pub const BG_TAB_HOVER: Rgba = const_rgb(0x24262b); + pub const BG_BOTTOM_PANEL: Rgba = const_rgb(0x1e1f22); + pub const BG_STATUSBAR: Rgba = const_rgb(0x18191c); + pub const BORDER: Rgba = const_rgb(0x393b40); + pub const ACCENT_BLUE: Rgba = const_rgb(0x3574f0); + pub const ACCENT_GREEN: Rgba = const_rgb(0x39a36b); + pub const TEXT_PRIMARY: Rgba = const_rgb(0xdfdfe0); + pub const TEXT_MUTED: Rgba = const_rgb(0x818594); + + #[inline] + pub fn bg_titlebar() -> Rgba { + Self::BG_TITLEBAR + } + #[inline] + pub fn bg_activity_rail() -> Rgba { + Self::BG_ACTIVITY_RAIL + } + #[inline] + pub fn bg_sidebar() -> Rgba { + Self::BG_SIDEBAR + } + #[inline] + pub fn bg_editor() -> Rgba { + Self::BG_EDITOR + } + #[inline] + pub fn bg_tab_bar() -> Rgba { + Self::BG_TAB_BAR + } + #[inline] + pub fn bg_tab_active() -> Rgba { + Self::BG_TAB_ACTIVE + } + #[inline] + pub fn bg_tab_hover() -> Rgba { + Self::BG_TAB_HOVER + } + #[inline] + pub fn bg_bottom_panel() -> Rgba { + Self::BG_BOTTOM_PANEL + } + #[inline] + pub fn bg_statusbar() -> Rgba { + Self::BG_STATUSBAR + } + #[inline] + pub fn border() -> Rgba { + Self::BORDER + } + #[inline] + pub fn accent_blue() -> Rgba { + Self::ACCENT_BLUE + } + #[inline] + pub fn accent_green() -> Rgba { + Self::ACCENT_GREEN + } + #[inline] + pub fn text_primary() -> Rgba { + Self::TEXT_PRIMARY + } + #[inline] + pub fn text_muted() -> Rgba { + Self::TEXT_MUTED + } +} diff --git a/linux/src/workbench/activity_rail.rs b/linux/src/workbench/activity_rail.rs new file mode 100644 index 000000000..a8279f5d9 --- /dev/null +++ b/linux/src/workbench/activity_rail.rs @@ -0,0 +1,147 @@ +use gpui_kit::component::v_flex; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::{ + div, px, Context, EventEmitter, InteractiveElement as _, IntoElement, ParentElement as _, + Render, StatefulInteractiveElement as _, Styled as _, Window, +}; + +use crate::theme::ThemeColors; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActivityTab { + Explorer, + Git, + Search, +} + +#[derive(Debug, Clone)] +pub enum ActivityRailEvent { + SelectTab(ActivityTab), + ToggleTerminal, + OpenSettings, +} + +pub struct ActivityRailView { + pub active_tab: Option, +} + +impl EventEmitter for ActivityRailView {} + +impl ActivityRailView { + pub fn new() -> Self { + Self { + active_tab: Some(ActivityTab::Explorer), + } + } + + pub fn set_active_tab(&mut self, tab: Option, cx: &mut Context) { + self.active_tab = tab; + cx.notify(); + } +} + +impl Render for ActivityRailView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let active = self.active_tab; + + v_flex() + .w(px(42.0)) + .h_full() + .bg(ThemeColors::bg_activity_rail()) + .border_r_1() + .border_color(ThemeColors::border()) + .items_center() + .justify_between() + .py_2() + .child( + // 顶部工具窗口图标列表 + v_flex() + .w_full() + .items_center() + .gap_1() + .child(self.render_rail_button( + "rail-files", + "📁", + "Files (Project Explorer)", + active == Some(ActivityTab::Explorer), + ActivityRailEvent::SelectTab(ActivityTab::Explorer), + cx, + )) + .child(self.render_rail_button( + "rail-git", + "⎇", + "Version Control (Git)", + active == Some(ActivityTab::Git), + ActivityRailEvent::SelectTab(ActivityTab::Git), + cx, + )) + .child(self.render_rail_button( + "rail-search", + "🔍", + "Search Everywhere", + active == Some(ActivityTab::Search), + ActivityRailEvent::SelectTab(ActivityTab::Search), + cx, + )) + .child(self.render_rail_button( + "rail-terminal", + "⌨", + "Terminal", + false, + ActivityRailEvent::ToggleTerminal, + cx, + )), + ) + .child( + // 底部设置图标 + v_flex() + .w_full() + .items_center() + .child(self.render_rail_button( + "rail-settings", + "⚙", + "Settings", + false, + ActivityRailEvent::OpenSettings, + cx, + )), + ) + } +} + +impl ActivityRailView { + fn render_rail_button( + &self, + id: &'static str, + icon: &'static str, + _tooltip: &'static str, + is_active: bool, + event: ActivityRailEvent, + cx: &mut Context, + ) -> impl IntoElement { + div() + .id(id) + .w(px(34.0)) + .h(px(34.0)) + .rounded_md() + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .text_sm() + .when(is_active, |btn| { + btn.bg(ThemeColors::bg_sidebar()) + .text_color(ThemeColors::accent_blue()) + .border_l_2() + .border_color(ThemeColors::accent_blue()) + }) + .when(!is_active, |btn| { + btn.text_color(ThemeColors::text_muted()) + .hover(|h| h.bg(ThemeColors::bg_tab_hover()).text_color(ThemeColors::text_primary())) + }) + .child(icon) + .on_click(cx.listener(move |_this, _event, _window, cx| { + cx.emit(event.clone()); + })) + } +} diff --git a/linux/src/workbench/bottom_panel.rs b/linux/src/workbench/bottom_panel.rs index 4a5406f2a..8fea5f41a 100644 --- a/linux/src/workbench/bottom_panel.rs +++ b/linux/src/workbench/bottom_panel.rs @@ -3,10 +3,11 @@ use gpui_kit::component::scroll::ScrollableElement as _; use gpui_kit::component::{h_flex, v_flex, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, rgb, AppContext as _, Context, Entity, IntoElement, - ParentElement as _, Render, Styled as _, Window, + div, px, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, + ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, Window, }; +use crate::theme::ThemeColors; use crate::workbench::terminal::TerminalView; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -52,17 +53,48 @@ impl BottomPanelView { self.output_logs.push(log); cx.notify(); } + + fn render_tab_button( + &self, + id: &'static str, + label: String, + is_active: bool, + tab: BottomTab, + cx: &mut Context, + ) -> impl IntoElement { + div() + .id(id) + .px_3() + .py_1() + .rounded_t_sm() + .cursor_pointer() + .text_xs() + .when(is_active, |btn| { + btn.bg(ThemeColors::bg_bottom_panel()) + .text_color(ThemeColors::text_primary()) + .border_b_2() + .border_color(ThemeColors::accent_blue()) + }) + .when(!is_active, |btn| { + btn.text_color(ThemeColors::text_muted()) + .hover(|h| h.bg(ThemeColors::bg_tab_hover()).text_color(ThemeColors::text_primary())) + }) + .child(label) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.set_tab(tab, cx); + })) + } } impl Render for BottomPanelView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { if self.is_collapsed { return div() - .h(px(28.0)) + .h(px(26.0)) .w_full() - .bg(rgb(0x141522)) + .bg(ThemeColors::BG_STATUSBAR) .border_t_1() - .border_color(rgb(0x23263b)) + .border_color(ThemeColors::BORDER) .flex() .items_center() .justify_between() @@ -70,7 +102,7 @@ impl Render for BottomPanelView { .child( div() .text_xs() - .text_color(rgb(0x8a90a2)) + .text_color(ThemeColors::TEXT_MUTED) .child("Panel collapsed"), ) .child( @@ -87,17 +119,17 @@ impl Render for BottomPanelView { v_flex() .h(px(240.0)) .w_full() - .bg(rgb(0x11121d)) + .bg(ThemeColors::BG_BOTTOM_PANEL) .border_t_1() - .border_color(rgb(0x23263b)) + .border_color(ThemeColors::BORDER) .child( // 顶部 Tab 切换栏 h_flex() .h(px(32.0)) .w_full() - .bg(rgb(0x141522)) + .bg(ThemeColors::BG_TAB_BAR) .border_b_1() - .border_color(rgb(0x23263b)) + .border_color(ThemeColors::BORDER) .items_center() .justify_between() .px_2() @@ -105,48 +137,27 @@ impl Render for BottomPanelView { h_flex() .items_center() .gap_1() - .child( - Button::new("tab-terminal") - .small() - .when(self.active_tab == BottomTab::Terminal, |btn| { - btn.primary() - }) - .when(self.active_tab != BottomTab::Terminal, |btn| { - btn.ghost() - }) - .label("Terminal") - .on_click(cx.listener(|this, _event, _window, cx| { - this.set_tab(BottomTab::Terminal, cx); - })), - ) - .child( - Button::new("tab-output") - .small() - .when(self.active_tab == BottomTab::Output, |btn| { - btn.primary() - }) - .when(self.active_tab != BottomTab::Output, |btn| { - btn.ghost() - }) - .label("Output") - .on_click(cx.listener(|this, _event, _window, cx| { - this.set_tab(BottomTab::Output, cx); - })), - ) - .child( - Button::new("tab-problems") - .small() - .when(self.active_tab == BottomTab::Problems, |btn| { - btn.primary() - }) - .when(self.active_tab != BottomTab::Problems, |btn| { - btn.ghost() - }) - .label(format!("Problems ({})", self.problems.len())) - .on_click(cx.listener(|this, _event, _window, cx| { - this.set_tab(BottomTab::Problems, cx); - })), - ), + .child(self.render_tab_button( + "tab-terminal", + "Terminal".to_string(), + self.active_tab == BottomTab::Terminal, + BottomTab::Terminal, + cx, + )) + .child(self.render_tab_button( + "tab-output", + "Output".to_string(), + self.active_tab == BottomTab::Output, + BottomTab::Output, + cx, + )) + .child(self.render_tab_button( + "tab-problems", + format!("Problems ({})", self.problems.len()), + self.active_tab == BottomTab::Problems, + BottomTab::Problems, + cx, + )), ) .child( Button::new("collapse-panel") @@ -171,7 +182,7 @@ impl Render for BottomPanelView { .overflow_y_scrollbar() .text_xs() .font_family("monospace") - .text_color(rgb(0x9ca3af)) + .text_color(ThemeColors::text_muted()) .children( self.output_logs .iter() @@ -184,7 +195,7 @@ impl Render for BottomPanelView { .p_3() .overflow_y_scrollbar() .text_xs() - .text_color(rgb(0x9ca3af)) + .text_color(ThemeColors::text_muted()) .child(if self.problems.is_empty() { "No problems have been detected in the workspace.".to_string() } else { diff --git a/linux/src/workbench/editor.rs b/linux/src/workbench/editor.rs index 09f450427..611850fff 100644 --- a/linux/src/workbench/editor.rs +++ b/linux/src/workbench/editor.rs @@ -7,6 +7,7 @@ use gpui_kit::{ }; use crate::core::CoreClient; +use crate::theme::ThemeColors; #[derive(Debug, Clone)] pub struct EditorTab { @@ -14,11 +15,13 @@ pub struct EditorTab { pub title: String, pub content: String, pub is_dirty: bool, + #[allow(dead_code)] pub cursor_line: usize, + #[allow(dead_code)] pub cursor_col: usize, } -/// 多标签代码编辑器组件 +/// 多标签代码编辑器组件(对齐 IntelliJ / Tauri 视觉规范) pub struct EditorView { pub workspace_root: String, pub tabs: Vec, @@ -148,15 +151,15 @@ impl Render for EditorView { v_flex() .size_full() - .bg(rgb(0x13141f)) + .bg(ThemeColors::BG_EDITOR) .child( // 1. 顶部标签栏(Tab Bar) h_flex() - .h(px(36.0)) + .h(px(34.0)) .w_full() - .bg(rgb(0x10111a)) + .bg(ThemeColors::BG_TAB_BAR) .border_b_1() - .border_color(rgb(0x23263b)) + .border_color(ThemeColors::BORDER) .items_center() .overflow_x_scrollbar() .px_1() @@ -167,17 +170,19 @@ impl Render for EditorView { h_flex() .id(idx) - .h(px(32.0)) + .h(px(34.0)) .items_center() .gap_2() .px_3() - .rounded_t_sm() .cursor_pointer() .when(is_active, |t| { - t.bg(rgb(0x181926)).border_t_2().border_color(rgb(0x3b82f6)) + t.bg(ThemeColors::BG_TAB_ACTIVE) + .border_t_2() + .border_color(ThemeColors::ACCENT_BLUE) }) .when(!is_active, |t| { - t.bg(rgb(0x10111a)).hover(|h| h.bg(rgb(0x151624))) + t.bg(ThemeColors::BG_TAB_BAR) + .hover(|h| h.bg(ThemeColors::BG_TAB_HOVER)) }) .on_click(cx.listener(move |this, _event, _window, cx| { this.active_tab_index = Some(idx); @@ -187,9 +192,9 @@ impl Render for EditorView { div() .text_xs() .text_color(if is_active { - rgb(0xffffff) + ThemeColors::TEXT_PRIMARY } else { - rgb(0x9ca3af) + ThemeColors::TEXT_MUTED }) .child(title), ) @@ -197,7 +202,7 @@ impl Render for EditorView { t.child( div() .text_xs() - .text_color(rgb(0x3b82f6)) + .text_color(ThemeColors::ACCENT_BLUE) .child("●"), ) }) @@ -205,7 +210,7 @@ impl Render for EditorView { div() .id(("close-tab", idx)) .text_xs() - .text_color(rgb(0x6b7280)) + .text_color(ThemeColors::TEXT_MUTED) .hover(|h| h.text_color(rgb(0xef4444))) .child("×") .on_click(cx.listener(move |this, _event, _window, cx| { @@ -214,8 +219,31 @@ impl Render for EditorView { ) })), ) + .when_some(active_tab.as_ref(), |this, tab| { + // 2. 面包屑导航栏(Breadcrumb Bar) + this.child( + h_flex() + .h(px(24.0)) + .w_full() + .bg(ThemeColors::BG_TAB_ACTIVE) + .border_b_1() + .border_color(ThemeColors::BORDER) + .items_center() + .px_3() + .gap_1() + .text_xs() + .text_color(ThemeColors::TEXT_MUTED) + .child("📂") + .child( + tab.path + .split('/') + .collect::>() + .join(" › "), + ), + ) + }) .child( - // 2. 中央内容编辑区 + // 3. 中央代码内容区 div().flex_1().w_full().overflow_scrollbar().child( if let Some(tab) = &active_tab { let lines: Vec = tab.content.lines().map(|s| s.to_string()).collect(); @@ -227,19 +255,19 @@ impl Render for EditorView { .font_family("monospace") .text_xs() .child( - // 行号列 + // 代码行号槽(Gutter) v_flex() .flex_shrink_0() - .text_color(rgb(0x4b5563)) + .text_color(ThemeColors::TEXT_MUTED) .children((1..=lines.len().max(1)).map(|num| { div().h(px(20.0)).child(format!("{num:4}")) })), ) .child( - // 代码文本列 + // 代码文本展示行 v_flex() .flex_1() - .text_color(rgb(0xe5e7eb)) + .text_color(ThemeColors::TEXT_PRIMARY) .children(lines.into_iter().enumerate().map(|(idx, line)| { div() .id(idx) @@ -248,49 +276,16 @@ impl Render for EditorView { })), ) } else { - // 空状态 + // 无打开文件欢迎占位 h_flex() .size_full() .items_center() .justify_center() .text_sm() - .text_color(rgb(0x6b7280)) - .child("No file opened. Select a file from the sidebar.") + .text_color(ThemeColors::TEXT_MUTED) + .child("No file opened. Select a file from the project explorer or press Ctrl+P.") }, ), ) - .child( - // 3. 底部状态栏 - h_flex() - .h(px(24.0)) - .w_full() - .bg(rgb(0x10111a)) - .border_t_1() - .border_color(rgb(0x23263b)) - .items_center() - .justify_between() - .px_3() - .text_xs() - .text_color(rgb(0x6b7280)) - .child( - h_flex().items_center().gap_3().child(if let Some(tab) = &active_tab { - format!("Ln {}, Col {}", tab.cursor_line, tab.cursor_col) - } else { - "Ready".to_string() - }), - ) - .child( - h_flex() - .items_center() - .gap_3() - .child(if let Some(tab) = &active_tab { - Self::language_name(&tab.path) - } else { - "Plain Text" - }) - .child("UTF-8") - .child("Lithe Core"), - ), - ) } } diff --git a/linux/src/workbench/mod.rs b/linux/src/workbench/mod.rs index a2031fb4c..5a3e7857f 100644 --- a/linux/src/workbench/mod.rs +++ b/linux/src/workbench/mod.rs @@ -1,10 +1,14 @@ +pub mod activity_rail; pub mod bottom_panel; pub mod editor; pub mod sidebar; +pub mod status_bar; pub mod terminal; pub mod toolbar; pub mod view; +#[allow(unused_imports)] +pub use activity_rail::{ActivityRailEvent, ActivityRailView, ActivityTab}; #[allow(unused_imports)] pub use bottom_panel::BottomPanelView; #[allow(unused_imports)] @@ -12,6 +16,8 @@ pub use editor::EditorView; #[allow(unused_imports)] pub use sidebar::SidebarView; #[allow(unused_imports)] +pub use status_bar::StatusBarView; +#[allow(unused_imports)] pub use terminal::TerminalView; #[allow(unused_imports)] pub use toolbar::ToolbarView; diff --git a/linux/src/workbench/sidebar.rs b/linux/src/workbench/sidebar.rs index 66c7af64e..a2eb22e46 100644 --- a/linux/src/workbench/sidebar.rs +++ b/linux/src/workbench/sidebar.rs @@ -8,6 +8,7 @@ use gpui_kit::{ use serde::{Deserialize, Serialize}; use crate::core::CoreClient; +use crate::theme::ThemeColors; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SidebarTab { @@ -326,23 +327,54 @@ impl SidebarView { } false } + + fn render_tab_button( + &self, + id: &'static str, + label: String, + is_active: bool, + tab: SidebarTab, + cx: &mut Context, + ) -> impl IntoElement { + div() + .id(id) + .px_3() + .py_1() + .rounded_t_sm() + .cursor_pointer() + .text_xs() + .when(is_active, |btn| { + btn.bg(ThemeColors::bg_sidebar()) + .text_color(ThemeColors::text_primary()) + .border_b_2() + .border_color(ThemeColors::accent_blue()) + }) + .when(!is_active, |btn| { + btn.text_color(ThemeColors::text_muted()) + .hover(|h| h.bg(ThemeColors::bg_tab_hover()).text_color(ThemeColors::text_primary())) + }) + .child(label) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.set_tab(tab, cx); + })) + } } impl Render for SidebarView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .size_full() - .bg(rgb(0x161724)) + .bg(ThemeColors::BG_SIDEBAR) .border_r_1() - .border_color(rgb(0x23263b)) + .border_color(ThemeColors::BORDER) .child( // 顶部视图切换栏 (Explorer / Search / Git) h_flex() .h(px(36.0)) .w_full() - .bg(rgb(0x141522)) + .bg(ThemeColors::BG_TAB_BAR) .border_b_1() - .border_color(rgb(0x23263b)) + .border_color(ThemeColors::BORDER) .items_center() .justify_between() .px_2() @@ -350,44 +382,27 @@ impl Render for SidebarView { h_flex() .items_center() .gap_1() - .child( - Button::new("tab-explorer") - .small() - .when(self.active_tab == SidebarTab::Explorer, |b| { - b.primary() - }) - .when(self.active_tab != SidebarTab::Explorer, |b| { - b.ghost() - }) - .label("Files") - .on_click(cx.listener(|this, _event, _window, cx| { - this.set_tab(SidebarTab::Explorer, cx); - })), - ) - .child( - Button::new("tab-search") - .small() - .when(self.active_tab == SidebarTab::Search, |b| { - b.primary() - }) - .when(self.active_tab != SidebarTab::Search, |b| { - b.ghost() - }) - .label("Search") - .on_click(cx.listener(|this, _event, _window, cx| { - this.set_tab(SidebarTab::Search, cx); - })), - ) - .child( - Button::new("tab-git") - .small() - .when(self.active_tab == SidebarTab::Git, |b| b.primary()) - .when(self.active_tab != SidebarTab::Git, |b| b.ghost()) - .label(format!("Git ({})", self.git_changes.len())) - .on_click(cx.listener(|this, _event, _window, cx| { - this.set_tab(SidebarTab::Git, cx); - })), - ), + .child(self.render_tab_button( + "tab-explorer", + "Files".to_string(), + self.active_tab == SidebarTab::Explorer, + SidebarTab::Explorer, + cx, + )) + .child(self.render_tab_button( + "tab-search", + "Search".to_string(), + self.active_tab == SidebarTab::Search, + SidebarTab::Search, + cx, + )) + .child(self.render_tab_button( + "tab-git", + format!("Git ({})", self.git_changes.len()), + self.active_tab == SidebarTab::Git, + SidebarTab::Git, + cx, + )), ) .child( Button::new("refresh-btn") @@ -459,10 +474,10 @@ impl SidebarView { .pr_2() .text_xs() .when(is_selected, |row| { - row.bg(rgb(0x2a2d42)).text_color(rgb(0xffffff)) + row.bg(ThemeColors::BG_TAB_ACTIVE).text_color(ThemeColors::TEXT_PRIMARY) }) .when(!is_selected, |row| { - row.text_color(rgb(0xcfd3e0)).hover(|h| h.bg(rgb(0x1e2030))) + row.text_color(ThemeColors::TEXT_PRIMARY).hover(|h| h.bg(ThemeColors::BG_TAB_HOVER)) }) .child(if is_dir { if item.is_expanded { "📂 " } else { "📁 " } @@ -495,13 +510,15 @@ impl SidebarView { .h(px(28.0)) .w_full() .items_center() - .bg(rgb(0x1e2030)) + .bg(ThemeColors::BG_TAB_ACTIVE) + .border_1() + .border_color(ThemeColors::BORDER) .rounded_sm() .px_2() .child( div() .text_xs() - .text_color(rgb(0x8a90a2)) + .text_color(ThemeColors::TEXT_MUTED) .child("Search files or text..."), ), ) @@ -530,19 +547,19 @@ impl SidebarView { .p_1() .rounded_sm() .cursor_pointer() - .hover(|h| h.bg(rgb(0x1e2030))) + .hover(|h| h.bg(ThemeColors::BG_TAB_HOVER)) .child( h_flex() .items_center() .gap_1() .text_xs() - .text_color(rgb(0x60a5fa)) + .text_color(ThemeColors::ACCENT_BLUE) .child(format!("{}{}", path, line_info)), ) .child( div() .text_xs() - .text_color(rgb(0x9ca3af)) + .text_color(ThemeColors::TEXT_MUTED) .child(preview), ) .on_click(cx.listener({ @@ -570,16 +587,16 @@ impl SidebarView { .gap_2() .pb_2() .border_b_1() - .border_color(rgb(0x23263b)) + .border_color(ThemeColors::BORDER) .text_xs() - .text_color(rgb(0x10b981)) + .text_color(ThemeColors::ACCENT_GREEN) .child("⎇ Branch:") .child(branch), ) .child( div() .text_xs() - .text_color(rgb(0x8a90a2)) + .text_color(ThemeColors::TEXT_MUTED) .child(format!("Changed Files ({})", self.git_changes.len())), ) .children(self.git_changes.iter().enumerate().map(|(idx, change)| { @@ -595,13 +612,13 @@ impl SidebarView { .px_2() .rounded_sm() .cursor_pointer() - .hover(|h| h.bg(rgb(0x1e2030))) + .hover(|h| h.bg(ThemeColors::BG_TAB_HOVER)) .text_xs() .child( h_flex() .items_center() .gap_2() - .text_color(rgb(0xcfd3e0)) + .text_color(ThemeColors::TEXT_PRIMARY) .child(path.clone()), ) .child( diff --git a/linux/src/workbench/status_bar.rs b/linux/src/workbench/status_bar.rs new file mode 100644 index 000000000..66a447972 --- /dev/null +++ b/linux/src/workbench/status_bar.rs @@ -0,0 +1,110 @@ +use gpui_kit::component::h_flex; +use gpui_kit::{ + div, px, Context, IntoElement, ParentElement as _, Render, Styled as _, Window, +}; + +use crate::theme::ThemeColors; + +pub struct StatusBarView { + pub git_branch: Option, + pub current_file: Option, + pub cursor_line: usize, + pub cursor_col: usize, + pub language: String, + pub encoding: String, +} + +impl StatusBarView { + pub fn new() -> Self { + Self { + git_branch: None, + current_file: None, + cursor_line: 1, + cursor_col: 1, + language: "Plain Text".to_string(), + encoding: "UTF-8".to_string(), + } + } + + pub fn set_file_info( + &mut self, + file: Option, + line: usize, + col: usize, + lang: String, + cx: &mut Context, + ) { + self.current_file = file; + self.cursor_line = line; + self.cursor_col = col; + self.language = lang; + cx.notify(); + } + + #[allow(dead_code)] + pub fn set_git_branch(&mut self, branch: Option, cx: &mut Context) { + self.git_branch = branch; + cx.notify(); + } +} + +impl Render for StatusBarView { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let branch = self.git_branch.clone().unwrap_or_else(|| "main".to_string()); + let file_label = self + .current_file + .clone() + .unwrap_or_else(|| "No file active".to_string()); + + h_flex() + .h(px(24.0)) + .w_full() + .bg(ThemeColors::bg_statusbar()) + .border_t_1() + .border_color(ThemeColors::border()) + .items_center() + .justify_between() + .px_3() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child( + // 左侧状态栏项目 + h_flex() + .items_center() + .gap_3() + .child( + h_flex() + .items_center() + .gap_1() + .text_color(ThemeColors::accent_green()) + .child("⎇") + .child(branch), + ) + .child(div().text_color(ThemeColors::border()).child("|")) + .child( + div() + .text_color(ThemeColors::text_primary()) + .child(file_label), + ), + ) + .child( + // 右侧状态栏项目 + h_flex() + .items_center() + .gap_3() + .child(format!("Ln {}, Col {}", self.cursor_line, self.cursor_col)) + .child(div().text_color(ThemeColors::border()).child("|")) + .child(self.language.clone()) + .child(div().text_color(ThemeColors::border()).child("|")) + .child(self.encoding.clone()) + .child(div().text_color(ThemeColors::border()).child("|")) + .child("4 spaces") + .child(div().text_color(ThemeColors::border()).child("|")) + .child( + div() + .text_color(ThemeColors::accent_blue()) + .child("Lithe Core"), + ), + ) + } +} diff --git a/linux/src/workbench/toolbar.rs b/linux/src/workbench/toolbar.rs index 20b83d076..748f52b4d 100644 --- a/linux/src/workbench/toolbar.rs +++ b/linux/src/workbench/toolbar.rs @@ -1,15 +1,29 @@ use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::menu::{DropdownMenu as _, PopupMenuItem}; use gpui_kit::component::{h_flex, Sizable as _}; use gpui_kit::{ - div, px, rgb, Context, EventEmitter, IntoElement, ParentElement as _, - Render, Styled as _, Window, + div, px, Context, EventEmitter, InteractiveElement as _, IntoElement, ParentElement as _, + Render, StatefulInteractiveElement as _, Styled as _, Window, }; +use crate::theme::ThemeColors; + #[derive(Debug, Clone)] pub enum ToolbarEvent { + NewFile, Save, + CloseTab, Run, + Debug, + #[allow(dead_code)] + Stop, ToggleTerminal, + ClearTerminal, + ToggleSidebar, + RefreshWorkspace, + QuickOpen, + About, + Exit, } pub struct ToolbarView { @@ -41,41 +55,297 @@ impl ToolbarView { impl Render for ToolbarView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let branch = self.git_branch.as_deref().unwrap_or("main"); + let branch = self.git_branch.clone().unwrap_or_else(|| "main".to_string()); + let view = cx.entity(); h_flex() .h(px(40.0)) .w_full() - .bg(rgb(0x10111a)) + .bg(ThemeColors::bg_titlebar()) .border_b_1() - .border_color(rgb(0x23263b)) + .border_color(ThemeColors::border()) .items_center() .justify_between() .px_3() .child( - // 左侧:项目名称与分支信息 + // 1. 左侧:Logo + 菜单栏 + 项目名称选择器 + 分支 h_flex() .items_center() - .gap_3() + .gap_1() .child( - div() - .text_sm() - .text_color(rgb(0xffffff)) - .child(format!("⚡ Lithe | {}", self.workspace_name)), + h_flex() + .items_center() + .gap_1() + .pr_2() + .child( + div() + .text_sm() + .text_color(ThemeColors::accent_blue()) + .child("⚡"), + ) + .child( + div() + .text_sm() + .text_color(ThemeColors::text_primary()) + .child("Lithe"), + ), ) + // File 菜单 + .child({ + let v = view.clone(); + Button::new("menu-file") + .small() + .ghost() + .label("File") + .dropdown_menu(move |menu, _window, _cx| { + let v_new = v.clone(); + let v_save = v.clone(); + let v_close = v.clone(); + let v_exit = v.clone(); + + menu.item( + PopupMenuItem::new("New File (Ctrl+N)") + .on_click(move |_, _, cx| { + v_new.update(cx, |_, cx| cx.emit(ToolbarEvent::NewFile)); + }), + ) + .item( + PopupMenuItem::new("Save (Ctrl+S)") + .on_click(move |_, _, cx| { + v_save.update(cx, |_, cx| cx.emit(ToolbarEvent::Save)); + }), + ) + .item( + PopupMenuItem::new("Close Active Tab (Ctrl+W)") + .on_click(move |_, _, cx| { + v_close.update(cx, |_, cx| cx.emit(ToolbarEvent::CloseTab)); + }), + ) + .separator() + .item( + PopupMenuItem::new("Exit") + .on_click(move |_, _, cx| { + v_exit.update(cx, |_, cx| cx.emit(ToolbarEvent::Exit)); + }), + ) + }) + }) + // Edit 菜单 + .child( + Button::new("menu-edit") + .small() + .ghost() + .label("Edit") + .dropdown_menu(|menu, _window, _cx| { + menu.item(PopupMenuItem::new("Undo (Ctrl+Z)")) + .item(PopupMenuItem::new("Redo (Ctrl+Y)")) + .separator() + .item(PopupMenuItem::new("Cut (Ctrl+X)")) + .item(PopupMenuItem::new("Copy (Ctrl+C)")) + .item(PopupMenuItem::new("Paste (Ctrl+V)")) + }), + ) + // View 菜单 + .child({ + let v = view.clone(); + Button::new("menu-view") + .small() + .ghost() + .label("View") + .dropdown_menu(move |menu, _window, _cx| { + let v_sidebar = v.clone(); + let v_term = v.clone(); + let v_refresh = v.clone(); + + menu.item( + PopupMenuItem::new("Toggle Sidebar (Ctrl+B)") + .on_click(move |_, _, cx| { + v_sidebar.update(cx, |_, cx| { + cx.emit(ToolbarEvent::ToggleSidebar) + }); + }), + ) + .item( + PopupMenuItem::new("Toggle Terminal (Ctrl+`)") + .on_click(move |_, _, cx| { + v_term.update(cx, |_, cx| { + cx.emit(ToolbarEvent::ToggleTerminal) + }); + }), + ) + .separator() + .item( + PopupMenuItem::new("Refresh Workspace") + .on_click(move |_, _, cx| { + v_refresh.update(cx, |_, cx| { + cx.emit(ToolbarEvent::RefreshWorkspace) + }); + }), + ) + }) + }) + // Run 菜单 + .child({ + let v = view.clone(); + Button::new("menu-run") + .small() + .ghost() + .label("Run") + .dropdown_menu(move |menu, _window, _cx| { + let v_run = v.clone(); + let v_debug = v.clone(); + + menu.item( + PopupMenuItem::new("Run Configuration (Shift+F10)") + .on_click(move |_, _, cx| { + v_run.update(cx, |_, cx| cx.emit(ToolbarEvent::Run)); + }), + ) + .item( + PopupMenuItem::new("Debug Configuration (Shift+F9)") + .on_click(move |_, _, cx| { + v_debug.update(cx, |_, cx| cx.emit(ToolbarEvent::Debug)); + }), + ) + }) + }) + // Terminal 菜单 + .child({ + let v = view.clone(); + Button::new("menu-terminal") + .small() + .ghost() + .label("Terminal") + .dropdown_menu(move |menu, _window, _cx| { + let v_toggle = v.clone(); + let v_clear = v.clone(); + + menu.item( + PopupMenuItem::new("Toggle Terminal") + .on_click(move |_, _, cx| { + v_toggle.update(cx, |_, cx| { + cx.emit(ToolbarEvent::ToggleTerminal) + }); + }), + ) + .item( + PopupMenuItem::new("Clear Output") + .on_click(move |_, _, cx| { + v_clear.update(cx, |_, cx| { + cx.emit(ToolbarEvent::ClearTerminal) + }); + }), + ) + }) + }) + // Help 菜单 + .child({ + let v = view.clone(); + Button::new("menu-help") + .small() + .ghost() + .label("Help") + .dropdown_menu(move |menu, _window, _cx| { + let v_about = v.clone(); + + menu.item( + PopupMenuItem::new("About Lithe") + .on_click(move |_, _, cx| { + v_about.update(cx, |_, cx| cx.emit(ToolbarEvent::About)); + }), + ) + }) + }) + // 分隔竖线 .child( div() + .h(px(14.0)) + .w(px(1.0)) + .bg(ThemeColors::border()) + .mx_2(), + ) + // 项目选择器外观 + .child( + h_flex() + .items_center() + .gap_1() .px_2() .py(px(2.0)) - .rounded_sm() - .bg(rgb(0x1e2030)) + .rounded_md() + .bg(ThemeColors::bg_tab_hover()) + .border_1() + .border_color(ThemeColors::border()) + .child("📂") + .child( + div() + .text_xs() + .text_color(ThemeColors::text_primary()) + .child(self.workspace_name.clone()), + ), + ) + // Git 分支标识 + .child( + h_flex() + .items_center() + .gap_1() + .px_2() + .py(px(2.0)) + .rounded_md() + .bg(ThemeColors::bg_tab_hover()) + .border_1() + .border_color(ThemeColors::border()) + .child("⎇") + .child( + div() + .text_xs() + .text_color(ThemeColors::accent_green()) + .child(branch), + ), + ), + ) + .child( + // 2. 中间:全局搜索栏 (Search Everywhere) + div() + .id("search-everywhere-bar") + .w(px(260.0)) + .h(px(26.0)) + .bg(ThemeColors::bg_tab_active()) + .border_1() + .border_color(ThemeColors::border()) + .rounded_md() + .px_2() + .flex() + .items_center() + .justify_between() + .cursor_pointer() + .hover(|h| h.border_color(ThemeColors::accent_blue())) + .on_click(cx.listener(|_this, _event, _window, cx| { + cx.emit(ToolbarEvent::QuickOpen); + })) + .child( + h_flex() + .items_center() + .gap_2() + .child(div().text_xs().text_color(ThemeColors::text_muted()).child("🔍")) + .child( + div() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child("Search files, symbols..."), + ), + ) + .child( + div() .text_xs() - .text_color(rgb(0x10b981)) - .child(format!("⎇ {}", branch)), + .text_color(ThemeColors::text_muted()) + .px_1() + .bg(ThemeColors::bg_titlebar()) + .rounded_sm() + .child("Ctrl+P"), ), ) .child( - // 中间:运行与调试配置 + // 3. 右侧:运行/调试配置按钮组 h_flex() .items_center() .gap_2() @@ -94,15 +364,9 @@ impl Render for ToolbarView { .ghost() .label("🐞 Debug") .on_click(cx.listener(|_this, _event, _window, cx| { - cx.emit(ToolbarEvent::Run); + cx.emit(ToolbarEvent::Debug); })), - ), - ) - .child( - // 右侧:保存与面板切换 - h_flex() - .items_center() - .gap_2() + ) .child( Button::new("tb-save") .small() @@ -111,15 +375,6 @@ impl Render for ToolbarView { .on_click(cx.listener(|_this, _event, _window, cx| { cx.emit(ToolbarEvent::Save); })), - ) - .child( - Button::new("tb-terminal") - .small() - .ghost() - .label("⌨ Terminal") - .on_click(cx.listener(|_this, _event, _window, cx| { - cx.emit(ToolbarEvent::ToggleTerminal); - })), ), ) } diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index 3170f8666..044ec4e5a 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -1,27 +1,37 @@ -use gpui_kit::component::{h_flex, v_flex, ActiveTheme as _}; +use gpui_kit::component::{h_flex, v_flex}; +use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, rgb, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, + div, px, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _, Subscription, Window, }; use crate::core::CoreClient; +use crate::theme::ThemeColors; +use crate::workbench::activity_rail::{ActivityRailEvent, ActivityRailView, ActivityTab}; use crate::workbench::bottom_panel::BottomPanelView; use crate::workbench::editor::EditorView; -use crate::workbench::sidebar::{SidebarEvent, SidebarView}; +use crate::workbench::sidebar::{SidebarEvent, SidebarTab, SidebarView}; +use crate::workbench::status_bar::StatusBarView; use crate::workbench::toolbar::{ToolbarEvent, ToolbarView}; -/// Linux 前端主工作台视图组件 +/// Linux 前端主工作台视图组件(复刻 Tauri IDE 经典架构) pub struct WorkbenchView { /// 项目工作区根路径 pub workspace_root: String, - /// 顶部工具栏 + /// 侧边栏是否展开 + pub sidebar_visible: bool, + /// 顶部标题栏/工具栏 pub toolbar: Entity, - /// 侧边栏视图实体 + /// 左侧垂直活动栏 (Activity Rail) + pub activity_rail: Entity, + /// 侧边栏面板 (Files / Git / Search) pub sidebar: Entity, - /// 编辑器视图实体 + /// 主代码编辑器区 pub editor: Entity, - /// 底部面板视图实体 + /// 底部抽屉面板 (Terminal / Output / Problems) pub bottom_panel: Entity, + /// 底部状态栏 (Status Bar) + pub status_bar: Entity, client: CoreClient, _subscriptions: Vec, } @@ -40,57 +50,170 @@ impl WorkbenchView { _window: &mut Window, cx: &mut Context, ) -> Self { - let root_for_toolbar = workspace_root.clone(); - let root_for_sidebar = workspace_root.clone(); - let root_for_editor = workspace_root.clone(); - let root_for_bottom = workspace_root.clone(); + let root = workspace_root.clone(); - let toolbar = cx.new(|_cx| ToolbarView::new(&root_for_toolbar)); - let sidebar = cx.new(|cx| SidebarView::new(root_for_sidebar, cx)); - let editor = cx.new(|cx| EditorView::new(root_for_editor, cx)); - let bottom_panel = cx.new(|cx| BottomPanelView::new(root_for_bottom, cx)); + let toolbar = cx.new(|_cx| ToolbarView::new(&root)); + let activity_rail = cx.new(|_cx| ActivityRailView::new()); + let sidebar = cx.new(|cx| SidebarView::new(root.clone(), cx)); + let editor = cx.new(|cx| EditorView::new(root.clone(), cx)); + let bottom_panel = cx.new(|cx| BottomPanelView::new(root.clone(), cx)); + let status_bar = cx.new(|_cx| StatusBarView::new()); - // 1. 订阅侧边栏事件:点击文件打开 - let sub_sidebar = cx.subscribe(&sidebar, |this, _sidebar, event: &SidebarEvent, cx| { + // 1. 订阅侧边栏文件打开事件 + let status_bar_clone = status_bar.clone(); + let sub_sidebar = cx.subscribe(&sidebar, move |this, _sidebar, event: &SidebarEvent, cx| { match event { SidebarEvent::OpenFile(path) => { this.open_file(path, cx); + let _ = status_bar_clone.update(cx, |sb, cx| { + sb.set_file_info( + Some(path.clone()), + 1, + 1, + EditorView::language_name(path).to_string(), + cx, + ); + }); + } + } + }); + + // 2. 订阅左侧活动栏 (Activity Rail) 事件 + let sidebar_clone = sidebar.clone(); + let bottom_panel_clone = bottom_panel.clone(); + let sub_rail = cx.subscribe(&activity_rail, move |this, rail, event: &ActivityRailEvent, cx| { + match event { + ActivityRailEvent::SelectTab(tab) => { + let sb_tab = match tab { + ActivityTab::Explorer => SidebarTab::Explorer, + ActivityTab::Git => SidebarTab::Git, + ActivityTab::Search => SidebarTab::Search, + }; + + let was_active = this.sidebar_visible + && rail.read(cx).active_tab == Some(*tab); + + if was_active { + this.sidebar_visible = false; + let _ = rail.update(cx, |r, cx| r.set_active_tab(None, cx)); + } else { + this.sidebar_visible = true; + let _ = rail.update(cx, |r, cx| r.set_active_tab(Some(*tab), cx)); + let _ = sidebar_clone.update(cx, |sb, cx| sb.set_tab(sb_tab, cx)); + } + cx.notify(); + } + ActivityRailEvent::ToggleTerminal => { + let _ = bottom_panel_clone.update(cx, |bp, cx| { + bp.toggle_collapsed(cx); + }); + } + ActivityRailEvent::OpenSettings => { + let _ = bottom_panel_clone.update(cx, |bp, cx| { + bp.append_log("[Settings] Settings dialog requested".to_string(), cx); + }); } } }); - // 2. 订阅工具栏事件:保存、运行、终端切换 + // 3. 订阅顶部标题栏与菜单栏事件 let sub_toolbar = cx.subscribe(&toolbar, |this, _toolbar, event: &ToolbarEvent, cx| { match event { + ToolbarEvent::NewFile => { + let _ = this.editor.update(cx, |ed, cx| { + ed.open_file("untitled.txt".to_string(), String::new(), cx); + }); + } ToolbarEvent::Save => { let _ = this.editor.update(cx, |ed, cx| { ed.save_active(cx); }); } + ToolbarEvent::CloseTab => { + let _ = this.editor.update(cx, |ed, cx| { + if let Some(idx) = ed.active_tab_index { + ed.close_tab(idx, cx); + } + }); + } + ToolbarEvent::ToggleSidebar => { + this.sidebar_visible = !this.sidebar_visible; + let tab = if this.sidebar_visible { + Some(ActivityTab::Explorer) + } else { + None + }; + let _ = this.activity_rail.update(cx, |r, cx| r.set_active_tab(tab, cx)); + cx.notify(); + } ToolbarEvent::ToggleTerminal => { let _ = this.bottom_panel.update(cx, |bp, cx| { bp.toggle_collapsed(cx); }); } + ToolbarEvent::ClearTerminal => { + let _ = this.bottom_panel.update(cx, |bp, cx| { + let _ = bp.terminal.update(cx, |term, cx| { + term.clear(cx); + }); + }); + } + ToolbarEvent::RefreshWorkspace => { + let _ = this.sidebar.update(cx, |sb, cx| { + sb.refresh(cx); + sb.refresh_git(cx); + }); + } + ToolbarEvent::QuickOpen => { + let _ = this.sidebar.update(cx, |sb, cx| { + sb.set_tab(SidebarTab::Search, cx); + }); + this.sidebar_visible = true; + let _ = this.activity_rail.update(cx, |r, cx| { + r.set_active_tab(Some(ActivityTab::Search), cx) + }); + cx.notify(); + } ToolbarEvent::Run => { let _ = this.bottom_panel.update(cx, |bp, cx| { - bp.append_log("[Run] Executing run configuration...".to_string(), cx); + bp.append_log("[Run] Executing default run configuration...".to_string(), cx); let _ = bp.terminal.update(cx, |term, cx| { term.send_command("echo '[Lithe Run]' && ls -la", cx); }); }); } + ToolbarEvent::Debug => { + let _ = this.bottom_panel.update(cx, |bp, cx| { + bp.append_log("[Debug] Launching DAP debug session...".to_string(), cx); + }); + } + ToolbarEvent::Stop => { + let _ = this.bottom_panel.update(cx, |bp, cx| { + bp.append_log("[Stop] Session terminated by user.".to_string(), cx); + }); + } + ToolbarEvent::About => { + let _ = this.bottom_panel.update(cx, |bp, cx| { + bp.append_log("[About] Lithe IDE for Linux (Powered by Zed GPUI Kit & Rust Core)".to_string(), cx); + }); + } + ToolbarEvent::Exit => { + cx.quit(); + } } }); Self { workspace_root, + sidebar_visible: true, toolbar, + activity_rail, sidebar, editor, bottom_panel, + status_bar, client: CoreClient::new(), - _subscriptions: vec![sub_sidebar, sub_toolbar], + _subscriptions: vec![sub_sidebar, sub_rail, sub_toolbar], } } @@ -123,40 +246,49 @@ impl WorkbenchView { } impl Render for WorkbenchView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { v_flex() .size_full() - .bg(cx.theme().background) + .bg(ThemeColors::bg_editor()) .child( - // 1. 顶部工具栏 + // 1. 顶部标题栏/菜单栏/运行控制 (Title Bar) self.toolbar.clone(), ) .child( - // 2. 主体区域(左右布局) + // 2. 主工作区区域(ActivityRail + 侧边栏 + 编辑器主岛) h_flex() .flex_1() .w_full() .child( - // 左侧分栏:侧边栏(Explorer / Search / Git) - div() - .w(px(280.0)) - .h_full() - .flex_shrink_0() - .border_r_1() - .border_color(rgb(0x23263b)) - .child(self.sidebar.clone()), + // 左侧垂直图标活动栏 (Activity Rail) + self.activity_rail.clone(), ) + .when(self.sidebar_visible, |layout| { + layout.child( + // 侧边栏内容面板 (Explorer / Search / Git) + div() + .w(px(260.0)) + .h_full() + .flex_shrink_0() + .child(self.sidebar.clone()), + ) + }) .child( - // 右侧分栏:多标签代码编辑器 + // 中央编辑器主岛 div() .flex_1() .h_full() + .bg(ThemeColors::bg_editor()) .child(self.editor.clone()), ), ) .child( - // 3. 底部面板(终端 / 日志 / 问题) + // 3. 底部抽屉面板 (Terminal / Output / Problems) self.bottom_panel.clone(), ) + .child( + // 4. 最底部状态栏 (Status Bar) + self.status_bar.clone(), + ) } } From 65a7ec199a59a449b1ca4670f1f52dc2a82221dc Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Thu, 24 Sep 2026 00:18:56 +0800 Subject: [PATCH 003/109] =?UTF-8?q?feat(linux):=20=E5=AF=B9=E9=BD=90=20mac?= =?UTF-8?q?OS=20UI/UX=20=E4=BD=93=E9=AA=8C=E5=B9=B6=E5=B0=86=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E7=BF=BB=E8=AF=91=E4=B8=BA=20gpui-kit=20=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除界面中所有 Emoji 字符,全面接入 gpui-kit Lucide 图标体系 - 顶部工具栏重构:实现 Lithe 品牌标识、项目与分支切换胶囊、Search Everywhere 搜索条与 Run/Debug 控制组 - 活动栏重构:对齐 macOS ActivityBar 布局,规范 32x32 图标按钮与激活态指示 - 侧边栏重构:精简工具窗口顶部标题栏,重写 Explorer/Git/Search 视图列表与状态指示徽章 - 多标签代码区重构:对齐 macOS 激活 Tab 2px 蓝条下划线、面包屑导航条、右对齐行号槽与居中空状态 - 底栏与状态栏重构:接入 Terminal/Output/Problems 标签与清理/折叠操作,完善状态栏展示 --- linux/src/theme.rs | 23 ++- linux/src/workbench/activity_rail.rs | 70 ++++--- linux/src/workbench/bottom_panel.rs | 112 ++++++++--- linux/src/workbench/editor.rs | 244 ++++++++++++++++++----- linux/src/workbench/sidebar.rs | 276 ++++++++++++++++---------- linux/src/workbench/status_bar.rs | 95 +++++++-- linux/src/workbench/toolbar.rs | 277 ++++++++++++--------------- linux/src/workbench/view.rs | 30 ++- 8 files changed, 738 insertions(+), 389 deletions(-) diff --git a/linux/src/theme.rs b/linux/src/theme.rs index 6b9bacdf0..73856b750 100644 --- a/linux/src/theme.rs +++ b/linux/src/theme.rs @@ -1,4 +1,8 @@ use gpui_kit::Rgba; +#[allow(unused_imports)] +pub use gpui_kit::assets::IconName; +#[allow(unused_imports)] +pub use gpui_kit::component::Icon; pub const fn const_rgb(hex: u32) -> Rgba { let r = ((hex >> 16) & 0xFF) as f32 / 255.0; @@ -17,14 +21,17 @@ impl ThemeColors { pub const BG_EDITOR: Rgba = const_rgb(0x2b2d30); pub const BG_TAB_BAR: Rgba = const_rgb(0x1e1f22); pub const BG_TAB_ACTIVE: Rgba = const_rgb(0x2b2d30); - pub const BG_TAB_HOVER: Rgba = const_rgb(0x24262b); + pub const BG_TAB_HOVER: Rgba = const_rgb(0x26282c); pub const BG_BOTTOM_PANEL: Rgba = const_rgb(0x1e1f22); pub const BG_STATUSBAR: Rgba = const_rgb(0x18191c); - pub const BORDER: Rgba = const_rgb(0x393b40); + pub const BORDER: Rgba = const_rgb(0x323438); pub const ACCENT_BLUE: Rgba = const_rgb(0x3574f0); pub const ACCENT_GREEN: Rgba = const_rgb(0x39a36b); + pub const ACCENT_YELLOW: Rgba = const_rgb(0xd29922); + pub const ACCENT_RED: Rgba = const_rgb(0xe02e2a); pub const TEXT_PRIMARY: Rgba = const_rgb(0xdfdfe0); pub const TEXT_MUTED: Rgba = const_rgb(0x818594); + pub const SUBTLE_SELECTION: Rgba = const_rgb(0x26282d); #[inline] pub fn bg_titlebar() -> Rgba { @@ -75,6 +82,14 @@ impl ThemeColors { Self::ACCENT_GREEN } #[inline] + pub fn accent_yellow() -> Rgba { + Self::ACCENT_YELLOW + } + #[inline] + pub fn accent_red() -> Rgba { + Self::ACCENT_RED + } + #[inline] pub fn text_primary() -> Rgba { Self::TEXT_PRIMARY } @@ -82,4 +97,8 @@ impl ThemeColors { pub fn text_muted() -> Rgba { Self::TEXT_MUTED } + #[inline] + pub fn subtle_selection() -> Rgba { + Self::SUBTLE_SELECTION + } } diff --git a/linux/src/workbench/activity_rail.rs b/linux/src/workbench/activity_rail.rs index a8279f5d9..03a007567 100644 --- a/linux/src/workbench/activity_rail.rs +++ b/linux/src/workbench/activity_rail.rs @@ -1,8 +1,9 @@ +use gpui_kit::assets::IconName; +use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::component::v_flex; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, Context, EventEmitter, InteractiveElement as _, IntoElement, ParentElement as _, - Render, StatefulInteractiveElement as _, Styled as _, Window, + px, Context, EventEmitter, IntoElement, ParentElement as _, Render, Styled as _, Window, }; use crate::theme::ThemeColors; @@ -45,7 +46,7 @@ impl Render for ActivityRailView { let active = self.active_tab; v_flex() - .w(px(42.0)) + .w(px(40.0)) .h_full() .bg(ThemeColors::bg_activity_rail()) .border_r_1() @@ -61,45 +62,46 @@ impl Render for ActivityRailView { .gap_1() .child(self.render_rail_button( "rail-files", - "📁", - "Files (Project Explorer)", + IconName::Folder, + "Explorer", active == Some(ActivityTab::Explorer), ActivityRailEvent::SelectTab(ActivityTab::Explorer), cx, )) .child(self.render_rail_button( "rail-git", - "⎇", - "Version Control (Git)", + IconName::GitBranch, + "Version Control", active == Some(ActivityTab::Git), ActivityRailEvent::SelectTab(ActivityTab::Git), cx, )) .child(self.render_rail_button( "rail-search", - "🔍", + IconName::Search, "Search Everywhere", active == Some(ActivityTab::Search), ActivityRailEvent::SelectTab(ActivityTab::Search), cx, - )) - .child(self.render_rail_button( - "rail-terminal", - "⌨", - "Terminal", - false, - ActivityRailEvent::ToggleTerminal, - cx, )), ) .child( - // 底部设置图标 + // 底部工具按钮列表 v_flex() .w_full() .items_center() + .gap_1() + .child(self.render_rail_button( + "rail-terminal", + IconName::Terminal, + "Terminal", + false, + ActivityRailEvent::ToggleTerminal, + cx, + )) .child(self.render_rail_button( "rail-settings", - "⚙", + IconName::Settings, "Settings", false, ActivityRailEvent::OpenSettings, @@ -113,33 +115,29 @@ impl ActivityRailView { fn render_rail_button( &self, id: &'static str, - icon: &'static str, - _tooltip: &'static str, + icon: IconName, + tooltip: &'static str, is_active: bool, event: ActivityRailEvent, cx: &mut Context, ) -> impl IntoElement { - div() - .id(id) - .w(px(34.0)) - .h(px(34.0)) - .rounded_md() - .flex() - .items_center() - .justify_center() - .cursor_pointer() - .text_sm() + Button::new(id) + .ghost() + .icon(icon) + .tooltip(tooltip) + .w(px(32.0)) + .h(px(32.0)) + .rounded(px(4.0)) + .text_color(if is_active { + ThemeColors::accent_blue() + } else { + ThemeColors::text_muted() + }) .when(is_active, |btn| { btn.bg(ThemeColors::bg_sidebar()) - .text_color(ThemeColors::accent_blue()) .border_l_2() .border_color(ThemeColors::accent_blue()) }) - .when(!is_active, |btn| { - btn.text_color(ThemeColors::text_muted()) - .hover(|h| h.bg(ThemeColors::bg_tab_hover()).text_color(ThemeColors::text_primary())) - }) - .child(icon) .on_click(cx.listener(move |_this, _event, _window, cx| { cx.emit(event.clone()); })) diff --git a/linux/src/workbench/bottom_panel.rs b/linux/src/workbench/bottom_panel.rs index 8fea5f41a..711d70dc9 100644 --- a/linux/src/workbench/bottom_panel.rs +++ b/linux/src/workbench/bottom_panel.rs @@ -1,6 +1,7 @@ +use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::component::scroll::ScrollableElement as _; -use gpui_kit::component::{h_flex, v_flex, Sizable as _}; +use gpui_kit::component::{h_flex, v_flex, Icon, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ div, px, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, @@ -57,16 +58,18 @@ impl BottomPanelView { fn render_tab_button( &self, id: &'static str, + icon: IconName, label: String, is_active: bool, tab: BottomTab, cx: &mut Context, ) -> impl IntoElement { - div() + h_flex() .id(id) + .items_center() + .gap_1p5() .px_3() - .py_1() - .rounded_t_sm() + .h(px(30.0)) .cursor_pointer() .text_xs() .when(is_active, |btn| { @@ -77,8 +80,20 @@ impl BottomPanelView { }) .when(!is_active, |btn| { btn.text_color(ThemeColors::text_muted()) - .hover(|h| h.bg(ThemeColors::bg_tab_hover()).text_color(ThemeColors::text_primary())) + .hover(|h| { + h.bg(ThemeColors::bg_tab_hover()) + .text_color(ThemeColors::text_primary()) + }) }) + .child( + Icon::new(icon) + .size(px(13.0)) + .text_color(if is_active { + ThemeColors::accent_blue() + } else { + ThemeColors::text_muted() + }), + ) .child(label) .on_click(cx.listener(move |this, _event, _window, cx| { this.set_tab(tab, cx); @@ -92,24 +107,40 @@ impl Render for BottomPanelView { return div() .h(px(26.0)) .w_full() - .bg(ThemeColors::BG_STATUSBAR) + .bg(ThemeColors::bg_statusbar()) .border_t_1() - .border_color(ThemeColors::BORDER) + .border_color(ThemeColors::border()) .flex() .items_center() .justify_between() .px_3() .child( - div() - .text_xs() - .text_color(ThemeColors::TEXT_MUTED) - .child("Panel collapsed"), + h_flex() + .id("collapsed-terminal-btn") + .items_center() + .gap_1p5() + .cursor_pointer() + .child( + Icon::new(IconName::Terminal) + .size(px(12.0)) + .text_color(ThemeColors::text_muted()), + ) + .child( + div() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child("Terminal"), + ) + .on_click(cx.listener(|this, _event, _window, cx| { + this.toggle_collapsed(cx); + })), ) .child( Button::new("expand-panel") .small() .ghost() - .label("▲ Expand") + .icon(IconName::ChevronUp) + .tooltip("Expand Panel") .on_click(cx.listener(|this, _event, _window, cx| { this.toggle_collapsed(cx); })), @@ -119,17 +150,17 @@ impl Render for BottomPanelView { v_flex() .h(px(240.0)) .w_full() - .bg(ThemeColors::BG_BOTTOM_PANEL) + .bg(ThemeColors::bg_bottom_panel()) .border_t_1() - .border_color(ThemeColors::BORDER) + .border_color(ThemeColors::border()) .child( - // 顶部 Tab 切换栏 + // 顶部 Tab 切换栏(高 30px) h_flex() - .h(px(32.0)) + .h(px(30.0)) .w_full() - .bg(ThemeColors::BG_TAB_BAR) + .bg(ThemeColors::bg_tab_bar()) .border_b_1() - .border_color(ThemeColors::BORDER) + .border_color(ThemeColors::border()) .items_center() .justify_between() .px_2() @@ -139,6 +170,7 @@ impl Render for BottomPanelView { .gap_1() .child(self.render_tab_button( "tab-terminal", + IconName::Terminal, "Terminal".to_string(), self.active_tab == BottomTab::Terminal, BottomTab::Terminal, @@ -146,6 +178,7 @@ impl Render for BottomPanelView { )) .child(self.render_tab_button( "tab-output", + IconName::FileText, "Output".to_string(), self.active_tab == BottomTab::Output, BottomTab::Output, @@ -153,6 +186,7 @@ impl Render for BottomPanelView { )) .child(self.render_tab_button( "tab-problems", + IconName::Bug, format!("Problems ({})", self.problems.len()), self.active_tab == BottomTab::Problems, BottomTab::Problems, @@ -160,13 +194,41 @@ impl Render for BottomPanelView { )), ) .child( - Button::new("collapse-panel") - .small() - .ghost() - .label("▼ Hide") - .on_click(cx.listener(|this, _event, _window, cx| { - this.toggle_collapsed(cx); - })), + h_flex() + .items_center() + .gap_1() + .child( + Button::new("clear-panel") + .small() + .ghost() + .icon(IconName::Trash) + .tooltip("Clear") + .on_click(cx.listener(|this, _event, _window, cx| { + match this.active_tab { + BottomTab::Terminal => { + let _ = this.terminal.update(cx, |t, cx| t.clear(cx)); + } + BottomTab::Output => { + this.output_logs.clear(); + cx.notify(); + } + BottomTab::Problems => { + this.problems.clear(); + cx.notify(); + } + } + })), + ) + .child( + Button::new("collapse-panel") + .small() + .ghost() + .icon(IconName::ChevronDown) + .tooltip("Hide Panel") + .on_click(cx.listener(|this, _event, _window, cx| { + this.toggle_collapsed(cx); + })), + ), ), ) .child( diff --git a/linux/src/workbench/editor.rs b/linux/src/workbench/editor.rs index 611850fff..5049f875f 100644 --- a/linux/src/workbench/editor.rs +++ b/linux/src/workbench/editor.rs @@ -1,8 +1,9 @@ +use gpui_kit::assets::IconName; use gpui_kit::component::scroll::ScrollableElement as _; -use gpui_kit::component::{h_flex, v_flex}; +use gpui_kit::component::{h_flex, v_flex, Icon}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, rgb, Context, InteractiveElement as _, IntoElement, ParentElement as _, + div, px, Context, FontWeight, InteractiveElement as _, IntoElement, ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, Window, }; @@ -21,7 +22,7 @@ pub struct EditorTab { pub cursor_col: usize, } -/// 多标签代码编辑器组件(对齐 IntelliJ / Tauri 视觉规范) +/// 多标签代码编辑器组件(对齐 macOS LitheTheme / IntelliJ 视觉规范) pub struct EditorView { pub workspace_root: String, pub tabs: Vec, @@ -137,12 +138,40 @@ impl EditorView { "YAML" } else if path.ends_with(".sh") { "Shell" + } else if path.ends_with(".sql") { + "SQL" } else { "Plain Text" } } } +fn is_code_file(name: &str) -> bool { + let lower = name.to_lowercase(); + lower.ends_with(".rs") + || lower.ends_with(".js") + || lower.ends_with(".ts") + || lower.ends_with(".jsx") + || lower.ends_with(".tsx") + || lower.ends_with(".json") + || lower.ends_with(".toml") + || lower.ends_with(".html") + || lower.ends_with(".css") + || lower.ends_with(".java") + || lower.ends_with(".c") + || lower.ends_with(".cpp") + || lower.ends_with(".h") + || lower.ends_with(".hpp") + || lower.ends_with(".py") + || lower.ends_with(".go") + || lower.ends_with(".swift") + || lower.ends_with(".sh") + || lower.ends_with(".xml") + || lower.ends_with(".yaml") + || lower.ends_with(".yml") + || lower.ends_with(".sql") +} + impl Render for EditorView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let active_tab = self @@ -151,22 +180,26 @@ impl Render for EditorView { v_flex() .size_full() - .bg(ThemeColors::BG_EDITOR) + .bg(ThemeColors::bg_editor()) .child( - // 1. 顶部标签栏(Tab Bar) + // 1. 顶部标签栏(Tab Bar,高 34px) h_flex() .h(px(34.0)) .w_full() - .bg(ThemeColors::BG_TAB_BAR) + .bg(ThemeColors::bg_tab_bar()) .border_b_1() - .border_color(ThemeColors::BORDER) + .border_color(ThemeColors::border()) .items_center() .overflow_x_scrollbar() - .px_1() .children(self.tabs.iter().enumerate().map(|(idx, tab)| { let is_active = self.active_tab_index == Some(idx); let title = tab.title.clone(); let is_dirty = tab.is_dirty; + let icon_name = if is_code_file(&title) { + IconName::FileCode + } else { + IconName::FileText + }; h_flex() .id(idx) @@ -174,72 +207,124 @@ impl Render for EditorView { .items_center() .gap_2() .px_3() + .relative() .cursor_pointer() - .when(is_active, |t| { - t.bg(ThemeColors::BG_TAB_ACTIVE) - .border_t_2() - .border_color(ThemeColors::ACCENT_BLUE) - }) + .when(is_active, |t| t.bg(ThemeColors::bg_editor())) .when(!is_active, |t| { - t.bg(ThemeColors::BG_TAB_BAR) - .hover(|h| h.bg(ThemeColors::BG_TAB_HOVER)) + t.bg(ThemeColors::bg_tab_bar()) + .hover(|h| h.bg(ThemeColors::bg_tab_hover())) }) .on_click(cx.listener(move |this, _event, _window, cx| { this.active_tab_index = Some(idx); cx.notify(); })) + .child( + Icon::new(icon_name) + .size(px(14.0)) + .text_color(if is_active { + ThemeColors::accent_blue() + } else { + ThemeColors::text_muted() + }), + ) .child( div() .text_xs() .text_color(if is_active { - ThemeColors::TEXT_PRIMARY + ThemeColors::text_primary() } else { - ThemeColors::TEXT_MUTED + ThemeColors::text_muted() }) .child(title), ) .when(is_dirty, |t| { t.child( div() - .text_xs() - .text_color(ThemeColors::ACCENT_BLUE) - .child("●"), + .w(px(6.0)) + .h(px(6.0)) + .rounded_full() + .bg(ThemeColors::accent_blue()), ) }) .child( div() .id(("close-tab", idx)) - .text_xs() - .text_color(ThemeColors::TEXT_MUTED) - .hover(|h| h.text_color(rgb(0xef4444))) - .child("×") + .p(px(2.0)) + .rounded_sm() + .cursor_pointer() + .hover(|h| { + h.bg(ThemeColors::bg_tab_hover()) + .text_color(ThemeColors::accent_red()) + }) + .child( + Icon::new(IconName::Close) + .size(px(12.0)) + .text_color(ThemeColors::text_muted()), + ) .on_click(cx.listener(move |this, _event, _window, cx| { this.close_tab(idx, cx); })), ) + // 激活 Tab 底部 2px 高亮条(对齐 macOS tabUnderline) + .when(is_active, |t| { + t.child( + div() + .absolute() + .bottom_0() + .left_0() + .right_0() + .h(px(2.0)) + .bg(ThemeColors::accent_blue()), + ) + }) })), ) .when_some(active_tab.as_ref(), |this, tab| { - // 2. 面包屑导航栏(Breadcrumb Bar) + // 2. 面包屑导航栏(Breadcrumb Bar,高 24px) + let segments: Vec<&str> = tab.path.split('/').filter(|s| !s.is_empty()).collect(); + let last_idx = segments.len().saturating_sub(1); + this.child( h_flex() .h(px(24.0)) .w_full() - .bg(ThemeColors::BG_TAB_ACTIVE) + .bg(ThemeColors::bg_tab_active()) .border_b_1() - .border_color(ThemeColors::BORDER) + .border_color(ThemeColors::border()) .items_center() .px_3() - .gap_1() + .gap_1p5() .text_xs() - .text_color(ThemeColors::TEXT_MUTED) - .child("📂") + .text_color(ThemeColors::text_muted()) .child( - tab.path - .split('/') - .collect::>() - .join(" › "), - ), + Icon::new(IconName::Folder) + .size(px(12.0)) + .text_color(ThemeColors::text_muted()), + ) + .children(segments.into_iter().enumerate().flat_map(|(idx, seg)| { + let is_last = idx == last_idx; + let seg_element = div() + .text_xs() + .text_color(if is_last { + ThemeColors::text_primary() + } else { + ThemeColors::text_muted() + }) + .child(seg.to_string()) + .into_any_element(); + + if is_last { + vec![seg_element] + } else { + vec![ + seg_element, + Icon::new(IconName::ChevronRight) + .size(px(10.0)) + .text_color(ThemeColors::text_muted()) + .into_any_element(), + ] + } + })), ) }) .child( @@ -247,27 +332,33 @@ impl Render for EditorView { div().flex_1().w_full().overflow_scrollbar().child( if let Some(tab) = &active_tab { let lines: Vec = tab.content.lines().map(|s| s.to_string()).collect(); + let total_lines = lines.len().max(1); h_flex() .size_full() - .p_3() - .gap_4() + .p_2() .font_family("monospace") .text_xs() .child( // 代码行号槽(Gutter) v_flex() .flex_shrink_0() - .text_color(ThemeColors::TEXT_MUTED) - .children((1..=lines.len().max(1)).map(|num| { - div().h(px(20.0)).child(format!("{num:4}")) + .w(px(48.0)) + .pr_3() + .border_r_1() + .border_color(ThemeColors::border()) + .text_color(ThemeColors::text_muted()) + .items_end() + .children((1..=total_lines).map(|num| { + div().h(px(20.0)).child(format!("{num}")) })), ) .child( // 代码文本展示行 v_flex() .flex_1() - .text_color(ThemeColors::TEXT_PRIMARY) + .pl_3() + .text_color(ThemeColors::text_primary()) .children(lines.into_iter().enumerate().map(|(idx, line)| { div() .id(idx) @@ -276,14 +367,77 @@ impl Render for EditorView { })), ) } else { - // 无打开文件欢迎占位 + // 无打开文件 Empty State(精致 Lithe 居中徽标与操作提示) h_flex() .size_full() .items_center() .justify_center() - .text_sm() - .text_color(ThemeColors::TEXT_MUTED) - .child("No file opened. Select a file from the project explorer or press Ctrl+P.") + .child( + v_flex() + .items_center() + .gap_3() + .child( + Icon::new(IconName::Zap) + .size(px(48.0)) + .text_color(ThemeColors::accent_blue()), + ) + .child( + div() + .text_lg() + .font_weight(FontWeight::BOLD) + .text_color(ThemeColors::text_primary()) + .child("Lithe IDEA"), + ) + .child( + div() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child("Next-generation IDE for Linux, powered by GPUI Kit & Rust Core"), + ) + .child( + v_flex() + .pt_2() + .gap_2() + .child( + h_flex() + .items_center() + .gap_2() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child("Search files everywhere:") + .child( + div() + .px_1p5() + .py(px(1.0)) + .bg(ThemeColors::bg_tab_hover()) + .border_1() + .border_color(ThemeColors::border()) + .rounded_sm() + .text_color(ThemeColors::text_primary()) + .child("Ctrl+P"), + ), + ) + .child( + h_flex() + .items_center() + .gap_2() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child("Toggle terminal panel:") + .child( + div() + .px_1p5() + .py(px(1.0)) + .bg(ThemeColors::bg_tab_hover()) + .border_1() + .border_color(ThemeColors::border()) + .rounded_sm() + .text_color(ThemeColors::text_primary()) + .child("Ctrl+`"), + ), + ), + ), + ) }, ), ) diff --git a/linux/src/workbench/sidebar.rs b/linux/src/workbench/sidebar.rs index a2eb22e46..b69af9ac1 100644 --- a/linux/src/workbench/sidebar.rs +++ b/linux/src/workbench/sidebar.rs @@ -1,8 +1,10 @@ +use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants as _}; -use gpui_kit::component::{h_flex, v_flex, Sizable as _}; +use gpui_kit::component::scroll::ScrollableElement as _; +use gpui_kit::component::{h_flex, v_flex, Icon, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, rgb, Context, EventEmitter, InteractiveElement as _, IntoElement, + div, px, Context, EventEmitter, FontWeight, InteractiveElement as _, IntoElement, ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, Window, }; use serde::{Deserialize, Serialize}; @@ -327,88 +329,71 @@ impl SidebarView { } false } +} - fn render_tab_button( - &self, - id: &'static str, - label: String, - is_active: bool, - tab: SidebarTab, - cx: &mut Context, - ) -> impl IntoElement { - div() - .id(id) - .px_3() - .py_1() - .rounded_t_sm() - .cursor_pointer() - .text_xs() - .when(is_active, |btn| { - btn.bg(ThemeColors::bg_sidebar()) - .text_color(ThemeColors::text_primary()) - .border_b_2() - .border_color(ThemeColors::accent_blue()) - }) - .when(!is_active, |btn| { - btn.text_color(ThemeColors::text_muted()) - .hover(|h| h.bg(ThemeColors::bg_tab_hover()).text_color(ThemeColors::text_primary())) - }) - .child(label) - .on_click(cx.listener(move |this, _event, _window, cx| { - this.set_tab(tab, cx); - })) - } +fn is_code_file(name: &str) -> bool { + let lower = name.to_lowercase(); + lower.ends_with(".rs") + || lower.ends_with(".js") + || lower.ends_with(".ts") + || lower.ends_with(".jsx") + || lower.ends_with(".tsx") + || lower.ends_with(".json") + || lower.ends_with(".toml") + || lower.ends_with(".html") + || lower.ends_with(".css") + || lower.ends_with(".java") + || lower.ends_with(".c") + || lower.ends_with(".cpp") + || lower.ends_with(".h") + || lower.ends_with(".hpp") + || lower.ends_with(".py") + || lower.ends_with(".go") + || lower.ends_with(".swift") + || lower.ends_with(".sh") + || lower.ends_with(".xml") + || lower.ends_with(".yaml") + || lower.ends_with(".yml") + || lower.ends_with(".sql") } impl Render for SidebarView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let title = match self.active_tab { + SidebarTab::Explorer => "PROJECT", + SidebarTab::Search => "SEARCH", + SidebarTab::Git => "GIT", + }; + v_flex() .size_full() - .bg(ThemeColors::BG_SIDEBAR) + .bg(ThemeColors::bg_sidebar()) .border_r_1() - .border_color(ThemeColors::BORDER) + .border_color(ThemeColors::border()) .child( - // 顶部视图切换栏 (Explorer / Search / Git) + // 顶部工具窗口精简标题 + 刷新操作按钮(无老旧横排Tab栏) h_flex() - .h(px(36.0)) + .h(px(32.0)) .w_full() - .bg(ThemeColors::BG_TAB_BAR) + .bg(ThemeColors::bg_sidebar()) .border_b_1() - .border_color(ThemeColors::BORDER) + .border_color(ThemeColors::border()) .items_center() .justify_between() - .px_2() + .px_3() .child( - h_flex() - .items_center() - .gap_1() - .child(self.render_tab_button( - "tab-explorer", - "Files".to_string(), - self.active_tab == SidebarTab::Explorer, - SidebarTab::Explorer, - cx, - )) - .child(self.render_tab_button( - "tab-search", - "Search".to_string(), - self.active_tab == SidebarTab::Search, - SidebarTab::Search, - cx, - )) - .child(self.render_tab_button( - "tab-git", - format!("Git ({})", self.git_changes.len()), - self.active_tab == SidebarTab::Git, - SidebarTab::Git, - cx, - )), + div() + .text_xs() + .font_weight(FontWeight::BOLD) + .text_color(ThemeColors::text_muted()) + .child(title), ) .child( - Button::new("refresh-btn") + Button::new("sidebar-refresh") .small() .ghost() - .label("↻") + .icon(IconName::RotateCw) + .tooltip("Refresh") .on_click(cx.listener(|this, _event, _window, cx| { this.refresh(cx); this.refresh_git(cx); @@ -417,7 +402,7 @@ impl Render for SidebarView { ) .child( // 对应面板内容渲染 - div().flex_1().w_full().child( + div().flex_1().w_full().overflow_y_scrollbar().child( match self.active_tab { SidebarTab::Explorer => self.render_explorer(cx).into_any_element(), SidebarTab::Search => self.render_search(cx).into_any_element(), @@ -437,13 +422,12 @@ impl SidebarView { v_flex() .size_full() - .p_2() - .gap_1() + .py_1() .when(self.is_loading, |this| { this.child( div() .text_xs() - .text_color(rgb(0x8a90a2)) + .text_color(ThemeColors::text_muted()) .p_2() .child("Loading workspace files..."), ) @@ -452,7 +436,7 @@ impl SidebarView { this.child( div() .text_xs() - .text_color(rgb(0xef4444)) + .text_color(ThemeColors::accent_red()) .p_2() .child(err.clone()), ) @@ -470,21 +454,73 @@ impl SidebarView { .items_center() .cursor_pointer() .rounded_sm() - .pl(px(indent + 4.0)) + .pl(px(indent + 6.0)) .pr_2() + .gap_1p5() .text_xs() .when(is_selected, |row| { - row.bg(ThemeColors::BG_TAB_ACTIVE).text_color(ThemeColors::TEXT_PRIMARY) + row.bg(ThemeColors::subtle_selection()) + .text_color(ThemeColors::text_primary()) }) .when(!is_selected, |row| { - row.text_color(ThemeColors::TEXT_PRIMARY).hover(|h| h.bg(ThemeColors::BG_TAB_HOVER)) + row.text_color(ThemeColors::text_primary()) + .hover(|h| h.bg(ThemeColors::bg_tab_hover())) }) .child(if is_dir { - if item.is_expanded { "📂 " } else { "📁 " } + if item.is_expanded { + h_flex() + .items_center() + .gap_1() + .child( + Icon::new(IconName::ChevronDown) + .size(px(12.0)) + .text_color(ThemeColors::text_muted()), + ) + .child( + Icon::new(IconName::FolderOpen) + .size(px(14.0)) + .text_color(ThemeColors::accent_blue()), + ) + .into_any_element() + } else { + h_flex() + .items_center() + .gap_1() + .child( + Icon::new(IconName::ChevronRight) + .size(px(12.0)) + .text_color(ThemeColors::text_muted()), + ) + .child( + Icon::new(IconName::Folder) + .size(px(14.0)) + .text_color(ThemeColors::accent_blue()), + ) + .into_any_element() + } } else { - "📄 " + let icon_name = if is_code_file(&item.name) { + IconName::FileCode + } else { + IconName::FileText + }; + h_flex() + .items_center() + .gap_1() + .child(div().w(px(12.0))) + .child( + Icon::new(icon_name) + .size(px(14.0)) + .text_color(ThemeColors::text_muted()), + ) + .into_any_element() }) - .child(item.name) + .child( + div() + .flex_1() + .truncate() + .child(item.name), + ) .on_click(cx.listener({ let path = path_str.clone(); move |this, _event, _window, cx| { @@ -510,15 +546,21 @@ impl SidebarView { .h(px(28.0)) .w_full() .items_center() - .bg(ThemeColors::BG_TAB_ACTIVE) + .gap_2() + .bg(ThemeColors::bg_tab_active()) .border_1() - .border_color(ThemeColors::BORDER) - .rounded_sm() + .border_color(ThemeColors::border()) + .rounded(px(4.0)) .px_2() + .child( + Icon::new(IconName::Search) + .size(px(13.0)) + .text_color(ThemeColors::text_muted()), + ) .child( div() .text_xs() - .text_color(ThemeColors::TEXT_MUTED) + .text_color(ThemeColors::text_muted()) .child("Search files or text..."), ), ) @@ -526,7 +568,8 @@ impl SidebarView { this.child( div() .text_xs() - .text_color(rgb(0x8a90a2)) + .text_color(ThemeColors::text_muted()) + .p_1() .child("Searching..."), ) }) @@ -544,22 +587,31 @@ impl SidebarView { v_flex() .id(idx) - .p_1() + .p_1p5() .rounded_sm() .cursor_pointer() - .hover(|h| h.bg(ThemeColors::BG_TAB_HOVER)) + .hover(|h| h.bg(ThemeColors::bg_tab_hover())) .child( h_flex() .items_center() - .gap_1() + .gap_1p5() .text_xs() - .text_color(ThemeColors::ACCENT_BLUE) - .child(format!("{}{}", path, line_info)), + .child( + Icon::new(IconName::FileText) + .size(px(12.0)) + .text_color(ThemeColors::accent_blue()), + ) + .child( + div() + .text_color(ThemeColors::accent_blue()) + .child(format!("{}{}", path, line_info)), + ), ) .child( div() .text_xs() - .text_color(ThemeColors::TEXT_MUTED) + .text_color(ThemeColors::text_muted()) + .pl(px(18.0)) .child(preview), ) .on_click(cx.listener({ @@ -587,21 +639,41 @@ impl SidebarView { .gap_2() .pb_2() .border_b_1() - .border_color(ThemeColors::BORDER) - .text_xs() - .text_color(ThemeColors::ACCENT_GREEN) - .child("⎇ Branch:") - .child(branch), + .border_color(ThemeColors::border()) + .child( + Icon::new(IconName::GitBranch) + .size(px(13.0)) + .text_color(ThemeColors::accent_green()), + ) + .child( + div() + .text_xs() + .font_weight(FontWeight::BOLD) + .text_color(ThemeColors::accent_green()) + .child(branch), + ), ) .child( div() .text_xs() - .text_color(ThemeColors::TEXT_MUTED) + .text_color(ThemeColors::text_muted()) .child(format!("Changed Files ({})", self.git_changes.len())), ) .children(self.git_changes.iter().enumerate().map(|(idx, change)| { let path = change.path.clone(); let status = change.status.clone(); + let status_color = match status.as_str() { + "M" => ThemeColors::accent_yellow(), + "A" => ThemeColors::accent_green(), + "D" => ThemeColors::accent_red(), + _ => ThemeColors::text_muted(), + }; + + let icon_name = if is_code_file(&path) { + IconName::FileCode + } else { + IconName::FileText + }; h_flex() .id(idx) @@ -612,18 +684,28 @@ impl SidebarView { .px_2() .rounded_sm() .cursor_pointer() - .hover(|h| h.bg(ThemeColors::BG_TAB_HOVER)) + .hover(|h| h.bg(ThemeColors::bg_tab_hover())) .text_xs() .child( h_flex() .items_center() .gap_2() - .text_color(ThemeColors::TEXT_PRIMARY) - .child(path.clone()), + .child( + Icon::new(icon_name) + .size(px(13.0)) + .text_color(ThemeColors::text_muted()), + ) + .child( + div() + .text_color(ThemeColors::text_primary()) + .truncate() + .child(path.clone()), + ), ) .child( div() - .text_color(rgb(0xf59e0b)) + .font_weight(FontWeight::BOLD) + .text_color(status_color) .child(status), ) .on_click(cx.listener({ diff --git a/linux/src/workbench/status_bar.rs b/linux/src/workbench/status_bar.rs index 66a447972..b9dd03a48 100644 --- a/linux/src/workbench/status_bar.rs +++ b/linux/src/workbench/status_bar.rs @@ -1,6 +1,7 @@ -use gpui_kit::component::h_flex; +use gpui_kit::assets::IconName; +use gpui_kit::component::{h_flex, Icon}; use gpui_kit::{ - div, px, Context, IntoElement, ParentElement as _, Render, Styled as _, Window, + div, px, Context, FontWeight, IntoElement, ParentElement as _, Render, Styled as _, Window, }; use crate::theme::ThemeColors; @@ -68,42 +69,96 @@ impl Render for StatusBarView { .text_xs() .text_color(ThemeColors::text_muted()) .child( - // 左侧状态栏项目 + // 左侧状态栏项目(分支 + 文件状态) h_flex() .items_center() - .gap_3() + .gap_2() .child( h_flex() .items_center() - .gap_1() + .gap_1p5() .text_color(ThemeColors::accent_green()) - .child("⎇") - .child(branch), + .child( + Icon::new(IconName::GitBranch) + .size(px(12.0)) + .text_color(ThemeColors::accent_green()), + ) + .child( + div() + .font_weight(FontWeight::SEMIBOLD) + .child(branch), + ), ) - .child(div().text_color(ThemeColors::border()).child("|")) .child( div() - .text_color(ThemeColors::text_primary()) - .child(file_label), + .h(px(10.0)) + .w(px(1.0)) + .bg(ThemeColors::border()) + .mx_1(), + ) + .child( + h_flex() + .items_center() + .gap_1p5() + .child( + Icon::new(IconName::FileText) + .size(px(12.0)) + .text_color(ThemeColors::text_muted()), + ) + .child( + div() + .text_color(ThemeColors::text_primary()) + .child(file_label), + ), ), ) .child( - // 右侧状态栏项目 + // 右侧状态栏项目(行列号、缩进、编码、语言、核心状态) h_flex() .items_center() - .gap_3() + .gap_2() .child(format!("Ln {}, Col {}", self.cursor_line, self.cursor_col)) - .child(div().text_color(ThemeColors::border()).child("|")) - .child(self.language.clone()) - .child(div().text_color(ThemeColors::border()).child("|")) - .child(self.encoding.clone()) - .child(div().text_color(ThemeColors::border()).child("|")) + .child( + div() + .h(px(10.0)) + .w(px(1.0)) + .bg(ThemeColors::border()), + ) .child("4 spaces") - .child(div().text_color(ThemeColors::border()).child("|")) .child( div() - .text_color(ThemeColors::accent_blue()) - .child("Lithe Core"), + .h(px(10.0)) + .w(px(1.0)) + .bg(ThemeColors::border()), + ) + .child(self.encoding.clone()) + .child( + div() + .h(px(10.0)) + .w(px(1.0)) + .bg(ThemeColors::border()), + ) + .child(self.language.clone()) + .child( + div() + .h(px(10.0)) + .w(px(1.0)) + .bg(ThemeColors::border()), + ) + .child( + h_flex() + .items_center() + .gap_1() + .child( + Icon::new(IconName::Check) + .size(px(12.0)) + .text_color(ThemeColors::accent_green()), + ) + .child( + div() + .text_color(ThemeColors::accent_blue()) + .child("Lithe Core"), + ), ), ) } diff --git a/linux/src/workbench/toolbar.rs b/linux/src/workbench/toolbar.rs index 748f52b4d..781991340 100644 --- a/linux/src/workbench/toolbar.rs +++ b/linux/src/workbench/toolbar.rs @@ -1,27 +1,29 @@ +use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::component::menu::{DropdownMenu as _, PopupMenuItem}; -use gpui_kit::component::{h_flex, Sizable as _}; +use gpui_kit::component::{h_flex, Icon, Sizable as _}; use gpui_kit::{ - div, px, Context, EventEmitter, InteractiveElement as _, IntoElement, ParentElement as _, - Render, StatefulInteractiveElement as _, Styled as _, Window, + div, px, Context, EventEmitter, FontWeight, InteractiveElement as _, IntoElement, + ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, Window, }; use crate::theme::ThemeColors; #[derive(Debug, Clone)] +#[allow(dead_code)] pub enum ToolbarEvent { NewFile, Save, CloseTab, Run, Debug, - #[allow(dead_code)] Stop, ToggleTerminal, ClearTerminal, ToggleSidebar, RefreshWorkspace, QuickOpen, + OpenSettings, About, Exit, } @@ -46,7 +48,6 @@ impl ToolbarView { } } - #[allow(dead_code)] pub fn set_git_branch(&mut self, branch: Option, cx: &mut Context) { self.git_branch = branch; cx.notify(); @@ -68,96 +69,78 @@ impl Render for ToolbarView { .justify_between() .px_3() .child( - // 1. 左侧:Logo + 菜单栏 + 项目名称选择器 + 分支 + // 1. 左侧:Lithe Logo + 项目选择器胶囊 + 分支选择器胶囊 h_flex() .items_center() - .gap_1() + .gap_3() .child( + // Lithe Logo + 品牌名 h_flex() .items_center() - .gap_1() - .pr_2() + .gap_1p5() + .pr_1() .child( - div() - .text_sm() - .text_color(ThemeColors::accent_blue()) - .child("⚡"), + Icon::new(IconName::Zap) + .size(px(16.0)) + .text_color(ThemeColors::accent_blue()), ) .child( div() + .font_weight(FontWeight::BOLD) .text_sm() .text_color(ThemeColors::text_primary()) .child("Lithe"), ), ) - // File 菜单 + // 项目选择器胶囊(点击可展开项目切换下拉菜单) .child({ let v = view.clone(); - Button::new("menu-file") + Button::new("tb-project-selector") .small() .ghost() - .label("File") + .icon(IconName::Folder) + .label(self.workspace_name.clone()) + .bg(ThemeColors::bg_tab_hover()) + .border_1() + .border_color(ThemeColors::border()) + .rounded_md() .dropdown_menu(move |menu, _window, _cx| { - let v_new = v.clone(); + let v_refresh = v.clone(); + let v_new_file = v.clone(); let v_save = v.clone(); let v_close = v.clone(); + let v_sidebar = v.clone(); + let v_terminal = v.clone(); + let v_settings = v.clone(); + let v_about = v.clone(); let v_exit = v.clone(); menu.item( PopupMenuItem::new("New File (Ctrl+N)") .on_click(move |_, _, cx| { - v_new.update(cx, |_, cx| cx.emit(ToolbarEvent::NewFile)); + v_new_file.update(cx, |_, cx| { + cx.emit(ToolbarEvent::NewFile) + }); }), ) .item( PopupMenuItem::new("Save (Ctrl+S)") .on_click(move |_, _, cx| { - v_save.update(cx, |_, cx| cx.emit(ToolbarEvent::Save)); + v_save.update(cx, |_, cx| { + cx.emit(ToolbarEvent::Save) + }); }), ) .item( PopupMenuItem::new("Close Active Tab (Ctrl+W)") .on_click(move |_, _, cx| { - v_close.update(cx, |_, cx| cx.emit(ToolbarEvent::CloseTab)); + v_close.update(cx, |_, cx| { + cx.emit(ToolbarEvent::CloseTab) + }); }), ) .separator() .item( - PopupMenuItem::new("Exit") - .on_click(move |_, _, cx| { - v_exit.update(cx, |_, cx| cx.emit(ToolbarEvent::Exit)); - }), - ) - }) - }) - // Edit 菜单 - .child( - Button::new("menu-edit") - .small() - .ghost() - .label("Edit") - .dropdown_menu(|menu, _window, _cx| { - menu.item(PopupMenuItem::new("Undo (Ctrl+Z)")) - .item(PopupMenuItem::new("Redo (Ctrl+Y)")) - .separator() - .item(PopupMenuItem::new("Cut (Ctrl+X)")) - .item(PopupMenuItem::new("Copy (Ctrl+C)")) - .item(PopupMenuItem::new("Paste (Ctrl+V)")) - }), - ) - // View 菜单 - .child({ - let v = view.clone(); - Button::new("menu-view") - .small() - .ghost() - .label("View") - .dropdown_menu(move |menu, _window, _cx| { - let v_sidebar = v.clone(); - let v_term = v.clone(); - let v_refresh = v.clone(); - - menu.item( PopupMenuItem::new("Toggle Sidebar (Ctrl+B)") .on_click(move |_, _, cx| { v_sidebar.update(cx, |_, cx| { @@ -168,12 +151,11 @@ impl Render for ToolbarView { .item( PopupMenuItem::new("Toggle Terminal (Ctrl+`)") .on_click(move |_, _, cx| { - v_term.update(cx, |_, cx| { + v_terminal.update(cx, |_, cx| { cx.emit(ToolbarEvent::ToggleTerminal) }); }), ) - .separator() .item( PopupMenuItem::new("Refresh Workspace") .on_click(move |_, _, cx| { @@ -182,124 +164,60 @@ impl Render for ToolbarView { }); }), ) - }) - }) - // Run 菜单 - .child({ - let v = view.clone(); - Button::new("menu-run") - .small() - .ghost() - .label("Run") - .dropdown_menu(move |menu, _window, _cx| { - let v_run = v.clone(); - let v_debug = v.clone(); - - menu.item( - PopupMenuItem::new("Run Configuration (Shift+F10)") - .on_click(move |_, _, cx| { - v_run.update(cx, |_, cx| cx.emit(ToolbarEvent::Run)); - }), - ) + .separator() .item( - PopupMenuItem::new("Debug Configuration (Shift+F9)") + PopupMenuItem::new("Settings") .on_click(move |_, _, cx| { - v_debug.update(cx, |_, cx| cx.emit(ToolbarEvent::Debug)); - }), - ) - }) - }) - // Terminal 菜单 - .child({ - let v = view.clone(); - Button::new("menu-terminal") - .small() - .ghost() - .label("Terminal") - .dropdown_menu(move |menu, _window, _cx| { - let v_toggle = v.clone(); - let v_clear = v.clone(); - - menu.item( - PopupMenuItem::new("Toggle Terminal") - .on_click(move |_, _, cx| { - v_toggle.update(cx, |_, cx| { - cx.emit(ToolbarEvent::ToggleTerminal) + v_settings.update(cx, |_, cx| { + cx.emit(ToolbarEvent::OpenSettings) }); }), ) .item( - PopupMenuItem::new("Clear Output") + PopupMenuItem::new("About Lithe") .on_click(move |_, _, cx| { - v_clear.update(cx, |_, cx| { - cx.emit(ToolbarEvent::ClearTerminal) + v_about.update(cx, |_, cx| { + cx.emit(ToolbarEvent::About) }); }), ) - }) - }) - // Help 菜单 - .child({ - let v = view.clone(); - Button::new("menu-help") - .small() - .ghost() - .label("Help") - .dropdown_menu(move |menu, _window, _cx| { - let v_about = v.clone(); - - menu.item( - PopupMenuItem::new("About Lithe") + .separator() + .item( + PopupMenuItem::new("Exit") .on_click(move |_, _, cx| { - v_about.update(cx, |_, cx| cx.emit(ToolbarEvent::About)); + v_exit.update(cx, |_, cx| { + cx.emit(ToolbarEvent::Exit) + }); }), ) }) }) - // 分隔竖线 - .child( - div() - .h(px(14.0)) - .w(px(1.0)) - .bg(ThemeColors::border()) - .mx_2(), - ) - // 项目选择器外观 + // 分支选择器胶囊:带 GitBranch 图标、分支名和 ChevronDown .child( h_flex() .items_center() - .gap_1() + .gap_1p5() .px_2() - .py(px(2.0)) + .py(px(3.0)) .rounded_md() .bg(ThemeColors::bg_tab_hover()) .border_1() .border_color(ThemeColors::border()) - .child("📂") .child( - div() - .text_xs() - .text_color(ThemeColors::text_primary()) - .child(self.workspace_name.clone()), - ), - ) - // Git 分支标识 - .child( - h_flex() - .items_center() - .gap_1() - .px_2() - .py(px(2.0)) - .rounded_md() - .bg(ThemeColors::bg_tab_hover()) - .border_1() - .border_color(ThemeColors::border()) - .child("⎇") + Icon::new(IconName::GitBranch) + .size(px(13.0)) + .text_color(ThemeColors::accent_green()), + ) .child( div() .text_xs() .text_color(ThemeColors::accent_green()) .child(branch), + ) + .child( + Icon::new(IconName::ChevronDown) + .size(px(12.0)) + .text_color(ThemeColors::text_muted()), ), ), ) @@ -307,12 +225,12 @@ impl Render for ToolbarView { // 2. 中间:全局搜索栏 (Search Everywhere) div() .id("search-everywhere-bar") - .w(px(260.0)) - .h(px(26.0)) + .w(px(280.0)) + .h(px(28.0)) .bg(ThemeColors::bg_tab_active()) .border_1() .border_color(ThemeColors::border()) - .rounded_md() + .rounded(px(6.0)) .px_2() .flex() .items_center() @@ -326,7 +244,11 @@ impl Render for ToolbarView { h_flex() .items_center() .gap_2() - .child(div().text_xs().text_color(ThemeColors::text_muted()).child("🔍")) + .child( + Icon::new(IconName::Search) + .size(px(13.0)) + .text_color(ThemeColors::text_muted()), + ) .child( div() .text_xs() @@ -338,22 +260,49 @@ impl Render for ToolbarView { div() .text_xs() .text_color(ThemeColors::text_muted()) - .px_1() + .px_1p5() + .py(px(1.0)) .bg(ThemeColors::bg_titlebar()) - .rounded_sm() + .border_1() + .border_color(ThemeColors::border()) + .rounded(px(4.0)) .child("Ctrl+P"), ), ) .child( - // 3. 右侧:运行/调试配置按钮组 + // 3. 右侧:运行目标胶囊 + 运行/调试/停止/设置控制组 h_flex() .items_center() .gap_2() + .child( + // 运行目标胶囊 + h_flex() + .items_center() + .gap_1p5() + .px_2() + .py(px(3.0)) + .rounded_md() + .bg(ThemeColors::bg_tab_hover()) + .border_1() + .border_color(ThemeColors::border()) + .child( + div() + .text_xs() + .text_color(ThemeColors::text_primary()) + .child("[Project] Default"), + ) + .child( + Icon::new(IconName::ChevronDown) + .size(px(12.0)) + .text_color(ThemeColors::text_muted()), + ), + ) .child( Button::new("tb-run") .small() .primary() - .label("▶ Run") + .icon(IconName::Play) + .label("Run") .on_click(cx.listener(|_this, _event, _window, cx| { cx.emit(ToolbarEvent::Run); })), @@ -362,18 +311,30 @@ impl Render for ToolbarView { Button::new("tb-debug") .small() .ghost() - .label("🐞 Debug") + .icon(IconName::Bug) + .label("Debug") .on_click(cx.listener(|_this, _event, _window, cx| { cx.emit(ToolbarEvent::Debug); })), ) .child( - Button::new("tb-save") + Button::new("tb-stop") + .small() + .ghost() + .icon(IconName::Square) + .tooltip("Stop") + .on_click(cx.listener(|_this, _event, _window, cx| { + cx.emit(ToolbarEvent::Stop); + })), + ) + .child( + Button::new("tb-settings") .small() .ghost() - .label("💾 Save") + .icon(IconName::Settings) + .tooltip("Settings") .on_click(cx.listener(|_this, _event, _window, cx| { - cx.emit(ToolbarEvent::Save); + cx.emit(ToolbarEvent::OpenSettings); })), ), ) diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index 044ec4e5a..54ad5f934 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -14,7 +14,7 @@ use crate::workbench::sidebar::{SidebarEvent, SidebarTab, SidebarView}; use crate::workbench::status_bar::StatusBarView; use crate::workbench::toolbar::{ToolbarEvent, ToolbarView}; -/// Linux 前端主工作台视图组件(复刻 Tauri IDE 经典架构) +/// Linux 前端主工作台视图组件(复刻 macOS LitheTheme / Tauri 架构) pub struct WorkbenchView { /// 项目工作区根路径 pub workspace_root: String, @@ -78,7 +78,20 @@ impl WorkbenchView { } }); - // 2. 订阅左侧活动栏 (Activity Rail) 事件 + // 2. 观察侧边栏 Git 分支状态变化并同步至 Toolbar 与 StatusBar + let toolbar_branch_sync = toolbar.clone(); + let status_bar_branch_sync = status_bar.clone(); + let obs_sidebar = cx.observe(&sidebar, move |_this, sidebar, cx| { + let branch = sidebar.read(cx).git_branch.clone(); + let _ = toolbar_branch_sync.update(cx, |tb, cx| { + tb.set_git_branch(branch.clone(), cx); + }); + let _ = status_bar_branch_sync.update(cx, |sb, cx| { + sb.set_git_branch(branch, cx); + }); + }); + + // 3. 订阅左侧活动栏 (Activity Rail) 事件 let sidebar_clone = sidebar.clone(); let bottom_panel_clone = bottom_panel.clone(); let sub_rail = cx.subscribe(&activity_rail, move |this, rail, event: &ActivityRailEvent, cx| { @@ -116,7 +129,7 @@ impl WorkbenchView { } }); - // 3. 订阅顶部标题栏与菜单栏事件 + // 4. 订阅顶部工具栏事件 let sub_toolbar = cx.subscribe(&toolbar, |this, _toolbar, event: &ToolbarEvent, cx| { match event { ToolbarEvent::NewFile => { @@ -192,9 +205,14 @@ impl WorkbenchView { bp.append_log("[Stop] Session terminated by user.".to_string(), cx); }); } + ToolbarEvent::OpenSettings => { + let _ = this.bottom_panel.update(cx, |bp, cx| { + bp.append_log("[Settings] Settings dialog requested".to_string(), cx); + }); + } ToolbarEvent::About => { let _ = this.bottom_panel.update(cx, |bp, cx| { - bp.append_log("[About] Lithe IDE for Linux (Powered by Zed GPUI Kit & Rust Core)".to_string(), cx); + bp.append_log("[About] Lithe IDE for Linux (Powered by GPUI Kit & Rust Core)".to_string(), cx); }); } ToolbarEvent::Exit => { @@ -213,7 +231,7 @@ impl WorkbenchView { bottom_panel, status_bar, client: CoreClient::new(), - _subscriptions: vec![sub_sidebar, sub_rail, sub_toolbar], + _subscriptions: vec![sub_sidebar, obs_sidebar, sub_rail, sub_toolbar], } } @@ -251,7 +269,7 @@ impl Render for WorkbenchView { .size_full() .bg(ThemeColors::bg_editor()) .child( - // 1. 顶部标题栏/菜单栏/运行控制 (Title Bar) + // 1. 顶部标题栏/工具栏 (Toolbar) self.toolbar.clone(), ) .child( From 8c334ec238a48a4b5f586424902adf943d7d236a Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Thu, 24 Sep 2026 11:02:41 +0800 Subject: [PATCH 004/109] =?UTF-8?q?feat(linux):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E6=BB=9A=E5=8A=A8=E4=B8=8E=E9=80=89?= =?UTF-8?q?=E4=B8=AD=EF=BC=8C=E8=A1=A5=E9=BD=90=E4=BE=A7=E8=BE=B9=E6=A0=8F?= =?UTF-8?q?/=E6=90=9C=E7=B4=A2/=E8=AE=BE=E7=BD=AE=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - editor: 正文改用 gpui-kit 上游 Editor/EditorState,恢复上下滚动、 文本选中与复制;标签内容与语言在切换时同步进编辑器,编辑变更回写 标签并置脏,保存读取编辑器实时文本。 - view: 接入 SearchEverywhere 与 SettingsDialog 事件;支持侧边栏宽度、 底部面板高度的拖拽调整(带边界 clamp)。 - sidebar: 扩展 SidebarEvent/SidebarTab 与文件树交互。 - bottom_panel: 高度可配置。 - 忽略 .codegraph/ 索引目录。 --- .gitignore | 1 + linux/src/workbench/bottom_panel.rs | 9 +- linux/src/workbench/editor.rs | 295 ++++++---- linux/src/workbench/mod.rs | 10 +- linux/src/workbench/search_everywhere.rs | 613 ++++++++++++++++++++ linux/src/workbench/settings_dialog.rs | 694 +++++++++++++++++++++++ linux/src/workbench/sidebar.rs | 245 +++++--- linux/src/workbench/view.rs | 471 +++++++++++++-- 8 files changed, 2081 insertions(+), 257 deletions(-) create mode 100644 linux/src/workbench/search_everywhere.rs create mode 100644 linux/src/workbench/settings_dialog.rs diff --git a/.gitignore b/.gitignore index 38786b01b..82d0575f5 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ docs/visual-qa/ .reasonix/ reasonix.toml .codex/ +.codegraph/ /node_modules/ *.swp *.swo diff --git a/linux/src/workbench/bottom_panel.rs b/linux/src/workbench/bottom_panel.rs index 711d70dc9..8d90d7e4a 100644 --- a/linux/src/workbench/bottom_panel.rs +++ b/linux/src/workbench/bottom_panel.rs @@ -21,6 +21,7 @@ pub enum BottomTab { pub struct BottomPanelView { pub active_tab: BottomTab, pub is_collapsed: bool, + pub height: f32, pub terminal: Entity, pub output_logs: Vec, pub problems: Vec, @@ -33,12 +34,18 @@ impl BottomPanelView { Self { active_tab: BottomTab::Terminal, is_collapsed: false, + height: 240.0, terminal, output_logs: vec!["[Lithe Linux] Initialized.".to_string()], problems: Vec::new(), } } + pub fn set_height(&mut self, height: f32, cx: &mut Context) { + self.height = height; + cx.notify(); + } + pub fn set_tab(&mut self, tab: BottomTab, cx: &mut Context) { self.active_tab = tab; self.is_collapsed = false; @@ -148,7 +155,7 @@ impl Render for BottomPanelView { } v_flex() - .h(px(240.0)) + .h(px(self.height)) .w_full() .bg(ThemeColors::bg_bottom_panel()) .border_t_1() diff --git a/linux/src/workbench/editor.rs b/linux/src/workbench/editor.rs index 5049f875f..cd505bc84 100644 --- a/linux/src/workbench/editor.rs +++ b/linux/src/workbench/editor.rs @@ -1,10 +1,12 @@ use gpui_kit::assets::IconName; +use gpui_kit::component::input::{Editor, EditorState, InputEvent}; use gpui_kit::component::scroll::ScrollableElement as _; use gpui_kit::component::{h_flex, v_flex, Icon}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, Context, FontWeight, InteractiveElement as _, IntoElement, ParentElement as _, - Render, StatefulInteractiveElement as _, Styled as _, Window, + div, px, relative, AppContext as _, Context, Entity, FontWeight, InteractiveElement as _, + IntoElement, ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, + Subscription, Window, }; use crate::core::CoreClient; @@ -23,22 +25,45 @@ pub struct EditorTab { } /// 多标签代码编辑器组件(对齐 macOS LitheTheme / IntelliJ 视觉规范) +/// +/// 正文复用上游 `gpui_kit::component::input::Editor`,由它负责滚动、 +/// 文本选择与语法高亮;本组件只管理标签栏、面包屑与文件读写。 pub struct EditorView { pub workspace_root: String, pub tabs: Vec, pub active_tab_index: Option, #[allow(dead_code)] pub encoding: String, + /// 当前活动标签对应的上游编辑器状态,随视图生命周期常驻并复用。 + editor_state: Entity, + /// 已同步到 `editor_state` 的标签索引。 + synced_tab: Option, + /// 活动标签内容被外部替换(重新打开文件)时置位,渲染时重新灌入编辑器。 + sync_needed: bool, + /// 监听编辑器文本变化以回写标签内容与脏标记。 + _editor_subscription: Subscription, client: CoreClient, } impl EditorView { - pub fn new(workspace_root: String, _cx: &mut Context) -> Self { + pub fn new(workspace_root: String, window: &mut Window, cx: &mut Context) -> Self { + let editor_state = cx.new(|cx| EditorState::new(window, cx).line_number(true)); + let _editor_subscription = + cx.subscribe_in(&editor_state, window, |this, _state, event: &InputEvent, _window, cx| { + if matches!(event, InputEvent::Change) { + this.on_editor_change(cx); + } + }); + Self { workspace_root, tabs: Vec::new(), active_tab_index: None, encoding: "UTF-8".to_string(), + editor_state, + synced_tab: None, + sync_needed: false, + _editor_subscription, client: CoreClient::new(), } } @@ -46,6 +71,13 @@ impl EditorView { /// 打开文件,如果已在标签中则切换,否则新增标签 pub fn open_file(&mut self, path: String, content: String, cx: &mut Context) { if let Some(pos) = self.tabs.iter().position(|t| t.path == path) { + if let Some(tab) = self.tabs.get_mut(pos) { + if tab.content != content { + tab.content = content; + tab.is_dirty = false; + self.sync_needed = true; + } + } self.active_tab_index = Some(pos); cx.notify(); return; @@ -66,6 +98,7 @@ impl EditorView { }); self.active_tab_index = Some(self.tabs.len() - 1); + self.sync_needed = true; cx.notify(); } @@ -78,12 +111,60 @@ impl EditorView { } else if let Some(current) = self.active_tab_index { if current >= self.tabs.len() { self.active_tab_index = Some(self.tabs.len() - 1); + } else if current > index { + self.active_tab_index = Some(current - 1); } } + self.sync_needed = true; cx.notify(); } } + /// 把当前活动标签的文本与语言灌入上游编辑器。 + fn sync_active_editor(&mut self, window: &mut Window, cx: &mut Context) { + let active_index = self.active_tab_index; + + if self.synced_tab == active_index && !self.sync_needed { + return; + } + + let Some(index) = active_index else { + self.synced_tab = None; + self.sync_needed = false; + return; + }; + + let Some(tab) = self.tabs.get(index).cloned() else { + return; + }; + + let language = Self::language_name(&tab.path).to_lowercase(); + let content = tab.content.clone(); + self.editor_state.update(cx, |editor, cx| { + editor.set_value(content, window, cx); + editor.set_highlighter(language, cx); + }); + + self.synced_tab = Some(index); + self.sync_needed = false; + } + + /// 编辑器文本变化后回写标签内容并标记为已修改。 + fn on_editor_change(&mut self, cx: &mut Context) { + let Some(index) = self.active_tab_index else { + return; + }; + + let value = self.editor_state.read(cx).value().to_string(); + if let Some(tab) = self.tabs.get_mut(index) { + if tab.content != value { + tab.content = value; + tab.is_dirty = true; + } + } + cx.notify(); + } + /// 保存当前活动的标签页至磁盘(对接 `file.write`) pub fn save_active(&mut self, cx: &mut Context) { let Some(idx) = self.active_tab_index else { @@ -93,9 +174,11 @@ impl EditorView { return; }; + // 以编辑器内的实时文本为准,避免依赖事件回写的时序。 + let text = self.editor_state.read(cx).value().to_string(); + let root = self.workspace_root.clone(); let path = tab.path.clone(); - let text = tab.content.clone(); let client = self.client.clone(); cx.spawn(async move |this, cx| { @@ -173,11 +256,15 @@ fn is_code_file(name: &str) -> bool { } impl Render for EditorView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.sync_active_editor(window, cx); + let active_tab = self .active_tab_index .and_then(|idx| self.tabs.get(idx).cloned()); + let editor_state = self.editor_state.clone(); + v_flex() .size_full() .bg(ThemeColors::bg_editor()) @@ -328,118 +415,94 @@ impl Render for EditorView { ) }) .child( - // 3. 中央代码内容区 - div().flex_1().w_full().overflow_scrollbar().child( - if let Some(tab) = &active_tab { - let lines: Vec = tab.content.lines().map(|s| s.to_string()).collect(); - let total_lines = lines.len().max(1); - - h_flex() - .size_full() - .p_2() - .font_family("monospace") - .text_xs() - .child( - // 代码行号槽(Gutter) - v_flex() - .flex_shrink_0() - .w(px(48.0)) - .pr_3() - .border_r_1() - .border_color(ThemeColors::border()) - .text_color(ThemeColors::text_muted()) - .items_end() - .children((1..=total_lines).map(|num| { - div().h(px(20.0)).child(format!("{num}")) - })), - ) - .child( - // 代码文本展示行 - v_flex() - .flex_1() - .pl_3() - .text_color(ThemeColors::text_primary()) - .children(lines.into_iter().enumerate().map(|(idx, line)| { - div() - .id(idx) - .h(px(20.0)) - .child(if line.is_empty() { " ".to_string() } else { line }) - })), - ) - } else { + // 3. 中央代码内容区(滚动与文本选择由上游 Editor 负责) + div() + .flex_1() + .w_full() + .overflow_hidden() + .when_some(active_tab.as_ref().map(|_| editor_state.clone()), |this, state| { + this.child( + Editor::new(&state) + .h(relative(1.0)) + .bordered(false) + .readonly(false), + ) + }) + .when(active_tab.is_none(), |this| { // 无打开文件 Empty State(精致 Lithe 居中徽标与操作提示) - h_flex() - .size_full() - .items_center() - .justify_center() - .child( - v_flex() - .items_center() - .gap_3() - .child( - Icon::new(IconName::Zap) - .size(px(48.0)) - .text_color(ThemeColors::accent_blue()), - ) - .child( - div() - .text_lg() - .font_weight(FontWeight::BOLD) - .text_color(ThemeColors::text_primary()) - .child("Lithe IDEA"), - ) - .child( - div() - .text_xs() - .text_color(ThemeColors::text_muted()) - .child("Next-generation IDE for Linux, powered by GPUI Kit & Rust Core"), - ) - .child( - v_flex() - .pt_2() - .gap_2() - .child( - h_flex() - .items_center() - .gap_2() - .text_xs() - .text_color(ThemeColors::text_muted()) - .child("Search files everywhere:") - .child( - div() - .px_1p5() - .py(px(1.0)) - .bg(ThemeColors::bg_tab_hover()) - .border_1() - .border_color(ThemeColors::border()) - .rounded_sm() - .text_color(ThemeColors::text_primary()) - .child("Ctrl+P"), - ), - ) - .child( - h_flex() - .items_center() - .gap_2() - .text_xs() - .text_color(ThemeColors::text_muted()) - .child("Toggle terminal panel:") - .child( - div() - .px_1p5() - .py(px(1.0)) - .bg(ThemeColors::bg_tab_hover()) - .border_1() - .border_color(ThemeColors::border()) - .rounded_sm() - .text_color(ThemeColors::text_primary()) - .child("Ctrl+`"), - ), - ), - ), - ) - }, - ), + this.child( + h_flex() + .size_full() + .items_center() + .justify_center() + .child( + v_flex() + .items_center() + .gap_3() + .child( + Icon::new(IconName::Zap) + .size(px(48.0)) + .text_color(ThemeColors::accent_blue()), + ) + .child( + div() + .text_lg() + .font_weight(FontWeight::BOLD) + .text_color(ThemeColors::text_primary()) + .child("Lithe IDEA"), + ) + .child( + div() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child("Next-generation IDE for Linux, powered by GPUI Kit & Rust Core"), + ) + .child( + v_flex() + .pt_2() + .gap_2() + .child( + h_flex() + .items_center() + .gap_2() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child("Search files everywhere:") + .child( + div() + .px_1p5() + .py(px(1.0)) + .bg(ThemeColors::bg_tab_hover()) + .border_1() + .border_color(ThemeColors::border()) + .rounded_sm() + .text_color(ThemeColors::text_primary()) + .child("Ctrl+P"), + ), + ) + .child( + h_flex() + .items_center() + .gap_2() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child("Toggle terminal panel:") + .child( + div() + .px_1p5() + .py(px(1.0)) + .bg(ThemeColors::bg_tab_hover()) + .border_1() + .border_color(ThemeColors::border()) + .rounded_sm() + .text_color(ThemeColors::text_primary()) + .child("Ctrl+`"), + ), + ), + ), + ), + ) + }), ) } } diff --git a/linux/src/workbench/mod.rs b/linux/src/workbench/mod.rs index 5a3e7857f..e397bbc73 100644 --- a/linux/src/workbench/mod.rs +++ b/linux/src/workbench/mod.rs @@ -1,6 +1,8 @@ pub mod activity_rail; pub mod bottom_panel; pub mod editor; +pub mod search_everywhere; +pub mod settings_dialog; pub mod sidebar; pub mod status_bar; pub mod terminal; @@ -14,11 +16,15 @@ pub use bottom_panel::BottomPanelView; #[allow(unused_imports)] pub use editor::EditorView; #[allow(unused_imports)] -pub use sidebar::SidebarView; +pub use search_everywhere::{SearchEverywhereEvent, SearchEverywhereModal, SearchScope}; +#[allow(unused_imports)] +pub use settings_dialog::{SettingsCategory, SettingsDialog, SettingsEvent}; +#[allow(unused_imports)] +pub use sidebar::{SidebarEvent, SidebarTab, SidebarView}; #[allow(unused_imports)] pub use status_bar::StatusBarView; #[allow(unused_imports)] pub use terminal::TerminalView; #[allow(unused_imports)] -pub use toolbar::ToolbarView; +pub use toolbar::{ToolbarEvent, ToolbarView}; pub use view::WorkbenchView; diff --git a/linux/src/workbench/search_everywhere.rs b/linux/src/workbench/search_everywhere.rs new file mode 100644 index 000000000..4ce398b58 --- /dev/null +++ b/linux/src/workbench/search_everywhere.rs @@ -0,0 +1,613 @@ +use gpui_kit::assets::IconName; +use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::scroll::ScrollableElement as _; +use gpui_kit::component::{h_flex, v_flex, Icon, Sizable as _}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::{ + div, px, rgba, Context, EventEmitter, FocusHandle, FontWeight, + InteractiveElement as _, IntoElement, KeyDownEvent, ParentElement as _, Render, + StatefulInteractiveElement as _, Styled as _, Window, +}; + +use crate::theme::ThemeColors; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SearchScope { + All, + Files, + Actions, +} + +#[derive(Debug, Clone)] +pub enum SearchEverywhereEvent { + OpenFile(String), + ExecuteAction(String), + Close, +} + +#[derive(Debug, Clone)] +pub struct SearchActionItem { + pub id: String, + pub name: String, + pub shortcut: Option, + pub icon: IconName, +} + +#[derive(Debug, Clone)] +pub enum MatchedItem { + File { + path: String, + name: String, + dir: String, + }, + Action { + id: String, + name: String, + shortcut: Option, + icon: IconName, + }, +} + +/// IntelliJ / macOS Lithe 风格居中全局搜索模态弹窗 +pub struct SearchEverywhereModal { + pub query: String, + pub scope: SearchScope, + pub files: Vec, + pub actions: Vec, + pub selected_index: usize, + pub focus_handle: FocusHandle, +} + +impl EventEmitter for SearchEverywhereModal {} + +impl SearchEverywhereModal { + pub fn new(cx: &mut Context) -> Self { + let actions = vec![ + SearchActionItem { + id: "workbench.new_file".to_string(), + name: "New File".to_string(), + shortcut: Some("Ctrl+N".to_string()), + icon: IconName::FilePlus, + }, + SearchActionItem { + id: "workbench.save".to_string(), + name: "Save Active File".to_string(), + shortcut: Some("Ctrl+S".to_string()), + icon: IconName::Save, + }, + SearchActionItem { + id: "workbench.close_tab".to_string(), + name: "Close Active Tab".to_string(), + shortcut: Some("Ctrl+W".to_string()), + icon: IconName::Close, + }, + SearchActionItem { + id: "workbench.toggle_terminal".to_string(), + name: "Toggle Terminal".to_string(), + shortcut: Some("Ctrl+`".to_string()), + icon: IconName::Terminal, + }, + SearchActionItem { + id: "workbench.toggle_sidebar".to_string(), + name: "Toggle Sidebar".to_string(), + shortcut: Some("Ctrl+B".to_string()), + icon: IconName::PanelLeft, + }, + SearchActionItem { + id: "workbench.open_settings".to_string(), + name: "Open Settings".to_string(), + shortcut: Some("Ctrl+,".to_string()), + icon: IconName::Settings, + }, + SearchActionItem { + id: "workbench.refresh_workspace".to_string(), + name: "Refresh Workspace".to_string(), + shortcut: Some("Ctrl+R".to_string()), + icon: IconName::RotateCw, + }, + SearchActionItem { + id: "workbench.run".to_string(), + name: "Run Project".to_string(), + shortcut: Some("Ctrl+F5".to_string()), + icon: IconName::Play, + }, + SearchActionItem { + id: "workbench.debug".to_string(), + name: "Debug Project".to_string(), + shortcut: Some("F5".to_string()), + icon: IconName::Bug, + }, + SearchActionItem { + id: "workbench.clear_terminal".to_string(), + name: "Clear Terminal".to_string(), + shortcut: Some("Ctrl+K".to_string()), + icon: IconName::Trash, + }, + ]; + + Self { + query: String::new(), + scope: SearchScope::All, + files: Vec::new(), + actions, + selected_index: 0, + focus_handle: cx.focus_handle(), + } + } + + pub fn set_files(&mut self, files: Vec, cx: &mut Context) { + self.files = files; + cx.notify(); + } + + pub fn reset(&mut self, cx: &mut Context) { + self.query.clear(); + self.selected_index = 0; + cx.notify(); + } + + fn filtered_items(&self) -> Vec { + let q = self.query.trim().to_lowercase(); + let mut results = Vec::new(); + + // 1. Files + if self.scope == SearchScope::All || self.scope == SearchScope::Files { + for file in &self.files { + if q.is_empty() || file.to_lowercase().contains(&q) { + let (dir, name) = if let Some((d, n)) = file.rsplit_once('/') { + (d.to_string(), n.to_string()) + } else { + (String::new(), file.clone()) + }; + results.push(MatchedItem::File { + path: file.clone(), + name, + dir, + }); + } + } + } + + // 2. Actions + if self.scope == SearchScope::All || self.scope == SearchScope::Actions { + for act in &self.actions { + if q.is_empty() + || act.name.to_lowercase().contains(&q) + || act.id.to_lowercase().contains(&q) + { + results.push(MatchedItem::Action { + id: act.id.clone(), + name: act.name.clone(), + shortcut: act.shortcut.clone(), + icon: act.icon, + }); + } + } + } + + results + } + + fn select_item(&self, item: &MatchedItem, cx: &mut Context) { + match item { + MatchedItem::File { path, .. } => { + cx.emit(SearchEverywhereEvent::OpenFile(path.clone())); + } + MatchedItem::Action { id, .. } => { + cx.emit(SearchEverywhereEvent::ExecuteAction(id.clone())); + } + } + } +} + +fn is_code_file(name: &str) -> bool { + let lower = name.to_lowercase(); + lower.ends_with(".rs") + || lower.ends_with(".js") + || lower.ends_with(".ts") + || lower.ends_with(".jsx") + || lower.ends_with(".tsx") + || lower.ends_with(".json") + || lower.ends_with(".toml") + || lower.ends_with(".html") + || lower.ends_with(".css") + || lower.ends_with(".java") + || lower.ends_with(".c") + || lower.ends_with(".cpp") + || lower.ends_with(".h") + || lower.ends_with(".py") + || lower.ends_with(".go") + || lower.ends_with(".swift") + || lower.ends_with(".sh") + || lower.ends_with(".sql") +} + +impl Render for SearchEverywhereModal { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // 请求聚焦以接收按键输入 + window.focus(&self.focus_handle, cx); + + let filtered = self.filtered_items(); + let current_index = if filtered.is_empty() { + 0 + } else { + self.selected_index.min(filtered.len() - 1) + }; + + // 全屏半透明遮罩背景 + div() + .id("search-everywhere-backdrop") + .track_focus(&self.focus_handle) + .absolute() + .inset_0() + .bg(rgba(0x00000088)) + .flex() + .items_center() + .justify_center() + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { + let key = event.keystroke.key.as_str(); + match key { + "escape" => { + cx.emit(SearchEverywhereEvent::Close); + } + "up" | "arrowup" => { + if this.selected_index > 0 { + this.selected_index -= 1; + cx.notify(); + } + } + "down" | "arrowdown" => { + let total = this.filtered_items().len(); + if total > 0 && this.selected_index + 1 < total { + this.selected_index += 1; + cx.notify(); + } + } + "enter" => { + let items = this.filtered_items(); + let idx = if items.is_empty() { + 0 + } else { + this.selected_index.min(items.len() - 1) + }; + if let Some(item) = items.get(idx) { + this.select_item(item, cx); + } + } + "backspace" => { + this.query.pop(); + this.selected_index = 0; + cx.notify(); + } + "space" => { + this.query.push(' '); + this.selected_index = 0; + cx.notify(); + } + _ => { + if !event.keystroke.modifiers.control + && !event.keystroke.modifiers.alt + && !event.keystroke.modifiers.platform + { + if let Some(ch) = &event.keystroke.key_char { + this.query.push_str(ch); + this.selected_index = 0; + cx.notify(); + } else if key.chars().count() == 1 { + this.query.push_str(key); + this.selected_index = 0; + cx.notify(); + } + } + } + } + })) + .on_mouse_down( + gpui_kit::MouseButton::Left, + cx.listener(|_this, _event, _window, cx| { + cx.emit(SearchEverywhereEvent::Close); + }), + ) + .child( + // 居中模态卡片,宽 580px,深色卡片与边框阴影 + v_flex() + .id("search-everywhere-card") + .w(px(580.0)) + .max_h(px(480.0)) + .bg(ThemeColors::bg_sidebar()) + .border_1() + .border_color(ThemeColors::border()) + .rounded_lg() + .shadow_lg() + .overflow_hidden() + .on_mouse_down( + gpui_kit::MouseButton::Left, + cx.listener(|_this, _event, _window, _cx| { + // 阻止向外层遮罩冒泡关闭 + }), + ) + .child( + // 1. 顶部搜索框栏(含图标、输入展示、关闭按钮) + h_flex() + .h(px(46.0)) + .w_full() + .items_center() + .px_3() + .gap_2p5() + .border_b_1() + .border_color(ThemeColors::border()) + .bg(ThemeColors::bg_titlebar()) + .child( + Icon::new(IconName::Search) + .size(px(16.0)) + .text_color(ThemeColors::accent_blue()), + ) + .child( + h_flex() + .flex_1() + .items_center() + .gap_1() + .child( + div() + .text_sm() + .text_color(if self.query.is_empty() { + ThemeColors::text_muted() + } else { + ThemeColors::text_primary() + }) + .child(if self.query.is_empty() { + "Search files, actions, symbols (Type to search)..." + .to_string() + } else { + self.query.clone() + }), + ) + .child( + // 闪烁光标模拟 + div() + .w(px(2.0)) + .h(px(14.0)) + .bg(ThemeColors::accent_blue()), + ), + ) + .when(!self.query.is_empty(), |row| { + row.child( + Button::new("clear-search-query") + .small() + .ghost() + .icon(IconName::Close) + .on_click(cx.listener(|this, _event, _window, cx| { + this.query.clear(); + this.selected_index = 0; + cx.notify(); + })), + ) + }) + .child( + Button::new("close-search-modal") + .small() + .ghost() + .icon(IconName::Close) + .on_click(cx.listener(|_this, _event, _window, cx| { + cx.emit(SearchEverywhereEvent::Close); + })), + ), + ) + .child( + // 2. Scope 药丸选项(All, Files, Actions) + h_flex() + .h(px(34.0)) + .w_full() + .items_center() + .px_3() + .gap_1p5() + .border_b_1() + .border_color(ThemeColors::border()) + .bg(ThemeColors::bg_tab_bar()) + .child(self.render_scope_pill( + "scope-all", + "All", + self.scope == SearchScope::All, + SearchScope::All, + cx, + )) + .child(self.render_scope_pill( + "scope-files", + "Files", + self.scope == SearchScope::Files, + SearchScope::Files, + cx, + )) + .child(self.render_scope_pill( + "scope-actions", + "Actions", + self.scope == SearchScope::Actions, + SearchScope::Actions, + cx, + )), + ) + .child( + // 3. 结果列表展示区 + div() + .flex_1() + .w_full() + .max_h(px(320.0)) + .overflow_y_scrollbar() + .py_1() + .when(filtered.is_empty(), |list| { + list.child( + div() + .w_full() + .py_8() + .text_center() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child("No matching files or actions found"), + ) + }) + .children(filtered.into_iter().enumerate().map(|(idx, item)| { + let is_selected = idx == current_index; + let item_clone = item.clone(); + + h_flex() + .id(idx) + .h(px(34.0)) + .w_full() + .items_center() + .justify_between() + .px_3() + .cursor_pointer() + .when(is_selected, |row| { + row.bg(ThemeColors::subtle_selection()) + .border_l_2() + .border_color(ThemeColors::accent_blue()) + }) + .when(!is_selected, |row| { + row.hover(|h| h.bg(ThemeColors::bg_tab_hover())) + }) + .child(match &item { + MatchedItem::File { name, dir, .. } => { + let icon = if is_code_file(name) { + IconName::FileCode + } else { + IconName::FileText + }; + h_flex() + .items_center() + .gap_2() + .child( + Icon::new(icon) + .size(px(14.0)) + .text_color(ThemeColors::accent_blue()), + ) + .child( + div() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(ThemeColors::text_primary()) + .child(name.clone()), + ) + .when(!dir.is_empty(), |row| { + row.child( + div() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(dir.clone()), + ) + }) + .into_any_element() + } + MatchedItem::Action { + name, + icon, + .. + } => h_flex() + .items_center() + .gap_2() + .child( + Icon::new(*icon) + .size(px(14.0)) + .text_color(ThemeColors::accent_green()), + ) + .child( + div() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(ThemeColors::text_primary()) + .child(name.clone()), + ) + .into_any_element(), + }) + .child(match &item { + MatchedItem::File { .. } => div() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child("File") + .into_any_element(), + MatchedItem::Action { shortcut, .. } => { + if let Some(sc) = shortcut { + div() + .px_1p5() + .py(px(1.0)) + .rounded_sm() + .bg(ThemeColors::bg_titlebar()) + .border_1() + .border_color(ThemeColors::border()) + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(sc.clone()) + .into_any_element() + } else { + div().into_any_element() + } + } + }) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.select_item(&item_clone, cx); + })) + })), + ) + .child( + // 4. 底部微型操作指引栏 + h_flex() + .h(px(26.0)) + .w_full() + .items_center() + .justify_between() + .px_3() + .bg(ThemeColors::bg_titlebar()) + .border_t_1() + .border_color(ThemeColors::border()) + .child( + div() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child("↑↓ Navigate · Enter Select · Esc Close"), + ) + .child( + div() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child("Search Everywhere"), + ), + ), + ) + } +} + +impl SearchEverywhereModal { + fn render_scope_pill( + &self, + id: &'static str, + label: &'static str, + is_active: bool, + scope: SearchScope, + cx: &mut Context, + ) -> impl IntoElement { + h_flex() + .id(id) + .items_center() + .px_2p5() + .py(px(2.0)) + .rounded_full() + .cursor_pointer() + .text_xs() + .when(is_active, |pill| { + pill.bg(ThemeColors::accent_blue()) + .text_color(ThemeColors::text_primary()) + .font_weight(FontWeight::BOLD) + }) + .when(!is_active, |pill| { + pill.text_color(ThemeColors::text_muted()).hover(|h| { + h.bg(ThemeColors::bg_tab_hover()) + .text_color(ThemeColors::text_primary()) + }) + }) + .child(label) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.scope = scope; + this.selected_index = 0; + cx.notify(); + })) + } +} diff --git a/linux/src/workbench/settings_dialog.rs b/linux/src/workbench/settings_dialog.rs new file mode 100644 index 000000000..14dde8f32 --- /dev/null +++ b/linux/src/workbench/settings_dialog.rs @@ -0,0 +1,694 @@ +use gpui_kit::assets::IconName; +use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::scroll::ScrollableElement as _; +use gpui_kit::component::{h_flex, v_flex, Icon, Sizable as _}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::{ + div, px, rgba, Context, EventEmitter, FontWeight, InteractiveElement as _, IntoElement, + ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, Window, +}; + +use crate::theme::ThemeColors; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsCategory { + General, + Editor, + Git, + Terminal, +} + +#[derive(Debug, Clone)] +pub enum SettingsEvent { + Close, +} + +/// 居中设置面板(宽 640px,高 440px) +pub struct SettingsDialog { + pub active_category: SettingsCategory, + // General 设置 + pub theme_name: String, + pub ui_scale: String, + pub language: String, + // Editor 设置 + pub font_size: usize, + pub tab_size: usize, + pub show_line_numbers: bool, + pub word_wrap: bool, + // Git 设置 + pub git_auto_fetch: bool, + pub git_format_on_commit: bool, + pub git_gpg_sign: bool, + // Terminal 设置 + pub default_shell: String, + pub scrollback_lines: usize, +} + +impl EventEmitter for SettingsDialog {} + +impl SettingsDialog { + pub fn new() -> Self { + Self { + active_category: SettingsCategory::General, + theme_name: "Dark Default".to_string(), + ui_scale: "100%".to_string(), + language: "English".to_string(), + font_size: 14, + tab_size: 4, + show_line_numbers: true, + word_wrap: false, + git_auto_fetch: true, + git_format_on_commit: true, + git_gpg_sign: false, + default_shell: std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()), + scrollback_lines: 1000, + } + } + + pub fn set_category(&mut self, cat: SettingsCategory, cx: &mut Context) { + self.active_category = cat; + cx.notify(); + } +} + +impl Render for SettingsDialog { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + // 全屏半透明遮罩 + div() + .id("settings-dialog-backdrop") + .absolute() + .inset_0() + .bg(rgba(0x00000088)) + .flex() + .items_center() + .justify_center() + .on_mouse_down( + gpui_kit::MouseButton::Left, + cx.listener(|_this, _event, _window, cx| { + cx.emit(SettingsEvent::Close); + }), + ) + .child( + // 居中模态面板:宽 640px,高 440px + v_flex() + .id("settings-dialog-card") + .w(px(640.0)) + .h(px(440.0)) + .bg(ThemeColors::bg_sidebar()) + .border_1() + .border_color(ThemeColors::border()) + .rounded_lg() + .shadow_lg() + .overflow_hidden() + .on_mouse_down( + gpui_kit::MouseButton::Left, + cx.listener(|_this, _event, _window, _cx| { + // 阻止向遮罩冒泡 + }), + ) + .child( + // 1. 头部标题栏:带 IconName::Settings 标题与 IconName::Close 关闭按钮 + h_flex() + .h(px(42.0)) + .w_full() + .items_center() + .justify_between() + .px_4() + .border_b_1() + .border_color(ThemeColors::border()) + .bg(ThemeColors::bg_titlebar()) + .child( + h_flex() + .items_center() + .gap_2() + .child( + Icon::new(IconName::Settings) + .size(px(16.0)) + .text_color(ThemeColors::accent_blue()), + ) + .child( + div() + .text_sm() + .font_weight(FontWeight::BOLD) + .text_color(ThemeColors::text_primary()) + .child("Settings"), + ), + ) + .child( + Button::new("close-settings-btn") + .small() + .ghost() + .icon(IconName::Close) + .tooltip("Close") + .on_click(cx.listener(|_this, _event, _window, cx| { + cx.emit(SettingsEvent::Close); + })), + ), + ) + .child( + // 2. 主体区(左侧分类导航 + 右侧设置内容) + h_flex() + .flex_1() + .w_full() + .child( + // 左侧分类导航 + v_flex() + .w(px(160.0)) + .h_full() + .bg(ThemeColors::bg_sidebar()) + .border_r_1() + .border_color(ThemeColors::border()) + .py_2() + .gap_1() + .child(self.render_nav_item( + "nav-general", + IconName::SlidersHorizontal, + "General", + self.active_category == SettingsCategory::General, + SettingsCategory::General, + cx, + )) + .child(self.render_nav_item( + "nav-editor", + IconName::FileCode, + "Editor", + self.active_category == SettingsCategory::Editor, + SettingsCategory::Editor, + cx, + )) + .child(self.render_nav_item( + "nav-git", + IconName::GitBranch, + "Git", + self.active_category == SettingsCategory::Git, + SettingsCategory::Git, + cx, + )) + .child(self.render_nav_item( + "nav-terminal", + IconName::Terminal, + "Terminal", + self.active_category == SettingsCategory::Terminal, + SettingsCategory::Terminal, + cx, + )), + ) + .child( + // 右侧设置内容详情 + div() + .flex_1() + .h_full() + .bg(ThemeColors::bg_editor()) + .p_4() + .overflow_y_scrollbar() + .child(match self.active_category { + SettingsCategory::General => { + self.render_general_settings(cx).into_any_element() + } + SettingsCategory::Editor => { + self.render_editor_settings(cx).into_any_element() + } + SettingsCategory::Git => { + self.render_git_settings(cx).into_any_element() + } + SettingsCategory::Terminal => { + self.render_terminal_settings(cx).into_any_element() + } + }), + ), + ), + ) + } +} + +impl SettingsDialog { + fn render_nav_item( + &self, + id: &'static str, + icon: IconName, + label: &'static str, + is_active: bool, + cat: SettingsCategory, + cx: &mut Context, + ) -> impl IntoElement { + h_flex() + .id(id) + .h(px(32.0)) + .w_full() + .items_center() + .gap_2p5() + .px_3() + .cursor_pointer() + .text_xs() + .when(is_active, |row| { + row.bg(ThemeColors::subtle_selection()) + .border_l_2() + .border_color(ThemeColors::accent_blue()) + .text_color(ThemeColors::text_primary()) + .font_weight(FontWeight::BOLD) + }) + .when(!is_active, |row| { + row.text_color(ThemeColors::text_muted()).hover(|h| { + h.bg(ThemeColors::bg_tab_hover()) + .text_color(ThemeColors::text_primary()) + }) + }) + .child( + Icon::new(icon) + .size(px(14.0)) + .text_color(if is_active { + ThemeColors::accent_blue() + } else { + ThemeColors::text_muted() + }), + ) + .child(label) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.set_category(cat, cx); + })) + } + + fn render_general_settings(&self, cx: &mut Context) -> impl IntoElement { + v_flex() + .size_full() + .gap_4() + .child(self.render_section_title("Appearance & General")) + // 外观主题 + .child(self.render_setting_row( + "Theme", + "Color theme for editor and workbench interface", + h_flex() + .gap_1p5() + .child(self.render_choice_pill( + "theme-dark", + "Dark Default", + self.theme_name == "Dark Default", + cx.listener(|this, _event, _window, cx| { + this.theme_name = "Dark Default".to_string(); + cx.notify(); + }), + )) + .child(self.render_choice_pill( + "theme-contrast", + "High Contrast", + self.theme_name == "High Contrast", + cx.listener(|this, _event, _window, cx| { + this.theme_name = "High Contrast".to_string(); + cx.notify(); + }), + )), + )) + // 界面缩放 + .child(self.render_setting_row( + "UI Scale", + "Display scaling factor for the entire workspace", + h_flex() + .gap_1p5() + .child(self.render_choice_pill( + "scale-100", + "100%", + self.ui_scale == "100%", + cx.listener(|this, _event, _window, cx| { + this.ui_scale = "100%".to_string(); + cx.notify(); + }), + )) + .child(self.render_choice_pill( + "scale-125", + "125%", + self.ui_scale == "125%", + cx.listener(|this, _event, _window, cx| { + this.ui_scale = "125%".to_string(); + cx.notify(); + }), + )), + )) + // 语言 + .child(self.render_setting_row( + "Language", + "Application display and localization language", + h_flex() + .gap_1p5() + .child(self.render_choice_pill( + "lang-en", + "English", + self.language == "English", + cx.listener(|this, _event, _window, cx| { + this.language = "English".to_string(); + cx.notify(); + }), + )) + .child(self.render_choice_pill( + "lang-zh", + "简体中文", + self.language == "简体中文", + cx.listener(|this, _event, _window, cx| { + this.language = "简体中文".to_string(); + cx.notify(); + }), + )), + )) + } + + fn render_editor_settings(&self, cx: &mut Context) -> impl IntoElement { + v_flex() + .size_full() + .gap_4() + .child(self.render_section_title("Editor Settings")) + // 字体大小 + .child(self.render_setting_row( + "Font Size", + "Controls editor text font size in pixels", + h_flex() + .gap_1p5() + .child(self.render_choice_pill( + "font-13", + "13px", + self.font_size == 13, + cx.listener(|this, _event, _window, cx| { + this.font_size = 13; + cx.notify(); + }), + )) + .child(self.render_choice_pill( + "font-14", + "14px", + self.font_size == 14, + cx.listener(|this, _event, _window, cx| { + this.font_size = 14; + cx.notify(); + }), + )) + .child(self.render_choice_pill( + "font-16", + "16px", + self.font_size == 16, + cx.listener(|this, _event, _window, cx| { + this.font_size = 16; + cx.notify(); + }), + )), + )) + // Tab 缩进 + .child(self.render_setting_row( + "Tab Size", + "Number of spaces per indentation level", + h_flex() + .gap_1p5() + .child(self.render_choice_pill( + "tab-2", + "2 Spaces", + self.tab_size == 2, + cx.listener(|this, _event, _window, cx| { + this.tab_size = 2; + cx.notify(); + }), + )) + .child(self.render_choice_pill( + "tab-4", + "4 Spaces", + self.tab_size == 4, + cx.listener(|this, _event, _window, cx| { + this.tab_size = 4; + cx.notify(); + }), + )), + )) + // 显示行号 + .child(self.render_setting_row( + "Line Numbers", + "Display line number gutter in code editor", + h_flex() + .gap_1p5() + .child(self.render_choice_pill( + "ln-show", + "Show", + self.show_line_numbers, + cx.listener(|this, _event, _window, cx| { + this.show_line_numbers = true; + cx.notify(); + }), + )) + .child(self.render_choice_pill( + "ln-hide", + "Hide", + !self.show_line_numbers, + cx.listener(|this, _event, _window, cx| { + this.show_line_numbers = false; + cx.notify(); + }), + )), + )) + // 自动换行 + .child(self.render_setting_row( + "Word Wrap", + "Wrap long lines to fit the editor viewport width", + h_flex() + .gap_1p5() + .child(self.render_choice_pill( + "wrap-on", + "On", + self.word_wrap, + cx.listener(|this, _event, _window, cx| { + this.word_wrap = true; + cx.notify(); + }), + )) + .child(self.render_choice_pill( + "wrap-off", + "Off", + !self.word_wrap, + cx.listener(|this, _event, _window, cx| { + this.word_wrap = false; + cx.notify(); + }), + )), + )) + } + + fn render_git_settings(&self, cx: &mut Context) -> impl IntoElement { + v_flex() + .size_full() + .gap_4() + .child(self.render_section_title("Version Control & Git")) + // 自动拉取 + .child(self.render_setting_row( + "Auto Fetch", + "Automatically fetch remote branch status periodically", + h_flex() + .gap_1p5() + .child(self.render_choice_pill( + "fetch-on", + "Enabled", + self.git_auto_fetch, + cx.listener(|this, _event, _window, cx| { + this.git_auto_fetch = true; + cx.notify(); + }), + )) + .child(self.render_choice_pill( + "fetch-off", + "Disabled", + !self.git_auto_fetch, + cx.listener(|this, _event, _window, cx| { + this.git_auto_fetch = false; + cx.notify(); + }), + )), + )) + // Commit 前格式化 + .child(self.render_setting_row( + "Format on Commit", + "Run code formatter on staged changes before committing", + h_flex() + .gap_1p5() + .child(self.render_choice_pill( + "format-on", + "Enabled", + self.git_format_on_commit, + cx.listener(|this, _event, _window, cx| { + this.git_format_on_commit = true; + cx.notify(); + }), + )) + .child(self.render_choice_pill( + "format-off", + "Disabled", + !self.git_format_on_commit, + cx.listener(|this, _event, _window, cx| { + this.git_format_on_commit = false; + cx.notify(); + }), + )), + )) + // GPG 签名 + .child(self.render_setting_row( + "GPG Commit Signing", + "Sign commits with user default GPG signing key", + h_flex() + .gap_1p5() + .child(self.render_choice_pill( + "gpg-on", + "Enabled", + self.git_gpg_sign, + cx.listener(|this, _event, _window, cx| { + this.git_gpg_sign = true; + cx.notify(); + }), + )) + .child(self.render_choice_pill( + "gpg-off", + "Disabled", + !self.git_gpg_sign, + cx.listener(|this, _event, _window, cx| { + this.git_gpg_sign = false; + cx.notify(); + }), + )), + )) + } + + fn render_terminal_settings(&self, cx: &mut Context) -> impl IntoElement { + v_flex() + .size_full() + .gap_4() + .child(self.render_section_title("Terminal & PTY")) + // 默认终端 + .child(self.render_setting_row( + "Default Shell", + "System shell binary used for bottom terminal panel", + h_flex() + .gap_1p5() + .child(self.render_choice_pill( + "shell-bash", + "/bin/bash", + self.default_shell.contains("bash"), + cx.listener(|this, _event, _window, cx| { + this.default_shell = "/bin/bash".to_string(); + cx.notify(); + }), + )) + .child(self.render_choice_pill( + "shell-zsh", + "/bin/zsh", + self.default_shell.contains("zsh"), + cx.listener(|this, _event, _window, cx| { + this.default_shell = "/bin/zsh".to_string(); + cx.notify(); + }), + )) + .child(self.render_choice_pill( + "shell-sh", + "/bin/sh", + self.default_shell == "/bin/sh", + cx.listener(|this, _event, _window, cx| { + this.default_shell = "/bin/sh".to_string(); + cx.notify(); + }), + )), + )) + // 滚动缓冲区大小 + .child(self.render_setting_row( + "Scrollback Buffer", + "Maximum number of terminal output lines kept in memory", + h_flex() + .gap_1p5() + .child(self.render_choice_pill( + "scroll-1000", + "1000 lines", + self.scrollback_lines == 1000, + cx.listener(|this, _event, _window, cx| { + this.scrollback_lines = 1000; + cx.notify(); + }), + )) + .child(self.render_choice_pill( + "scroll-5000", + "5000 lines", + self.scrollback_lines == 5000, + cx.listener(|this, _event, _window, cx| { + this.scrollback_lines = 5000; + cx.notify(); + }), + )), + )) + } + + fn render_section_title(&self, title: &'static str) -> impl IntoElement { + div() + .text_sm() + .font_weight(FontWeight::BOLD) + .text_color(ThemeColors::text_primary()) + .pb_1() + .border_b_1() + .border_color(ThemeColors::border()) + .child(title) + } + + fn render_setting_row( + &self, + label: &'static str, + desc: &'static str, + control: impl IntoElement, + ) -> impl IntoElement { + h_flex() + .w_full() + .items_center() + .justify_between() + .gap_4() + .child( + v_flex() + .flex_1() + .gap_0p5() + .child( + div() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(ThemeColors::text_primary()) + .child(label), + ) + .child( + div() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(desc), + ), + ) + .child(control) + } + + fn render_choice_pill( + &self, + id: &'static str, + label: &'static str, + is_selected: bool, + click_handler: impl Fn(&gpui_kit::ClickEvent, &mut Window, &mut gpui_kit::App) + 'static, + ) -> impl IntoElement { + h_flex() + .id(id) + .items_center() + .px_2p5() + .py(px(3.0)) + .rounded(px(4.0)) + .border_1() + .cursor_pointer() + .text_xs() + .when(is_selected, |pill| { + pill.bg(ThemeColors::accent_blue()) + .border_color(ThemeColors::accent_blue()) + .text_color(ThemeColors::text_primary()) + .font_weight(FontWeight::BOLD) + }) + .when(!is_selected, |pill| { + pill.bg(ThemeColors::bg_tab_bar()) + .border_color(ThemeColors::border()) + .text_color(ThemeColors::text_muted()) + .hover(|h| { + h.bg(ThemeColors::bg_tab_hover()) + .text_color(ThemeColors::text_primary()) + }) + }) + .child(label) + .on_click(click_handler) + } +} diff --git a/linux/src/workbench/sidebar.rs b/linux/src/workbench/sidebar.rs index b69af9ac1..dd285c192 100644 --- a/linux/src/workbench/sidebar.rs +++ b/linux/src/workbench/sidebar.rs @@ -78,6 +78,8 @@ impl FileEntry { #[derive(Debug, Clone)] pub enum SidebarEvent { OpenFile(String), + NewFile, + Commit(String), } #[derive(Debug, Clone)] @@ -113,6 +115,7 @@ pub struct SidebarView { pub git_branch: Option, pub git_changes: Vec, pub is_git_loading: bool, + pub git_commit_message: String, client: CoreClient, } @@ -133,6 +136,7 @@ impl SidebarView { git_branch: None, git_changes: Vec::new(), is_git_loading: false, + git_commit_message: String::new(), client: CoreClient::new(), }; @@ -371,7 +375,7 @@ impl Render for SidebarView { .border_r_1() .border_color(ThemeColors::border()) .child( - // 顶部工具窗口精简标题 + 刷新操作按钮(无老旧横排Tab栏) + // 顶部工具窗口精简标题 + 刷新/新建文件操作按钮(无老旧横排Tab栏) h_flex() .h(px(32.0)) .w_full() @@ -389,15 +393,32 @@ impl Render for SidebarView { .child(title), ) .child( - Button::new("sidebar-refresh") - .small() - .ghost() - .icon(IconName::RotateCw) - .tooltip("Refresh") - .on_click(cx.listener(|this, _event, _window, cx| { - this.refresh(cx); - this.refresh_git(cx); - })), + h_flex() + .items_center() + .gap_1() + .when(self.active_tab == SidebarTab::Explorer, |buttons| { + buttons.child( + Button::new("sidebar-new-file") + .small() + .ghost() + .icon(IconName::FilePlus) + .tooltip("New File") + .on_click(cx.listener(|_this, _event, _window, cx| { + cx.emit(SidebarEvent::NewFile); + })), + ) + }) + .child( + Button::new("sidebar-refresh") + .small() + .ghost() + .icon(IconName::RotateCw) + .tooltip("Refresh") + .on_click(cx.listener(|this, _event, _window, cx| { + this.refresh(cx); + this.refresh_git(cx); + })), + ), ), ) .child( @@ -631,91 +652,157 @@ impl SidebarView { v_flex() .size_full() - .p_2() - .gap_2() + .justify_between() .child( - h_flex() - .items_center() + // 1. Git 变更文件列表区 + v_flex() + .flex_1() + .w_full() + .p_2() .gap_2() - .pb_2() - .border_b_1() - .border_color(ThemeColors::border()) + .overflow_y_scrollbar() .child( - Icon::new(IconName::GitBranch) - .size(px(13.0)) - .text_color(ThemeColors::accent_green()), + h_flex() + .items_center() + .gap_2() + .pb_2() + .border_b_1() + .border_color(ThemeColors::border()) + .child( + Icon::new(IconName::GitBranch) + .size(px(13.0)) + .text_color(ThemeColors::accent_green()), + ) + .child( + div() + .text_xs() + .font_weight(FontWeight::BOLD) + .text_color(ThemeColors::accent_green()) + .child(branch), + ), ) .child( div() .text_xs() - .font_weight(FontWeight::BOLD) - .text_color(ThemeColors::accent_green()) - .child(branch), - ), - ) - .child( - div() - .text_xs() - .text_color(ThemeColors::text_muted()) - .child(format!("Changed Files ({})", self.git_changes.len())), - ) - .children(self.git_changes.iter().enumerate().map(|(idx, change)| { - let path = change.path.clone(); - let status = change.status.clone(); - let status_color = match status.as_str() { - "M" => ThemeColors::accent_yellow(), - "A" => ThemeColors::accent_green(), - "D" => ThemeColors::accent_red(), - _ => ThemeColors::text_muted(), - }; - - let icon_name = if is_code_file(&path) { - IconName::FileCode - } else { - IconName::FileText - }; + .text_color(ThemeColors::text_muted()) + .child(format!("Changed Files ({})", self.git_changes.len())), + ) + .children(self.git_changes.iter().enumerate().map(|(idx, change)| { + let path = change.path.clone(); + let status = change.status.clone(); + let status_color = match status.as_str() { + "M" => ThemeColors::accent_yellow(), + "A" => ThemeColors::accent_green(), + "D" => ThemeColors::accent_red(), + _ => ThemeColors::text_muted(), + }; + + let icon_name = if is_code_file(&path) { + IconName::FileCode + } else { + IconName::FileText + }; - h_flex() - .id(idx) - .h(px(24.0)) - .w_full() - .items_center() - .justify_between() - .px_2() - .rounded_sm() - .cursor_pointer() - .hover(|h| h.bg(ThemeColors::bg_tab_hover())) - .text_xs() - .child( h_flex() + .id(idx) + .h(px(24.0)) + .w_full() .items_center() - .gap_2() + .justify_between() + .px_2() + .rounded_sm() + .cursor_pointer() + .hover(|h| h.bg(ThemeColors::bg_tab_hover())) + .text_xs() .child( - Icon::new(icon_name) - .size(px(13.0)) - .text_color(ThemeColors::text_muted()), + h_flex() + .items_center() + .gap_2() + .child( + Icon::new(icon_name) + .size(px(13.0)) + .text_color(ThemeColors::text_muted()), + ) + .child( + div() + .text_color(ThemeColors::text_primary()) + .truncate() + .child(path.clone()), + ), ) .child( div() - .text_color(ThemeColors::text_primary()) - .truncate() - .child(path.clone()), - ), - ) + .font_weight(FontWeight::BOLD) + .text_color(status_color) + .child(status), + ) + .on_click(cx.listener({ + let p = path.clone(); + move |this, _event, _window, cx| { + this.selected_path = Some(p.clone()); + cx.emit(SidebarEvent::OpenFile(p.clone())); + cx.notify(); + } + })) + })), + ) + .child( + // 2. Git 面板底部 Commit 工作区 + v_flex() + .w_full() + .p_2() + .border_t_1() + .border_color(ThemeColors::border()) + .bg(ThemeColors::bg_tab_bar()) + .gap_2() .child( + // Commit message 输入提示卡片 div() - .font_weight(FontWeight::BOLD) - .text_color(status_color) - .child(status), + .w_full() + .bg(ThemeColors::bg_editor()) + .border_1() + .border_color(ThemeColors::border()) + .rounded(px(4.0)) + .p_2() + .text_xs() + .text_color(if self.git_commit_message.is_empty() { + ThemeColors::text_muted() + } else { + ThemeColors::text_primary() + }) + .child(if self.git_commit_message.is_empty() { + "Commit message (e.g. feat: update files)...".to_string() + } else { + self.git_commit_message.clone() + }), ) - .on_click(cx.listener({ - let p = path.clone(); - move |this, _event, _window, cx| { - this.selected_path = Some(p.clone()); - cx.emit(SidebarEvent::OpenFile(p.clone())); - cx.notify(); - } - })) - })) + .child( + h_flex() + .w_full() + .items_center() + .justify_between() + .child( + div() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(format!("{} changed", self.git_changes.len())), + ) + .child( + Button::new("git-commit-btn") + .small() + .primary() + .icon(IconName::Check) + .label("Commit") + .on_click(cx.listener(|this, _event, _window, cx| { + let msg = if this.git_commit_message.trim().is_empty() { + "feat: update workspace files".to_string() + } else { + this.git_commit_message.trim().to_string() + }; + cx.emit(SidebarEvent::Commit(msg)); + })), + ), + ), + ) } } diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index 54ad5f934..2a7b2950c 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -1,8 +1,9 @@ use gpui_kit::component::{h_flex, v_flex}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, - Styled as _, Subscription, Window, + div, px, AppContext as _, Context, Entity, FocusHandle, InteractiveElement as _, IntoElement, + KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, + Render, Styled as _, Subscription, Window, }; use crate::core::CoreClient; @@ -10,16 +11,37 @@ use crate::theme::ThemeColors; use crate::workbench::activity_rail::{ActivityRailEvent, ActivityRailView, ActivityTab}; use crate::workbench::bottom_panel::BottomPanelView; use crate::workbench::editor::EditorView; -use crate::workbench::sidebar::{SidebarEvent, SidebarTab, SidebarView}; +use crate::workbench::search_everywhere::{ + SearchEverywhereEvent, SearchEverywhereModal, +}; +use crate::workbench::settings_dialog::{SettingsDialog, SettingsEvent}; +use crate::workbench::sidebar::{FileEntry, SidebarEvent, SidebarTab, SidebarView}; use crate::workbench::status_bar::StatusBarView; use crate::workbench::toolbar::{ToolbarEvent, ToolbarView}; -/// Linux 前端主工作台视图组件(复刻 macOS LitheTheme / Tauri 架构) +/// Linux 前端主工作台视图组件(复刻 macOS LitheTheme / IntelliJ 架构) pub struct WorkbenchView { /// 项目工作区根路径 pub workspace_root: String, /// 侧边栏是否展开 pub sidebar_visible: bool, + /// 侧边栏宽度(默认 260.0,支持拖拽调节) + pub sidebar_width: f32, + /// 底部面板高度(默认 240.0,支持拖拽调节) + pub bottom_panel_height: f32, + /// 是否正在调节侧边栏宽度 + pub is_resizing_sidebar: bool, + /// 是否正在调节底部面板高度 + pub is_resizing_bottom_panel: bool, + drag_start_x: f32, + drag_start_y: f32, + initial_resize_size: f32, + + /// 全局搜索弹窗浮层是否可见 + pub show_search_everywhere: bool, + /// 设置模态对话框是否可见 + pub show_settings_dialog: bool, + /// 顶部标题栏/工具栏 pub toolbar: Entity, /// 左侧垂直活动栏 (Activity Rail) @@ -32,6 +54,12 @@ pub struct WorkbenchView { pub bottom_panel: Entity, /// 底部状态栏 (Status Bar) pub status_bar: Entity, + /// 全局搜索弹窗 + pub search_everywhere: Entity, + /// 设置模态对话框 + pub settings_dialog: Entity, + + focus_handle: FocusHandle, client: CoreClient, _subscriptions: Vec, } @@ -47,7 +75,7 @@ impl WorkbenchView { pub fn with_root( workspace_root: String, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) -> Self { let root = workspace_root.clone(); @@ -55,13 +83,17 @@ impl WorkbenchView { let toolbar = cx.new(|_cx| ToolbarView::new(&root)); let activity_rail = cx.new(|_cx| ActivityRailView::new()); let sidebar = cx.new(|cx| SidebarView::new(root.clone(), cx)); - let editor = cx.new(|cx| EditorView::new(root.clone(), cx)); + let editor = cx.new(|cx| EditorView::new(root.clone(), window, cx)); let bottom_panel = cx.new(|cx| BottomPanelView::new(root.clone(), cx)); let status_bar = cx.new(|_cx| StatusBarView::new()); + let search_everywhere = cx.new(|cx| SearchEverywhereModal::new(cx)); + let settings_dialog = cx.new(|_cx| SettingsDialog::new()); + let focus_handle = cx.focus_handle(); - // 1. 订阅侧边栏文件打开事件 + // 1. 订阅侧边栏事件(打开文件、新建文件、提交 Git) let status_bar_clone = status_bar.clone(); - let sub_sidebar = cx.subscribe(&sidebar, move |this, _sidebar, event: &SidebarEvent, cx| { + let bottom_panel_sidebar = bottom_panel.clone(); + let sub_sidebar = cx.subscribe(&sidebar, move |this, sidebar, event: &SidebarEvent, cx| { match event { SidebarEvent::OpenFile(path) => { this.open_file(path, cx); @@ -75,12 +107,39 @@ impl WorkbenchView { ); }); } + SidebarEvent::NewFile => { + let _ = this.editor.update(cx, |ed, cx| { + ed.open_file("untitled.txt".to_string(), String::new(), cx); + }); + let _ = status_bar_clone.update(cx, |sb, cx| { + sb.set_file_info( + Some("untitled.txt".to_string()), + 1, + 1, + "Plain Text".to_string(), + cx, + ); + }); + } + SidebarEvent::Commit(msg) => { + let log_msg = format!("[Git Commit] {}", msg); + let _ = bottom_panel_sidebar.update(cx, |bp, cx| { + bp.append_log(log_msg.clone(), cx); + let _ = bp.terminal.update(cx, |term, cx| { + term.send_command(&format!("git commit -m \"{}\"", msg), cx); + }); + }); + let _ = sidebar.update(cx, |sb, cx| { + sb.refresh_git(cx); + }); + } } }); - // 2. 观察侧边栏 Git 分支状态变化并同步至 Toolbar 与 StatusBar + // 2. 观察侧边栏状态变化(分支同步与工作区文件快照同步给全局搜索) let toolbar_branch_sync = toolbar.clone(); let status_bar_branch_sync = status_bar.clone(); + let search_sync = search_everywhere.clone(); let obs_sidebar = cx.observe(&sidebar, move |_this, sidebar, cx| { let branch = sidebar.read(cx).git_branch.clone(); let _ = toolbar_branch_sync.update(cx, |tb, cx| { @@ -89,6 +148,17 @@ impl WorkbenchView { let _ = status_bar_branch_sync.update(cx, |sb, cx| { sb.set_git_branch(branch, cx); }); + + // 收集所有文件供全局搜索 + let mut file_list = Vec::new(); + if let Some(root_node) = &sidebar.read(cx).root_node { + Self::collect_all_file_paths(root_node, &mut file_list); + } + if !file_list.is_empty() { + let _ = search_sync.update(cx, |search, cx| { + search.set_files(file_list, cx); + }); + } }); // 3. 订阅左侧活动栏 (Activity Rail) 事件 @@ -122,9 +192,8 @@ impl WorkbenchView { }); } ActivityRailEvent::OpenSettings => { - let _ = bottom_panel_clone.update(cx, |bp, cx| { - bp.append_log("[Settings] Settings dialog requested".to_string(), cx); - }); + this.show_settings_dialog = true; + cx.notify(); } } }); @@ -178,20 +247,13 @@ impl WorkbenchView { }); } ToolbarEvent::QuickOpen => { - let _ = this.sidebar.update(cx, |sb, cx| { - sb.set_tab(SidebarTab::Search, cx); - }); - this.sidebar_visible = true; - let _ = this.activity_rail.update(cx, |r, cx| { - r.set_active_tab(Some(ActivityTab::Search), cx) - }); - cx.notify(); + this.open_search_everywhere(cx); } ToolbarEvent::Run => { let _ = this.bottom_panel.update(cx, |bp, cx| { bp.append_log("[Run] Executing default run configuration...".to_string(), cx); let _ = bp.terminal.update(cx, |term, cx| { - term.send_command("echo '[Lithe Run]' && ls -la", cx); + term.send_command("echo '[Lithe Run]' && cargo check", cx); }); }); } @@ -206,9 +268,8 @@ impl WorkbenchView { }); } ToolbarEvent::OpenSettings => { - let _ = this.bottom_panel.update(cx, |bp, cx| { - bp.append_log("[Settings] Settings dialog requested".to_string(), cx); - }); + this.show_settings_dialog = true; + cx.notify(); } ToolbarEvent::About => { let _ = this.bottom_panel.update(cx, |bp, cx| { @@ -221,17 +282,166 @@ impl WorkbenchView { } }); + // 5. 订阅全局搜索事件 (SearchEverywhereEvent) + let status_bar_search = status_bar.clone(); + let sub_search = cx.subscribe(&search_everywhere, move |this, _search, event: &SearchEverywhereEvent, cx| { + match event { + SearchEverywhereEvent::OpenFile(path) => { + this.open_file(path, cx); + let _ = status_bar_search.update(cx, |sb, cx| { + sb.set_file_info( + Some(path.clone()), + 1, + 1, + EditorView::language_name(path).to_string(), + cx, + ); + }); + this.show_search_everywhere = false; + cx.notify(); + } + SearchEverywhereEvent::ExecuteAction(action_id) => { + this.handle_action(action_id, cx); + this.show_search_everywhere = false; + cx.notify(); + } + SearchEverywhereEvent::Close => { + this.show_search_everywhere = false; + cx.notify(); + } + } + }); + + // 6. 订阅设置对话框事件 (SettingsEvent) + let sub_settings = cx.subscribe(&settings_dialog, |this, _dialog, event: &SettingsEvent, cx| { + match event { + SettingsEvent::Close => { + this.show_settings_dialog = false; + cx.notify(); + } + } + }); + Self { workspace_root, sidebar_visible: true, + sidebar_width: 260.0, + bottom_panel_height: 240.0, + is_resizing_sidebar: false, + is_resizing_bottom_panel: false, + drag_start_x: 0.0, + drag_start_y: 0.0, + initial_resize_size: 0.0, + show_search_everywhere: false, + show_settings_dialog: false, toolbar, activity_rail, sidebar, editor, bottom_panel, status_bar, + search_everywhere, + settings_dialog, + focus_handle, client: CoreClient::new(), - _subscriptions: vec![sub_sidebar, obs_sidebar, sub_rail, sub_toolbar], + _subscriptions: vec![ + sub_sidebar, + obs_sidebar, + sub_rail, + sub_toolbar, + sub_search, + sub_settings, + ], + } + } + + /// 打开全局搜索弹窗并更新当前文件索引 + pub fn open_search_everywhere(&mut self, cx: &mut Context) { + this_sync_files(self, cx); + self.show_search_everywhere = true; + let _ = self.search_everywhere.update(cx, |search, cx| { + search.reset(cx); + }); + cx.notify(); + } + + fn collect_all_file_paths(entry: &FileEntry, output: &mut Vec) { + if !entry.is_directory { + output.push(entry.path.clone()); + } + if let Some(children) = &entry.children { + for child in children { + Self::collect_all_file_paths(child, output); + } + } + } + + /// 执行常用命令动作 + pub fn handle_action(&mut self, action_id: &str, cx: &mut Context) { + match action_id { + "workbench.new_file" => { + let _ = self.editor.update(cx, |ed, cx| { + ed.open_file("untitled.txt".to_string(), String::new(), cx); + }); + } + "workbench.save" => { + let _ = self.editor.update(cx, |ed, cx| { + ed.save_active(cx); + }); + } + "workbench.close_tab" => { + let _ = self.editor.update(cx, |ed, cx| { + if let Some(idx) = ed.active_tab_index { + ed.close_tab(idx, cx); + } + }); + } + "workbench.toggle_terminal" => { + let _ = self.bottom_panel.update(cx, |bp, cx| { + bp.toggle_collapsed(cx); + }); + } + "workbench.toggle_sidebar" => { + self.sidebar_visible = !self.sidebar_visible; + let tab = if self.sidebar_visible { + Some(ActivityTab::Explorer) + } else { + None + }; + let _ = self.activity_rail.update(cx, |r, cx| r.set_active_tab(tab, cx)); + cx.notify(); + } + "workbench.open_settings" => { + self.show_settings_dialog = true; + cx.notify(); + } + "workbench.refresh_workspace" => { + let _ = self.sidebar.update(cx, |sb, cx| { + sb.refresh(cx); + sb.refresh_git(cx); + }); + } + "workbench.run" => { + let _ = self.bottom_panel.update(cx, |bp, cx| { + bp.append_log("[Run] Executing default run configuration...".to_string(), cx); + let _ = bp.terminal.update(cx, |term, cx| { + term.send_command("cargo check", cx); + }); + }); + } + "workbench.debug" => { + let _ = self.bottom_panel.update(cx, |bp, cx| { + bp.append_log("[Debug] Launching DAP debug session...".to_string(), cx); + }); + } + "workbench.clear_terminal" => { + let _ = self.bottom_panel.update(cx, |bp, cx| { + let _ = bp.terminal.update(cx, |term, cx| { + term.clear(cx); + }); + }); + } + _ => {} } } @@ -263,50 +473,193 @@ impl WorkbenchView { } } +fn this_sync_files(this: &WorkbenchView, cx: &mut Context) { + let mut file_list = Vec::new(); + if let Some(root_node) = &this.sidebar.read(cx).root_node { + WorkbenchView::collect_all_file_paths(root_node, &mut file_list); + } + if !file_list.is_empty() { + let _ = this.search_everywhere.update(cx, |search, cx| { + search.set_files(file_list, cx); + }); + } +} + impl Render for WorkbenchView { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - v_flex() + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .track_focus(&self.focus_handle) + .relative() .size_full() .bg(ThemeColors::bg_editor()) - .child( - // 1. 顶部标题栏/工具栏 (Toolbar) - self.toolbar.clone(), + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { + let key = event.keystroke.key.as_str(); + // 键盘快捷键监听 + if event.keystroke.modifiers.control || event.keystroke.modifiers.platform { + match key { + "p" => { + this.open_search_everywhere(cx); + } + "," => { + this.show_settings_dialog = true; + cx.notify(); + } + "b" => { + this.sidebar_visible = !this.sidebar_visible; + let tab = if this.sidebar_visible { + Some(ActivityTab::Explorer) + } else { + None + }; + let _ = this.activity_rail.update(cx, |r, cx| r.set_active_tab(tab, cx)); + cx.notify(); + } + "`" => { + let _ = this.bottom_panel.update(cx, |bp, cx| { + bp.toggle_collapsed(cx); + }); + } + _ => {} + } + } else if key == "escape" { + if this.show_search_everywhere { + this.show_search_everywhere = false; + cx.notify(); + } else if this.show_settings_dialog { + this.show_settings_dialog = false; + cx.notify(); + } + } + })) + .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| { + if this.is_resizing_sidebar { + let current_x = f32::from(event.position.x); + let delta = current_x - this.drag_start_x; + let new_width = (this.initial_resize_size + delta).clamp(160.0, 600.0); + if (this.sidebar_width - new_width).abs() > 0.5 { + this.sidebar_width = new_width; + cx.notify(); + } + } else if this.is_resizing_bottom_panel { + let current_y = f32::from(event.position.y); + let delta = this.drag_start_y - current_y; + let new_height = (this.initial_resize_size + delta).clamp(120.0, 500.0); + if (this.bottom_panel_height - new_height).abs() > 0.5 { + this.bottom_panel_height = new_height; + let _ = this.bottom_panel.update(cx, |bp, cx| { + bp.set_height(new_height, cx); + }); + cx.notify(); + } + } + })) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _event: &MouseUpEvent, _window, cx| { + if this.is_resizing_sidebar || this.is_resizing_bottom_panel { + this.is_resizing_sidebar = false; + this.is_resizing_bottom_panel = false; + cx.notify(); + } + }), ) .child( - // 2. 主工作区区域(ActivityRail + 侧边栏 + 编辑器主岛) - h_flex() - .flex_1() - .w_full() + v_flex() + .size_full() + .bg(ThemeColors::bg_editor()) .child( - // 左侧垂直图标活动栏 (Activity Rail) - self.activity_rail.clone(), + // 1. 顶部标题栏/工具栏 (Toolbar) + self.toolbar.clone(), ) - .when(self.sidebar_visible, |layout| { - layout.child( - // 侧边栏内容面板 (Explorer / Search / Git) - div() - .w(px(260.0)) - .h_full() - .flex_shrink_0() - .child(self.sidebar.clone()), - ) - }) .child( - // 中央编辑器主岛 - div() + // 2. 主工作区区域(ActivityRail + 侧边栏 + 可调节垂直Splitter + 编辑器主岛) + h_flex() .flex_1() - .h_full() - .bg(ThemeColors::bg_editor()) - .child(self.editor.clone()), + .w_full() + .child( + // 左侧垂直图标活动栏 (Activity Rail) + self.activity_rail.clone(), + ) + .when(self.sidebar_visible, |layout| { + layout + .child( + // 侧边栏内容面板 (Explorer / Search / Git) + div() + .w(px(self.sidebar_width)) + .h_full() + .flex_shrink_0() + .child(self.sidebar.clone()), + ) + .child( + // 侧边栏与主编辑器之间的精致 Splitter 分隔条 + div() + .id("sidebar-splitter") + .w(px(4.0)) + .h_full() + .cursor_col_resize() + .bg(if self.is_resizing_sidebar { + ThemeColors::accent_blue() + } else { + ThemeColors::border() + }) + .hover(|h| h.bg(ThemeColors::accent_blue())) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, event: &MouseDownEvent, _window, cx| { + this.is_resizing_sidebar = true; + this.drag_start_x = f32::from(event.position.x); + this.initial_resize_size = this.sidebar_width; + cx.notify(); + }), + ), + ) + }) + .child( + // 中央编辑器主岛 + div() + .flex_1() + .h_full() + .bg(ThemeColors::bg_editor()) + .child(self.editor.clone()), + ), + ) + .child( + // 主编辑器与底栏之间的精致 Splitter 分隔条 + div() + .id("bottom-panel-splitter") + .h(px(4.0)) + .w_full() + .cursor_row_resize() + .bg(if self.is_resizing_bottom_panel { + ThemeColors::accent_blue() + } else { + ThemeColors::border() + }) + .hover(|h| h.bg(ThemeColors::accent_blue())) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, event: &MouseDownEvent, _window, cx| { + this.is_resizing_bottom_panel = true; + this.drag_start_y = f32::from(event.position.y); + this.initial_resize_size = this.bottom_panel_height; + cx.notify(); + }), + ), + ) + .child( + // 3. 底部抽屉面板 (Terminal / Output / Problems) + self.bottom_panel.clone(), + ) + .child( + // 4. 最底部状态栏 (Status Bar) + self.status_bar.clone(), ), ) - .child( - // 3. 底部抽屉面板 (Terminal / Output / Problems) - self.bottom_panel.clone(), - ) - .child( - // 4. 最底部状态栏 (Status Bar) - self.status_bar.clone(), - ) + .when(self.show_search_everywhere, |view| { + view.child(self.search_everywhere.clone()) + }) + .when(self.show_settings_dialog, |view| { + view.child(self.settings_dialog.clone()) + }) } } From 93d8a1162d7d0a9a32ecaaec5f6bb4c9d5b0cfac Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Thu, 24 Sep 2026 11:55:33 +0800 Subject: [PATCH 005/109] =?UTF-8?q?feat(linux):=20=E5=A4=8D=E5=88=BB=20Tau?= =?UTF-8?q?ri=20=E4=B8=BB=E5=B7=A5=E4=BD=9C=E5=8F=B0=20UI=20=E6=A1=86?= =?UTF-8?q?=E6=9E=B6=E5=B9=B6=E6=8E=A5=E5=85=A5=20XDG=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对齐 windows/tauri 的 UI 结构与交互,功能暂不接后端: - theme: 新增运行时调色板,移植 lithe-dark / lithe-light 全套 token, 保留旧 ThemeColors 访问器为别名,视图无需改动即可跟随主题。 - settings: 新增 XDG 设置持久化(~/.config/lithe/settings.json), 键名与 Tauri defaultSettings 对齐,含 coreFeatures / 顺序 / 布局尺寸; 提供 resolved_theme_id + apply_theme 支持明暗与跟随系统。 - toolbar: 顶栏复刻为紧凑菜单栏(File/Edit/View/Go/Terminal/Run/Tools/ Window/Help)+ 项目/分支胶囊 + 搜索条 + 运行控件 + 主题切换 + 窗口控件。 - activity_rail: 左侧活动栏 9 项(按 core_features 与设置顺序过滤/排序), 新增右侧插件活动栏(Extensions / Notifications 带角标 / Maven)。 - status_bar: 按 footerLeading/TrailingItemsOrder 渲染文件路径、分支、 光标、编码、缩进、只读、内存、Git 变更,变更 chip 可点击打开 Git。 - settings_dialog: 扩展为 12 分类(820x620),设置项读写 XDG 存储, 主题项即时应用并落盘,支持 Restore Defaults。 - command_palette / quick_open / welcome_screen: 新增三个浮层与欢迎页, 含分组列表、模式徽标、键盘导航与事件出口。 - main: 改用 AllAssets 以提供完整 Lucide 图标目录。 --- linux/src/lib.rs | 1 + linux/src/main.rs | 27 +- linux/src/settings.rs | 270 ++++ linux/src/theme.rs | 335 ++++- linux/src/workbench/activity_rail.rs | 393 ++++- linux/src/workbench/command_palette.rs | 912 +++++++++++ linux/src/workbench/mod.rs | 17 +- linux/src/workbench/quick_open.rs | 506 +++++++ linux/src/workbench/settings_dialog.rs | 1912 +++++++++++++++++------- linux/src/workbench/status_bar.rs | 299 ++-- linux/src/workbench/toolbar.rs | 533 +++++-- linux/src/workbench/view.rs | 1013 +++++++++---- linux/src/workbench/welcome_screen.rs | 544 +++++++ 13 files changed, 5591 insertions(+), 1171 deletions(-) create mode 100644 linux/src/settings.rs create mode 100644 linux/src/workbench/command_palette.rs create mode 100644 linux/src/workbench/quick_open.rs create mode 100644 linux/src/workbench/welcome_screen.rs diff --git a/linux/src/lib.rs b/linux/src/lib.rs index f8a5e12e9..a8ee881d4 100644 --- a/linux/src/lib.rs +++ b/linux/src/lib.rs @@ -1,4 +1,5 @@ pub mod core; +pub mod settings; pub mod theme; pub mod workbench; diff --git a/linux/src/main.rs b/linux/src/main.rs index e614fba38..c5d5a3990 100644 --- a/linux/src/main.rs +++ b/linux/src/main.rs @@ -1,4 +1,7 @@ +//! Linux 工作台入口:初始化设置与主题,打开主窗口。 + mod core; +mod settings; mod theme; mod workbench; @@ -7,11 +10,31 @@ use gpui_kit::*; use workbench::WorkbenchView; fn main() { - let app = gpui_kit::application().with_assets(gpui_kit::assets::Assets); + // 工作台使用大量 Lucide 图标,默认 `Assets` 仅内嵌 101 个,需用全量目录。 + let app = gpui_kit::application().with_assets(gpui_kit::assets::AllAssets); app.run(move |cx| { gpui_kit::init(cx); - Theme::change(ThemeMode::Dark, None, cx); + + // 先加载持久化设置,再据此决定 gpui-component 主题与工作台调色板。 + settings::init(cx); + let is_dark = { + let settings = settings::get(cx); + let theme_id = settings::resolved_theme_id(settings, false); + !crate::theme::ThemePalette::is_light(&theme_id) + }; + let theme_id = settings::resolved_theme_id(settings::get(cx), false); + settings::apply_theme(&theme_id); + + Theme::change( + if is_dark { + ThemeMode::Dark + } else { + ThemeMode::Light + }, + None, + cx, + ); cx.open_window(WindowOptions::default(), |window, cx| { let view = cx.new(|cx| WorkbenchView::new(window, cx)); diff --git a/linux/src/settings.rs b/linux/src/settings.rs new file mode 100644 index 000000000..e3dfdd291 --- /dev/null +++ b/linux/src/settings.rs @@ -0,0 +1,270 @@ +//! 应用设置的 XDG 持久化与运行时全局状态。 +//! +//! 对齐 Tauri 端 `settings.json` 的键名与默认值,落到 +//! `$XDG_CONFIG_HOME/lithe/settings.json`(缺省 `~/.config/lithe/settings.json`)。 +//! 视图通过 [`get`] / [`update`] 读取和修改,修改后写盘并刷新窗口。 + +use std::path::PathBuf; + +use gpui_kit::{App, Global}; +use serde::{Deserialize, Serialize}; + +use crate::theme::ThemePalette; + +/// 活动栏默认项顺序,对应 Tauri `SIDEBAR_ACTIVITY_ITEM_IDS`。 +pub const SIDEBAR_ACTIVITY_ITEM_IDS: &[&str] = &[ + "files", + "git", + "search", + "maven", + "run", + "terminal", + "diagnostics", + "gitLog", + "settings", +]; + +/// 活动栏底部固定组,对应 Tauri `SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS`。 +pub const SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS: &[&str] = &[ + "maven", + "run", + "terminal", + "diagnostics", + "gitLog", + "settings", +]; + +/// 状态栏左侧项顺序,对应 Tauri `FOOTER_LEADING_ITEM_IDS`。 +pub const FOOTER_LEADING_ITEM_IDS: &[&str] = &["filePath", "branch"]; + +/// 状态栏右侧项顺序,对应 Tauri `FOOTER_TRAILING_ITEM_IDS`。 +pub const FOOTER_TRAILING_ITEM_IDS: &[&str] = &[ + "cursor", + "encoding", + "indent", + "readOnly", + "memory", + "gitChanges", +]; + +/// 功能开关,对应 Tauri `coreFeatures`。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CoreFeatures { + pub git: bool, + pub github: bool, + pub remote: bool, + pub terminal: bool, + pub search: bool, + pub diagnostics: bool, + pub debugger: bool, + pub docker: bool, + pub outline: bool, + pub ai_chat: bool, + pub breadcrumbs: bool, + pub persistent_commands: bool, + pub web_viewer: bool, +} + +impl Default for CoreFeatures { + fn default() -> Self { + Self { + git: true, + github: false, + remote: false, + terminal: true, + search: true, + diagnostics: true, + debugger: false, + docker: false, + outline: true, + ai_chat: false, + breadcrumbs: true, + persistent_commands: true, + web_viewer: false, + } + } +} + +/// 应用设置真源,字段名与 Tauri `defaultSettings` 的 camelCase 键一一对应。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Settings { + // General + pub auto_save: bool, + pub quick_open_preview: bool, + // Editor + pub font_size: f32, + pub tab_size: usize, + pub word_wrap: bool, + pub line_numbers: bool, + pub show_minimap: bool, + // Terminal + pub terminal_scrollback: usize, + // UI + pub display_language: String, + pub reduce_motion: bool, + pub show_status_bar: bool, + pub show_tab_icons: bool, + pub tab_close_button_visibility: String, + pub window_chrome_density: String, + // Theme + pub theme: String, + pub icon_theme: String, + pub sync_system_theme: bool, + pub auto_theme_light: String, + pub auto_theme_dark: String, + pub compact_menu_bar: bool, + // Layout + pub sidebar_activity_items_order: Vec, + pub hidden_sidebar_activity_items: Vec, + pub footer_leading_items_order: Vec, + pub footer_trailing_items_order: Vec, + pub activity_rail_expanded: bool, + pub activity_rail_width: f32, + pub sidebar_width: f32, + pub right_tool_window_width: f32, + // Tabs + pub max_open_tabs: usize, + pub horizontal_tab_scroll: bool, + // Advanced + pub last_settings_tab: String, + pub core_features: CoreFeatures, +} + +impl Default for Settings { + fn default() -> Self { + Self { + auto_save: true, + quick_open_preview: true, + font_size: 13.0, + tab_size: 2, + word_wrap: false, + line_numbers: true, + show_minimap: true, + terminal_scrollback: 10000, + display_language: "zh-CN".to_string(), + reduce_motion: false, + show_status_bar: true, + show_tab_icons: true, + tab_close_button_visibility: "active".to_string(), + window_chrome_density: "focused".to_string(), + theme: "lithe-dark".to_string(), + icon_theme: "idea-icons".to_string(), + sync_system_theme: false, + auto_theme_light: "lithe-light".to_string(), + auto_theme_dark: "lithe-dark".to_string(), + compact_menu_bar: true, + sidebar_activity_items_order: SIDEBAR_ACTIVITY_ITEM_IDS + .iter() + .map(|s| s.to_string()) + .collect(), + hidden_sidebar_activity_items: Vec::new(), + footer_leading_items_order: FOOTER_LEADING_ITEM_IDS + .iter() + .map(|s| s.to_string()) + .collect(), + footer_trailing_items_order: FOOTER_TRAILING_ITEM_IDS + .iter() + .map(|s| s.to_string()) + .collect(), + activity_rail_expanded: false, + activity_rail_width: 180.0, + sidebar_width: 320.0, + right_tool_window_width: 400.0, + max_open_tabs: 100, + horizontal_tab_scroll: true, + last_settings_tab: "general".to_string(), + core_features: CoreFeatures::default(), + } + } +} + +/// 全局设置持有者,注册到 GPUI 的 `Global` 存储。 +pub struct AppSettings { + pub settings: Settings, +} + +impl Global for AppSettings {} + +/// 当前设置(只读)。 +pub fn get(cx: &App) -> &Settings { + &cx.global::().settings +} + +/// 当前设置(可变),修改后需调用 [`persist`]。 +pub fn get_mut(cx: &mut App) -> &mut Settings { + &mut cx.global_mut::().settings +} + +/// 解析配置文件路径;优先 `XDG_CONFIG_HOME`,否则 `~/.config`。 +pub fn config_path() -> Option { + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?; + Some(base.join("lithe").join("settings.json")) +} + +/// 从磁盘加载设置;缺失或损坏时回退到默认值并补齐顺序字段。 +pub fn load() -> Settings { + let Some(path) = config_path() else { + return Settings::default(); + }; + let Ok(text) = std::fs::read_to_string(&path) else { + return Settings::default(); + }; + serde_json::from_str::(&text).unwrap_or_default() +} + +/// 将设置写回磁盘(临时文件 + 原子替换)。 +pub fn persist(settings: &Settings) { + let Some(path) = config_path() else { + return; + }; + if let Some(parent) = path.parent() { + if std::fs::create_dir_all(parent).is_err() { + return; + } + } + let Ok(text) = serde_json::to_string_pretty(settings) else { + return; + }; + let tmp = path.with_extension("json.tmp"); + if std::fs::write(&tmp, text).is_ok() { + let _ = std::fs::rename(&tmp, &path); + } +} + +/// 初始化全局设置(应用启动时调用一次)。 +pub fn init(cx: &mut App) { + let settings = load(); + cx.set_global(AppSettings { settings }); +} + +/// 修改设置:写全局、落盘并刷新所有窗口。 +pub fn update(cx: &mut App, f: impl FnOnce(&mut Settings)) { + f(get_mut(cx)); + let snapshot = get(cx).clone(); + persist(&snapshot); + cx.refresh_windows(); +} + +/// 解析当前应生效的主题 id:跟随系统时按外观取 `autoTheme*`,否则取 `theme`。 +pub fn resolved_theme_id(settings: &Settings, system_is_dark: bool) -> String { + if settings.sync_system_theme { + if system_is_dark { + settings.auto_theme_dark.clone() + } else { + settings.auto_theme_light.clone() + } + } else { + settings.theme.clone() + } +} + +/// 把主题 id 应用到工作台调色板;未知 id 回退深色。 +pub fn apply_theme(theme_id: &str) { + let palette = ThemePalette::for_theme_id(theme_id).unwrap_or_else(ThemePalette::dark); + crate::theme::set_palette(palette); +} diff --git a/linux/src/theme.rs b/linux/src/theme.rs index 73856b750..67fe1974e 100644 --- a/linux/src/theme.rs +++ b/linux/src/theme.rs @@ -1,104 +1,363 @@ +//! 工作台配色真源:对齐 Tauri 端 `lithe-dark` / `lithe-light` 主题 token。 +//! +//! 调色板在运行时通过 [`set_palette`] 切换,[`ThemeColors`] 的访问器读取当前 +//! 调色板,因此所有视图无需持有 `Context` 即可跟随主题变化。 + +use std::sync::RwLock; + use gpui_kit::Rgba; + #[allow(unused_imports)] pub use gpui_kit::assets::IconName; #[allow(unused_imports)] pub use gpui_kit::component::Icon; +/// 由 `0xRRGGBB` 构造不透明颜色。 pub const fn const_rgb(hex: u32) -> Rgba { + const_rgba(hex, 1.0) +} + +/// 由 `0xRRGGBB` 与 alpha 构造颜色。 +pub const fn const_rgba(hex: u32, a: f32) -> Rgba { let r = ((hex >> 16) & 0xFF) as f32 / 255.0; let g = ((hex >> 8) & 0xFF) as f32 / 255.0; let b = (hex & 0xFF) as f32 / 255.0; - Rgba { r, g, b, a: 1.0 } + Rgba { r, g, b, a } } +/// 在两个颜色之间按 `t`(0..=1)线性插值,保留 `a` 的 alpha。 +pub fn mix(from: Rgba, to: Rgba, t: f32) -> Rgba { + let t = t.clamp(0.0, 1.0); + Rgba { + r: from.r + (to.r - from.r) * t, + g: from.g + (to.g - from.g) * t, + b: from.b + (to.b - from.b) * t, + a: from.a, + } +} + +/// 一套完整的工作台配色,字段名与 Tauri 主题 token 对应。 +/// +/// 部分 token(git/syntax/terminal 等)当前视图尚未消费,但作为主题真源保留, +/// 供后续编辑器高亮与终端渲染直接取用。 +#[derive(Debug, Clone, Copy)] +#[allow(dead_code)] +pub struct ThemePalette { + pub background: Rgba, + pub surface: Rgba, + pub foreground: Rgba, + pub muted_foreground: Rgba, + pub subtle_foreground: Rgba, + pub border: Rgba, + pub accent: Rgba, + pub selected: Rgba, + pub selection: Rgba, + pub primary: Rgba, + pub destructive: Rgba, + pub success: Rgba, + pub warning: Rgba, + pub info: Rgba, + pub git_modified: Rgba, + pub git_added: Rgba, + pub git_deleted: Rgba, + pub git_untracked: Rgba, + pub git_renamed: Rgba, + pub terminal_black: Rgba, + pub terminal_red: Rgba, + pub terminal_green: Rgba, + pub terminal_yellow: Rgba, + pub terminal_blue: Rgba, + pub terminal_magenta: Rgba, + pub terminal_cyan: Rgba, + pub terminal_white: Rgba, + pub syntax_comment: Rgba, + pub syntax_keyword: Rgba, + pub syntax_string: Rgba, + pub syntax_number: Rgba, + pub syntax_function: Rgba, + pub syntax_variable: Rgba, + pub syntax_type: Rgba, + pub syntax_constant: Rgba, + pub syntax_property: Rgba, +} + +impl ThemePalette { + /// `lithe-dark`:分层中性表面与 Lithe 蓝强调色。 + pub const fn dark() -> Self { + Self { + background: const_rgb(0x1e1f22), + surface: const_rgb(0x2b2d30), + foreground: const_rgb(0xdfe1e5), + muted_foreground: const_rgb(0xb4b8bf), + subtle_foreground: const_rgb(0x8b929e), + border: const_rgb(0x43454a), + accent: const_rgb(0x393b40), + selected: const_rgb(0x2e436e), + selection: const_rgba(0x214283, 1.0), + primary: const_rgb(0x3574f0), + destructive: const_rgb(0xdb5c5c), + success: const_rgb(0x57965c), + warning: const_rgb(0xd6ae58), + info: const_rgb(0x548af7), + git_modified: const_rgb(0xd9a441), + git_added: const_rgb(0x4cc38a), + git_deleted: const_rgb(0xf16d75), + git_untracked: const_rgb(0x58a6e7), + git_renamed: const_rgb(0xc8a2f4), + terminal_black: const_rgb(0x0f1012), + terminal_red: const_rgb(0xf16d75), + terminal_green: const_rgb(0x4cc38a), + terminal_yellow: const_rgb(0xd9a441), + terminal_blue: const_rgb(0x58a6e7), + terminal_magenta: const_rgb(0xc8a2f4), + terminal_cyan: const_rgb(0x61c0bf), + terminal_white: const_rgb(0xc4c9d1), + syntax_comment: const_rgb(0x7a7e85), + syntax_keyword: const_rgb(0xcf8e6d), + syntax_string: const_rgb(0x6aab73), + syntax_number: const_rgb(0x2aacb8), + syntax_function: const_rgb(0x56a8f5), + syntax_variable: const_rgb(0xbcbec4), + syntax_type: const_rgb(0xbcbec4), + syntax_constant: const_rgb(0xc77dbb), + syntax_property: const_rgb(0xc77dbb), + } + } + + /// `lithe-light`:清晰中性表面与高对比文本。 + pub const fn light() -> Self { + Self { + background: const_rgb(0xffffff), + surface: const_rgb(0xf7f8fa), + foreground: const_rgb(0x1f2328), + muted_foreground: const_rgb(0x4f5965), + subtle_foreground: const_rgb(0x68717d), + border: const_rgb(0xdfe1e5), + accent: const_rgb(0xedf3ff), + selected: const_rgb(0xd4e2ff), + selection: const_rgba(0x3574f0, 0.2), + primary: const_rgb(0x3574f0), + destructive: const_rgb(0xcf3f4f), + success: const_rgb(0x27864f), + warning: const_rgb(0xa86400), + info: const_rgb(0x3574f0), + git_modified: const_rgb(0xa86400), + git_added: const_rgb(0x27864f), + git_deleted: const_rgb(0xcf3f4f), + git_untracked: const_rgb(0x0877c1), + git_renamed: const_rgb(0x7656a8), + terminal_black: const_rgb(0x1f2328), + terminal_red: const_rgb(0xcf3f4f), + terminal_green: const_rgb(0x27864f), + terminal_yellow: const_rgb(0xa86400), + terminal_blue: const_rgb(0x0877c1), + terminal_magenta: const_rgb(0x8a4fb0), + terminal_cyan: const_rgb(0x147d83), + terminal_white: const_rgb(0x68717d), + syntax_comment: const_rgb(0x68717d), + syntax_keyword: const_rgb(0xb83280), + syntax_string: const_rgb(0x287d3c), + syntax_number: const_rgb(0xa15c00), + syntax_function: const_rgb(0x14777d), + syntax_variable: const_rgb(0x7656a8), + syntax_type: const_rgb(0x4169a8), + syntax_constant: const_rgb(0xa15c00), + syntax_property: const_rgb(0x075e9e), + } + } + + /// 按主题 id 解析内置调色板;未知 id 返回 `None`,由调用方回退。 + pub fn for_theme_id(theme_id: &str) -> Option { + match theme_id { + "lithe-light" => Some(Self::light()), + "lithe-dark" => Some(Self::dark()), + _ => None, + } + } + + /// 该主题是否为浅色外观。 + pub fn is_light(theme_id: &str) -> bool { + theme_id == "lithe-light" + } + + /// `border-strong`:边框与前景按 72/28 混合。 + pub fn border_strong(&self) -> Rgba { + mix(self.border, self.foreground, 0.28) + } + + /// `tab-hover-bg`:强调色 72% 叠在背景之上。 + pub fn tab_hover(&self) -> Rgba { + mix(self.background, self.accent, 0.72) + } +} + +static CURRENT_PALETTE: RwLock = RwLock::new(ThemePalette::dark()); + +/// 切换当前工作台调色板;调用方随后需刷新窗口。 +pub fn set_palette(palette: ThemePalette) { + if let Ok(mut current) = CURRENT_PALETTE.write() { + *current = palette; + } +} + +/// 当前生效的工作台调色板。 +pub fn palette() -> ThemePalette { + CURRENT_PALETTE + .read() + .map(|p| *p) + .unwrap_or_else(|_| ThemePalette::dark()) +} + +/// 语义化颜色访问器。旧命名保留为别名,避免视图层大范围重写。 pub struct ThemeColors; #[allow(dead_code)] impl ThemeColors { - pub const BG_TITLEBAR: Rgba = const_rgb(0x1e1f22); - pub const BG_ACTIVITY_RAIL: Rgba = const_rgb(0x18191c); - pub const BG_SIDEBAR: Rgba = const_rgb(0x1e1f22); - pub const BG_EDITOR: Rgba = const_rgb(0x2b2d30); - pub const BG_TAB_BAR: Rgba = const_rgb(0x1e1f22); - pub const BG_TAB_ACTIVE: Rgba = const_rgb(0x2b2d30); - pub const BG_TAB_HOVER: Rgba = const_rgb(0x26282c); - pub const BG_BOTTOM_PANEL: Rgba = const_rgb(0x1e1f22); - pub const BG_STATUSBAR: Rgba = const_rgb(0x18191c); - pub const BORDER: Rgba = const_rgb(0x323438); - pub const ACCENT_BLUE: Rgba = const_rgb(0x3574f0); - pub const ACCENT_GREEN: Rgba = const_rgb(0x39a36b); - pub const ACCENT_YELLOW: Rgba = const_rgb(0xd29922); - pub const ACCENT_RED: Rgba = const_rgb(0xe02e2a); - pub const TEXT_PRIMARY: Rgba = const_rgb(0xdfdfe0); - pub const TEXT_MUTED: Rgba = const_rgb(0x818594); - pub const SUBTLE_SELECTION: Rgba = const_rgb(0x26282d); + // ---- Tauri token 命名 ---- + #[inline] + pub fn background() -> Rgba { + palette().background + } + #[inline] + pub fn surface() -> Rgba { + palette().surface + } + #[inline] + pub fn foreground() -> Rgba { + palette().foreground + } + #[inline] + pub fn muted_foreground() -> Rgba { + palette().muted_foreground + } + #[inline] + pub fn subtle_foreground() -> Rgba { + palette().subtle_foreground + } + #[inline] + pub fn border() -> Rgba { + palette().border + } + #[inline] + pub fn border_strong() -> Rgba { + palette().border_strong() + } + #[inline] + pub fn accent() -> Rgba { + palette().accent + } + #[inline] + pub fn selected() -> Rgba { + palette().selected + } + #[inline] + pub fn selection() -> Rgba { + palette().selection + } + #[inline] + pub fn primary() -> Rgba { + palette().primary + } + #[inline] + pub fn destructive() -> Rgba { + palette().destructive + } + #[inline] + pub fn success() -> Rgba { + palette().success + } + #[inline] + pub fn warning() -> Rgba { + palette().warning + } + #[inline] + pub fn info() -> Rgba { + palette().info + } + #[inline] + pub fn git_modified() -> Rgba { + palette().git_modified + } + #[inline] + pub fn git_added() -> Rgba { + palette().git_added + } + #[inline] + pub fn git_deleted() -> Rgba { + palette().git_deleted + } + #[inline] + pub fn git_untracked() -> Rgba { + palette().git_untracked + } + #[inline] + pub fn git_renamed() -> Rgba { + palette().git_renamed + } + // ---- 兼容旧访问器(映射到 Tauri token) ---- #[inline] pub fn bg_titlebar() -> Rgba { - Self::BG_TITLEBAR + palette().background } #[inline] pub fn bg_activity_rail() -> Rgba { - Self::BG_ACTIVITY_RAIL + palette().surface } #[inline] pub fn bg_sidebar() -> Rgba { - Self::BG_SIDEBAR + palette().surface } #[inline] pub fn bg_editor() -> Rgba { - Self::BG_EDITOR + palette().background } #[inline] pub fn bg_tab_bar() -> Rgba { - Self::BG_TAB_BAR + palette().background } #[inline] pub fn bg_tab_active() -> Rgba { - Self::BG_TAB_ACTIVE + palette().background } #[inline] pub fn bg_tab_hover() -> Rgba { - Self::BG_TAB_HOVER + palette().tab_hover() } #[inline] pub fn bg_bottom_panel() -> Rgba { - Self::BG_BOTTOM_PANEL + palette().background } #[inline] pub fn bg_statusbar() -> Rgba { - Self::BG_STATUSBAR - } - #[inline] - pub fn border() -> Rgba { - Self::BORDER + palette().background } #[inline] pub fn accent_blue() -> Rgba { - Self::ACCENT_BLUE + palette().primary } #[inline] pub fn accent_green() -> Rgba { - Self::ACCENT_GREEN + palette().success } #[inline] pub fn accent_yellow() -> Rgba { - Self::ACCENT_YELLOW + palette().warning } #[inline] pub fn accent_red() -> Rgba { - Self::ACCENT_RED + palette().destructive } #[inline] pub fn text_primary() -> Rgba { - Self::TEXT_PRIMARY + palette().foreground } #[inline] pub fn text_muted() -> Rgba { - Self::TEXT_MUTED + palette().subtle_foreground } #[inline] pub fn subtle_selection() -> Rgba { - Self::SUBTLE_SELECTION + palette().accent } } diff --git a/linux/src/workbench/activity_rail.rs b/linux/src/workbench/activity_rail.rs index 03a007567..5174d9bfa 100644 --- a/linux/src/workbench/activity_rail.rs +++ b/linux/src/workbench/activity_rail.rs @@ -1,126 +1,235 @@ +//! 活动栏:左侧主活动栏 + 右侧插件活动栏,复刻 Tauri `sidebar-pane-selector.tsx` +//! 与 `plugin-activity-rail.tsx`。 +//! +//! 左侧项顺序与可见性完全来自设置(`sidebar_activity_items_order`、 +//! `hidden_sidebar_activity_items`),并按 `core_features` 过滤后端不可用项; +//! 顺序与 Tauri 的 `SIDEBAR_ACTIVITY_ITEM_IDS` / `SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS` +//! 保持一致。所有交互只通过事件向外广播,具体业务由 `view.rs` 订阅处理。 + use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants as _}; -use gpui_kit::component::v_flex; +use gpui_kit::component::{h_flex, v_flex}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - px, Context, EventEmitter, IntoElement, ParentElement as _, Render, Styled as _, Window, + div, px, AnyElement, Context, EventEmitter, IntoElement, ParentElement as _, Render, + Styled as _, Window, }; +use crate::settings::{ + self, CoreFeatures, SIDEBAR_ACTIVITY_ITEM_IDS, SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS, +}; use crate::theme::ThemeColors; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ActivityTab { - Explorer, - Git, - Search, -} - +/// 左侧活动栏事件。`SelectView` 只承载顶部视图类(files/git/search), +/// `ToggleBottomPane` 承载底部工具面板类,二者取值与 Tauri 的稳定 id 一致。 #[derive(Debug, Clone)] +#[allow(dead_code)] pub enum ActivityRailEvent { - SelectTab(ActivityTab), - ToggleTerminal, + /// 切换顶部侧边视图,载荷为 "files" | "git" | "search"。 + SelectView(String), + /// 切换底部工具窗口,载荷为 "maven" | "run" | "terminal" | "diagnostics" | "gitLog"。 + ToggleBottomPane(String), + /// 打开设置。 OpenSettings, } pub struct ActivityRailView { - pub active_tab: Option, + /// 当前激活的顶部视图 id;`None` 表示侧边栏整体收起。 + pub active_view: Option, + /// 当前激活的底部工具窗口 id;`None` 表示底部面板收起。 + pub active_bottom: Option, + /// 顶部组项顺序(已过滤后端能力与隐藏项)。 + top_items: Vec<&'static str>, + /// 底部组项顺序,按 `SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS` 固定顺序排列。 + bottom_items: Vec<&'static str>, } impl EventEmitter for ActivityRailView {} impl ActivityRailView { - pub fn new() -> Self { + /// 从设置构建活动栏项列表。 + /// + /// 构造期只做一次快照:设置变更由 `view.rs` 重建/重订阅处理,这里不持有 + /// `App`,避免把全局状态带进渲染路径。 + pub fn new(cx: &mut Context) -> Self { + let s = settings::get(cx); + let ordered = normalize_order(&s.sidebar_activity_items_order); + let features = &s.core_features; + let hidden = &s.hidden_sidebar_activity_items; + + let visible: Vec<&'static str> = ordered + .into_iter() + .filter(|id| is_feature_available(id, features)) + .filter(|id| !hidden.iter().any(|h| h.as_str() == *id)) + .collect(); + + // 顶部组保留可见顺序;底部组按 Tauri 常量顺序取交集,保证与参考实现一致。 + let top_items = visible + .iter() + .copied() + .filter(|id| !SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS.contains(id)) + .collect(); + let bottom_items = SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS + .iter() + .copied() + .filter(|id| visible.contains(id)) + .collect(); + Self { - active_tab: Some(ActivityTab::Explorer), + active_view: Some("files".to_string()), + active_bottom: None, + top_items, + bottom_items, } } - pub fn set_active_tab(&mut self, tab: Option, cx: &mut Context) { - self.active_tab = tab; + pub fn set_active_view(&mut self, view: Option, cx: &mut Context) { + self.active_view = view; cx.notify(); } + + pub fn set_active_bottom(&mut self, bottom: Option, cx: &mut Context) { + self.active_bottom = bottom; + cx.notify(); + } + + /// 该 id 对应的事件出口:视图类发 `SelectView`,settings 发 `OpenSettings`, + /// 其余底部项发 `ToggleBottomPane`。 + fn event_for(id: &'static str) -> ActivityRailEvent { + match id { + "files" | "git" | "search" => ActivityRailEvent::SelectView(id.to_string()), + "settings" => ActivityRailEvent::OpenSettings, + other => ActivityRailEvent::ToggleBottomPane(other.to_string()), + } + } + + fn render_item(&self, id: &'static str, cx: &mut Context) -> AnyElement { + let (icon, tooltip) = item_meta(id); + let is_active = + self.active_view.as_deref() == Some(id) || self.active_bottom.as_deref() == Some(id); + let event = Self::event_for(id); + + // 用相对容器叠加左侧 2px 高亮竖条:Button 自身不便稳定地画出单边竖条, + // 且竖条需要压在按钮背景之上。hover 反馈由 ghost 变体内置的 accent 背景提供。 + div() + .w(px(32.0)) + .h(px(32.0)) + .relative() + .flex_shrink_0() + .child( + Button::new(format!("rail-{id}")) + .ghost() + .icon(icon) + .tooltip(tooltip) + .w(px(32.0)) + .h(px(32.0)) + .rounded(px(4.0)) + .text_color(if is_active { + ThemeColors::primary() + } else { + ThemeColors::subtle_foreground() + }) + .when(is_active, |btn| btn.bg(ThemeColors::selected())) + .on_click(cx.listener(move |_this, _event, _window, cx| { + cx.emit(event.clone()); + })), + ) + .when(is_active, |d| { + d.child( + div() + .absolute() + .left_0() + .top_0() + .bottom_0() + .w(px(2.0)) + .bg(ThemeColors::primary()), + ) + }) + .into_any_element() + } } impl Render for ActivityRailView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let active = self.active_tab; + let top: Vec = self + .top_items + .clone() + .into_iter() + .map(|id| self.render_item(id, cx)) + .collect(); + let bottom: Vec = self + .bottom_items + .clone() + .into_iter() + .map(|id| self.render_item(id, cx)) + .collect(); v_flex() .w(px(40.0)) .h_full() - .bg(ThemeColors::bg_activity_rail()) + .flex_shrink_0() + .bg(ThemeColors::surface()) .border_r_1() .border_color(ThemeColors::border()) .items_center() .justify_between() .py_2() .child( - // 顶部工具窗口图标列表 - v_flex() - .w_full() - .items_center() - .gap_1() - .child(self.render_rail_button( - "rail-files", - IconName::Folder, - "Explorer", - active == Some(ActivityTab::Explorer), - ActivityRailEvent::SelectTab(ActivityTab::Explorer), - cx, - )) - .child(self.render_rail_button( - "rail-git", - IconName::GitBranch, - "Version Control", - active == Some(ActivityTab::Git), - ActivityRailEvent::SelectTab(ActivityTab::Git), - cx, - )) - .child(self.render_rail_button( - "rail-search", - IconName::Search, - "Search Everywhere", - active == Some(ActivityTab::Search), - ActivityRailEvent::SelectTab(ActivityTab::Search), - cx, - )), + // 顶部视图组 + v_flex().w_full().items_center().gap_1().children(top), ) .child( - // 底部工具按钮列表 - v_flex() - .w_full() - .items_center() - .gap_1() - .child(self.render_rail_button( - "rail-terminal", - IconName::Terminal, - "Terminal", - false, - ActivityRailEvent::ToggleTerminal, - cx, - )) - .child(self.render_rail_button( - "rail-settings", - IconName::Settings, - "Settings", - false, - ActivityRailEvent::OpenSettings, - cx, - )), + // 底部工具组 + v_flex().w_full().items_center().gap_1().children(bottom), ) } } -impl ActivityRailView { - fn render_rail_button( +/// 右侧插件活动栏事件。 +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub enum PluginRailEvent { + OpenExtensions, + ToggleNotifications, + ToggleMaven, +} + +/// 右侧插件活动栏:Extensions / Notifications(带未读角标)/ Maven。 +#[allow(dead_code)] +pub struct PluginActivityRailView { + /// 通知未读数,用于角标;为 0 时不渲染角标。 + pub unread_notifications: usize, + /// Maven 是否可用(无 Maven 项目时隐藏入口)。 + pub maven_available: bool, +} + +impl EventEmitter for PluginActivityRailView {} + +#[allow(dead_code)] +impl Default for PluginActivityRailView { + fn default() -> Self { + Self::new() + } +} + +#[allow(dead_code)] +impl PluginActivityRailView { + pub fn new() -> Self { + Self { + unread_notifications: 3, + maven_available: true, + } + } + + /// 单个插件按钮:尺寸与左侧栏按钮一致;图标沿用按钮 Medium 尺寸(16px)。 + fn render_button( &self, id: &'static str, icon: IconName, tooltip: &'static str, - is_active: bool, - event: ActivityRailEvent, + event: PluginRailEvent, cx: &mut Context, - ) -> impl IntoElement { + ) -> AnyElement { Button::new(id) .ghost() .icon(icon) @@ -128,18 +237,136 @@ impl ActivityRailView { .w(px(32.0)) .h(px(32.0)) .rounded(px(4.0)) - .text_color(if is_active { - ThemeColors::accent_blue() - } else { - ThemeColors::text_muted() - }) - .when(is_active, |btn| { - btn.bg(ThemeColors::bg_sidebar()) - .border_l_2() - .border_color(ThemeColors::accent_blue()) - }) + .text_color(ThemeColors::subtle_foreground()) .on_click(cx.listener(move |_this, _event, _window, cx| { cx.emit(event.clone()); })) + .into_any_element() + } +} + +impl Render for PluginActivityRailView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let extensions = self.render_button( + "plugin-extensions", + IconName::Puzzle, + "Extensions", + PluginRailEvent::OpenExtensions, + cx, + ); + let maven = self.maven_available.then(|| { + self.render_button( + "plugin-maven", + IconName::Box, + "Maven", + PluginRailEvent::ToggleMaven, + cx, + ) + }); + + v_flex() + .w(px(38.0)) + .h_full() + .flex_shrink_0() + .bg(ThemeColors::surface()) + .border_l_1() + .border_color(ThemeColors::border()) + .items_center() + .pt_1() + .gap_1() + .child(extensions) + .child( + // 通知按钮 + 右上角未读角标。角标叠加在按钮容器上,避免撑开按钮尺寸。 + div() + .w(px(32.0)) + .h(px(32.0)) + .relative() + .flex_shrink_0() + .child(self.render_button( + "plugin-notifications", + IconName::Bell, + "Notifications", + PluginRailEvent::ToggleNotifications, + cx, + )) + .when(self.unread_notifications > 0, |d| { + // 超过 9 条统一显示 9+,避免角标随数字变宽。 + let label = if self.unread_notifications > 9 { + "9+".to_string() + } else { + self.unread_notifications.to_string() + }; + d.child( + h_flex() + .absolute() + .top_0() + .right_0() + .min_w(px(14.0)) + .h(px(14.0)) + .rounded_full() + .bg(ThemeColors::destructive()) + .items_center() + .justify_center() + .text_xs() + .text_color(gpui_kit::white()) + .child(label), + ) + }), + ) + .when_some(maven, |rail, maven| rail.child(maven)) + } +} + +/// id → 图标与 tooltip(含快捷键,文案对齐 Tauri)。 +fn item_meta(id: &str) -> (IconName, &'static str) { + match id { + "files" => (IconName::Files, "Files (Ctrl+Shift+E)"), + "git" => (IconName::GitBranch, "Version Control (Ctrl+Shift+G)"), + "search" => (IconName::Search, "Search (Ctrl+Shift+F)"), + "maven" => (IconName::Box, "Maven"), + "run" => (IconName::Play, "Run (Shift+F10)"), + "terminal" => (IconName::Terminal, "Terminal (Ctrl+J)"), + "diagnostics" => (IconName::TriangleAlert, "Problems (Ctrl+Shift+J)"), + "gitLog" => (IconName::GitGraph, "Git Graph (Alt+9)"), + _ => (IconName::Settings, "Settings"), + } +} + +/// 把持久化顺序规范化成合法且无重复的静态 id 列表,缺失项按默认顺序补齐, +/// 对齐 Tauri `normalizeItemOrder`。 +fn normalize_order(persisted: &[String]) -> Vec<&'static str> { + let mut out: Vec<&'static str> = Vec::new(); + for id in persisted { + if let Some(known) = static_id(id) { + if !out.contains(&known) { + out.push(known); + } + } + } + for id in SIDEBAR_ACTIVITY_ITEM_IDS.iter().copied() { + if !out.contains(&id) { + out.push(id); + } + } + out +} + +fn static_id(id: &str) -> Option<&'static str> { + SIDEBAR_ACTIVITY_ITEM_IDS + .iter() + .copied() + .find(|known| *known == id) +} + +/// 按后端能力过滤:search/git(含 gitLog)/terminal/diagnostics 由 `core_features` +/// 控制,其余(files/maven/run/settings)始终可见,对齐 Tauri +/// `sidebarActivityVisibilityItemIds`。 +fn is_feature_available(id: &str, features: &CoreFeatures) -> bool { + match id { + "search" => features.search, + "git" | "gitLog" => features.git, + "terminal" => features.terminal, + "diagnostics" => features.diagnostics, + _ => true, } } diff --git a/linux/src/workbench/command_palette.rs b/linux/src/workbench/command_palette.rs new file mode 100644 index 000000000..e0aa6c052 --- /dev/null +++ b/linux/src/workbench/command_palette.rs @@ -0,0 +1,912 @@ +//! 工作台命令面板(Command Palette)模态视图。 +//! +//! 复刻 Tauri 端 `features/command-palette` 的通用弹层壳与动作清单:顶部搜索输入行、 +//! 按 category 分组的命令列表、底部操作指引。当前只提供点击与键盘交互,选中后通过 +//! [`CommandPaletteEvent::Execute`] 把命令 id 交给上层工作台处理,不在本模块执行真实命令。 + +use gpui_kit::assets::IconName; +use gpui_kit::component::scroll::ScrollableElement as _; +use gpui_kit::component::{h_flex, v_flex, Icon}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::{ + div, px, rgba, AnyElement, Context, EventEmitter, FocusHandle, FontWeight, + InteractiveElement as _, IntoElement, KeyDownEvent, ParentElement as _, Render, + StatefulInteractiveElement as _, Styled as _, Window, +}; + +use crate::theme::ThemeColors; + +/// 命令面板中的一条命令。 +/// +/// `id` 为稳定的命令标识(如 `file.save`),`category` 用作列表分组标题, +/// `shortcut` 为展示用快捷键文案(不参与按键分发)。 +#[derive(Debug, Clone)] +pub struct CommandPaletteItem { + pub id: String, + pub name: String, + pub category: String, + pub shortcut: Option, + pub icon: IconName, +} + +/// 命令面板对外事件。 +#[derive(Debug, Clone)] +pub enum CommandPaletteEvent { + /// 选中并确认执行某条命令,携带命令 id。 + Execute(String), + /// 请求关闭命令面板。 + Close, +} + +/// 列表渲染行:分组标题或命令项(`Item` 携带该项在 `filtered` 中的位置)。 +enum PaletteRow { + Header(String), + Item(usize), +} + +/// 居中的命令面板模态,宽 704、最大高 440。 +pub struct CommandPaletteModal { + pub query: String, + pub items: Vec, + /// 命中筛选的命令在 `items` 中的下标,保持构造时的分组顺序。 + pub filtered: Vec, + pub selected_index: usize, + pub focus_handle: FocusHandle, + /// 预留的多级视图栈,根视图为 `"root"`。 + pub view_stack: Vec, +} + +impl EventEmitter for CommandPaletteModal {} + +impl CommandPaletteModal { + pub fn new(cx: &mut Context) -> Self { + let items = build_commands(); + let filtered = (0..items.len()).collect(); + + Self { + query: String::new(), + items, + filtered, + selected_index: 0, + focus_handle: cx.focus_handle(), + view_stack: vec!["root".to_string()], + } + } + + /// 复位到初始状态:清空查询、选中归零、视图栈回到根,并重算筛选结果。 + pub fn reset(&mut self, cx: &mut Context) { + self.query.clear(); + self.selected_index = 0; + self.view_stack = vec!["root".to_string()]; + self.recompute_filtered(); + cx.notify(); + } + + /// 直接设置查询串(供上层联动外部输入)。 + #[allow(dead_code)] + pub fn set_query(&mut self, q: impl Into, cx: &mut Context) { + self.query = q.into(); + self.selected_index = 0; + self.recompute_filtered(); + cx.notify(); + } + + /// 在当前命中列表内移动选中项,`delta` 为正向下、为负向上,越界即夹紧。 + pub fn move_selection(&mut self, delta: i32, cx: &mut Context) { + let total = self.filtered.len(); + if total == 0 { + self.selected_index = 0; + } else { + let current = self.selected_index.min(total - 1) as i32; + self.selected_index = (current + delta).clamp(0, total as i32 - 1) as usize; + } + cx.notify(); + } + + /// 按查询串重算命中列表:命令名、分组、id 任一包含即命中。 + fn recompute_filtered(&mut self) { + let q = self.query.trim().to_lowercase(); + self.filtered = self + .items + .iter() + .enumerate() + .filter(|(_, item)| { + q.is_empty() + || item.name.to_lowercase().contains(&q) + || item.category.to_lowercase().contains(&q) + || item.id.to_lowercase().contains(&q) + }) + .map(|(idx, _)| idx) + .collect(); + if self.selected_index >= self.filtered.len() { + self.selected_index = self.filtered.len().saturating_sub(1); + } + } + + /// 把命中列表展开成“分组标题 + 命令项”的渲染行序列。 + fn build_rows(&self) -> Vec { + let mut rows = Vec::new(); + let mut last_category: Option<&str> = None; + for (position, &item_idx) in self.filtered.iter().enumerate() { + let item = &self.items[item_idx]; + if last_category != Some(item.category.as_str()) { + rows.push(PaletteRow::Header(item.category.clone())); + last_category = Some(item.category.as_str()); + } + rows.push(PaletteRow::Item(position)); + } + rows + } + + /// 当前有效选中下标(对空列表安全)。 + fn current_index(&self) -> usize { + if self.filtered.is_empty() { + 0 + } else { + self.selected_index.min(self.filtered.len() - 1) + } + } + + /// 执行命中列表第 `position` 项,发出其命令 id。 + fn execute_at(&self, position: usize, cx: &mut Context) { + if let Some(&item_idx) = self.filtered.get(position) { + cx.emit(CommandPaletteEvent::Execute( + self.items[item_idx].id.clone(), + )); + } + } +} + +impl Render for CommandPaletteModal { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // 请求聚焦以接收按键输入 + window.focus(&self.focus_handle, cx); + + let rows = self.build_rows(); + let current_index = self.current_index(); + let total = self.filtered.len(); + + // 全屏半透明遮罩:点击空白处关闭 + div() + .id("command-palette-backdrop") + .track_focus(&self.focus_handle) + .absolute() + .inset_0() + .bg(rgba(0x00000088)) + .flex() + .items_center() + .justify_center() + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { + let key = event.keystroke.key.as_str(); + match key { + "escape" => { + cx.emit(CommandPaletteEvent::Close); + } + "up" | "arrowup" => this.move_selection(-1, cx), + "down" | "arrowdown" => this.move_selection(1, cx), + "enter" => { + if !this.filtered.is_empty() { + let idx = this.current_index(); + this.execute_at(idx, cx); + } + } + "backspace" => { + this.query.pop(); + this.selected_index = 0; + this.recompute_filtered(); + cx.notify(); + } + "space" => { + this.query.push(' '); + this.selected_index = 0; + this.recompute_filtered(); + cx.notify(); + } + _ => { + // 无修饰键时把可打印字符追加到查询串 + if !event.keystroke.modifiers.control + && !event.keystroke.modifiers.alt + && !event.keystroke.modifiers.platform + { + if let Some(ch) = &event.keystroke.key_char { + this.query.push_str(ch); + this.selected_index = 0; + this.recompute_filtered(); + cx.notify(); + } else if key.chars().count() == 1 { + this.query.push_str(key); + this.selected_index = 0; + this.recompute_filtered(); + cx.notify(); + } + } + } + } + })) + .on_mouse_down( + gpui_kit::MouseButton::Left, + cx.listener(|_this, _event, _window, cx| { + cx.emit(CommandPaletteEvent::Close); + }), + ) + .child( + v_flex() + .id("command-palette-card") + .w(px(704.0)) + .max_h(px(440.0)) + .bg(ThemeColors::surface()) + .border_1() + .border_color(ThemeColors::border()) + .rounded_lg() + .shadow_lg() + .overflow_hidden() + .on_mouse_down( + gpui_kit::MouseButton::Left, + cx.listener(|_this, _event, _window, cx| { + // 卡片内点击不冒泡到遮罩,避免误关闭 + cx.stop_propagation(); + }), + ) + .child( + // 1. 顶部输入行:搜索图标 + 查询串/占位 + Esc 徽标 + h_flex() + .h(px(52.0)) + .w_full() + .items_center() + .gap_2p5() + .px_4() + .border_b_1() + .border_color(ThemeColors::border()) + .child( + Icon::new(IconName::Search) + .size(px(16.0)) + .text_color(ThemeColors::primary()), + ) + .child( + h_flex() + .flex_1() + .items_center() + .gap_1() + .child( + div() + .text_sm() + .text_color(if self.query.is_empty() { + ThemeColors::subtle_foreground() + } else { + ThemeColors::foreground() + }) + .child(if self.query.is_empty() { + "Type a command...".to_string() + } else { + self.query.clone() + }), + ) + .child( + // 静态光标条,提示可输入 + div().w(px(2.0)).h(px(16.0)).bg(ThemeColors::primary()), + ), + ) + .child(shortcut_badge("Esc")), + ) + .child( + // 2. 命令列表:按 category 分组,可滚动 + div() + .flex_1() + .w_full() + .max_h(px(340.0)) + .overflow_y_scrollbar() + .py_1() + .when(rows.is_empty(), |list| { + list.child( + div() + .w_full() + .py_8() + .text_center() + .text_sm() + .text_color(ThemeColors::subtle_foreground()) + .child("No commands found"), + ) + }) + .children(rows.into_iter().map(|row| { + match row { + PaletteRow::Header(category) => h_flex() + .px_3() + .pt_2() + .pb_1() + .child( + div() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(ThemeColors::muted_foreground()) + .child(category), + ) + .into_any_element(), + PaletteRow::Item(position) => { + let item = &self.items[self.filtered[position]]; + let is_selected = position == current_index; + let icon = item.icon; + let name = item.name.clone(); + let shortcut = item.shortcut.clone(); + + h_flex() + .id(("command-palette-item", position)) + .h(px(36.0)) + .w_full() + .mx_2() + .px_2p5() + .items_center() + .gap_2p5() + .rounded_md() + .cursor_pointer() + .when(is_selected, |row| { + row.bg(ThemeColors::selected()) + }) + .when(!is_selected, |row| { + row.hover(|h| h.bg(ThemeColors::accent())) + }) + .child(Icon::new(icon).size(px(15.0)).text_color( + if is_selected { + ThemeColors::primary() + } else { + ThemeColors::muted_foreground() + }, + )) + .child( + div() + .flex_1() + .text_sm() + .text_color(ThemeColors::foreground()) + .child(name), + ) + .when_some(shortcut, |row, sc| { + row.child(shortcut_badge(&sc)) + }) + .on_click(cx.listener( + move |this, _event, _window, cx| { + this.execute_at(position, cx); + }, + )) + .into_any_element() + } + } + })), + ) + .child( + // 3. 底部指引栏:左侧命令数、右侧操作提示 + h_flex() + .h(px(34.0)) + .w_full() + .items_center() + .justify_between() + .px_4() + .bg(ThemeColors::background()) + .border_t_1() + .border_color(ThemeColors::border()) + .child( + div() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child(format!("{} commands", total)), + ) + .child( + div() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child("↑↓ Navigate · ↵ Run · Esc Close"), + ), + ), + ) + } +} + +/// 快捷键/键位徽标。 +fn shortcut_badge(label: &str) -> AnyElement { + div() + .px_1p5() + .py(px(1.0)) + .rounded_sm() + .bg(ThemeColors::accent()) + .border_1() + .border_color(ThemeColors::border()) + .text_xs() + .text_color(ThemeColors::muted_foreground()) + .child(label.to_string()) + .into_any_element() +} + +/// 构造一条命令,减少清单里的重复样板。 +fn command( + id: &str, + name: &str, + category: &str, + shortcut: Option<&str>, + icon: IconName, +) -> CommandPaletteItem { + CommandPaletteItem { + id: id.to_string(), + name: name.to_string(), + category: category.to_string(), + shortcut: shortcut.map(|s| s.to_string()), + icon, + } +} + +/// 与 Tauri 端动作工厂对齐的命令清单,按 category 顺序排列以支持列表分组。 +fn build_commands() -> Vec { + vec![ + // File + command( + "file.new", + "File: New File", + "File", + Some("Ctrl+N"), + IconName::Plus, + ), + command( + "file.open", + "File: Open Project...", + "File", + Some("Ctrl+O"), + IconName::FolderOpen, + ), + command( + "file.save", + "File: Save", + "File", + Some("Ctrl+S"), + IconName::Save, + ), + command( + "file.saveAll", + "File: Save All", + "File", + Some("Ctrl+Shift+S"), + IconName::SaveAll, + ), + command( + "file.closeEditor", + "File: Close Editor", + "File", + Some("Ctrl+W"), + IconName::Close, + ), + command( + "file.reopenClosed", + "File: Reopen Closed Editor", + "File", + Some("Ctrl+Shift+T"), + IconName::RotateCw, + ), + // Edit + command( + "edit.undo", + "Edit: Undo", + "Edit", + Some("Ctrl+Z"), + IconName::Undo, + ), + command( + "edit.redo", + "Edit: Redo", + "Edit", + Some("Ctrl+Shift+Z"), + IconName::Redo, + ), + command( + "edit.cut", + "Edit: Cut", + "Edit", + Some("Ctrl+X"), + IconName::Scissors, + ), + command( + "edit.copy", + "Edit: Copy", + "Edit", + Some("Ctrl+C"), + IconName::Copy, + ), + command( + "edit.paste", + "Edit: Paste", + "Edit", + Some("Ctrl+V"), + IconName::ClipboardPaste, + ), + command( + "edit.find", + "Edit: Find", + "Edit", + Some("Ctrl+F"), + IconName::Search, + ), + command( + "edit.replace", + "Edit: Replace", + "Edit", + Some("Ctrl+H"), + IconName::RefreshCw, + ), + command( + "edit.format", + "Edit: Format Document", + "Edit", + Some("Shift+Alt+F"), + IconName::Code, + ), + // View + command( + "workbench.toggle_sidebar", + "View: Toggle Sidebar", + "View", + Some("Ctrl+B"), + IconName::PanelLeft, + ), + command( + "workbench.toggle_terminal", + "View: Toggle Bottom Panel", + "View", + Some("Ctrl+J"), + IconName::PanelBottom, + ), + command( + "view.zoomIn", + "View: Zoom In", + "View", + Some("Ctrl+="), + IconName::Plus, + ), + command( + "view.zoomOut", + "View: Zoom Out", + "View", + Some("Ctrl+-"), + IconName::Minus, + ), + command( + "view.resetZoom", + "View: Reset Zoom", + "View", + Some("Ctrl+0"), + IconName::RotateCw, + ), + command( + "view.colorTheme", + "View: Color Theme", + "View", + None, + IconName::Palette, + ), + command( + "view.iconTheme", + "View: File Icon Theme", + "View", + None, + IconName::Box, + ), + // Go + command( + "go.quickOpen", + "Go: Quick Open", + "Go", + Some("Ctrl+P"), + IconName::Search, + ), + command( + "go.commandPalette", + "Go: Command Palette", + "Go", + Some("Ctrl+Shift+P"), + IconName::Search, + ), + command( + "go.symbolInFile", + "Go: Symbol in File", + "Go", + Some("Ctrl+Shift+O"), + IconName::Code, + ), + command( + "go.workspaceSymbol", + "Go: Symbol in Workspace", + "Go", + Some("Ctrl+T"), + IconName::Code, + ), + command( + "go.goToLine", + "Go: Go to Line", + "Go", + Some("Ctrl+G"), + IconName::Hash, + ), + command( + "go.back", + "Go: Back", + "Go", + Some("Alt+Left"), + IconName::ArrowLeft, + ), + command( + "go.forward", + "Go: Forward", + "Go", + Some("Alt+Right"), + IconName::ArrowRight, + ), + command( + "go.nextEditor", + "Go: Next Editor", + "Go", + Some("Ctrl+Tab"), + IconName::ArrowRight, + ), + // Terminal + command( + "terminal.new", + "Terminal: New Terminal", + "Terminal", + Some("Ctrl+`"), + IconName::Terminal, + ), + command( + "terminal.split", + "Terminal: Split Terminal", + "Terminal", + None, + IconName::Columns2, + ), + command( + "terminal.clear", + "Terminal: Clear", + "Terminal", + None, + IconName::Trash, + ), + command( + "terminal.toggle", + "Terminal: Toggle Panel", + "Terminal", + Some("Ctrl+J"), + IconName::PanelBottom, + ), + // Run + command( + "run.start", + "Run: Start", + "Run", + Some("Shift+F10"), + IconName::Play, + ), + command( + "run.debug", + "Run: Start Debugging", + "Run", + Some("F5"), + IconName::Bug, + ), + command( + "run.stop", + "Run: Stop", + "Run", + Some("Shift+F5"), + IconName::Square, + ), + command( + "run.withoutDebug", + "Run: Run Without Debugging", + "Run", + Some("Ctrl+F5"), + IconName::Play, + ), + command( + "run.build", + "Run: Build Project", + "Run", + Some("Ctrl+Shift+B"), + IconName::Box, + ), + command( + "run.tests", + "Run: Run Tests", + "Run", + None, + IconName::CircleCheck, + ), + // Tools + command( + "tools.keyboardShortcuts", + "Tools: Keyboard Shortcuts", + "Tools", + Some("Ctrl+K Ctrl+S"), + IconName::Keyboard, + ), + command( + "tools.developerTools", + "Tools: Toggle Developer Tools", + "Tools", + Some("Ctrl+Shift+I"), + IconName::Bug, + ), + command( + "tools.showLogs", + "Tools: Show Logs", + "Tools", + None, + IconName::Terminal, + ), + command( + "tools.database", + "Tools: Open Database", + "Tools", + None, + IconName::Database, + ), + command( + "tools.memory", + "Tools: Memory Usage", + "Tools", + None, + IconName::MemoryStick, + ), + command( + "tools.languageServers", + "Tools: Language Servers", + "Tools", + None, + IconName::Info, + ), + // Window + command( + "window.minimize", + "Window: Minimize", + "Window", + None, + IconName::Minimize, + ), + command( + "window.maximize", + "Window: Maximize", + "Window", + None, + IconName::Maximize, + ), + command( + "window.toggleFullscreen", + "Window: Toggle Fullscreen", + "Window", + Some("F11"), + IconName::Maximize, + ), + command( + "window.reload", + "Window: Reload", + "Window", + Some("Ctrl+R"), + IconName::RotateCw, + ), + command( + "window.close", + "Window: Close", + "Window", + Some("Ctrl+Shift+W"), + IconName::Close, + ), + // Help + command( + "help.documentation", + "Help: Documentation", + "Help", + None, + IconName::BookOpen, + ), + command( + "help.reportIssue", + "Help: Report Issue", + "Help", + None, + IconName::ExternalLink, + ), + command( + "help.checkForUpdates", + "Help: Check for Updates", + "Help", + None, + IconName::RotateCw, + ), + command( + "help.about", + "Help: About Lithe", + "Help", + None, + IconName::Info, + ), + command("help.welcome", "Help: Welcome", "Help", None, IconName::Zap), + // Settings + command( + "workbench.open_settings", + "Settings: Open Settings", + "Settings", + Some("Ctrl+,"), + IconName::Settings, + ), + command( + "workbench.open_settings_appearance", + "Settings: Appearance", + "Settings", + None, + IconName::SlidersHorizontal, + ), + command( + "workbench.open_settings_keybindings", + "Settings: Keybindings", + "Settings", + None, + IconName::Keyboard, + ), + command( + "workbench.open_settings_theme", + "Settings: Color Theme", + "Settings", + None, + IconName::Palette, + ), + // Pane + command( + "view.split_editor_right", + "Pane: Split Editor Right", + "Pane", + None, + IconName::Columns2, + ), + command( + "view.split_editor_down", + "Pane: Split Editor Down", + "Pane", + None, + IconName::Rows2, + ), + command( + "view.close_editor_group", + "Pane: Close Editor Group", + "Pane", + None, + IconName::Close, + ), + command( + "view.close_other_groups", + "Pane: Close Other Editor Groups", + "Pane", + None, + IconName::Close, + ), + command( + "view.move_editor_next_group", + "Pane: Move Editor to Next Group", + "Pane", + None, + IconName::ArrowRight, + ), + command( + "view.move_editor_previous_group", + "Pane: Move Editor to Previous Group", + "Pane", + None, + IconName::ArrowLeft, + ), + command( + "view.reset_editor_group_sizes", + "Pane: Reset Editor Group Sizes", + "Pane", + None, + IconName::Columns2, + ), + command( + "view.toggle_editor_group_lock", + "Pane: Toggle Editor Group Lock", + "Pane", + None, + IconName::Lock, + ), + ] +} diff --git a/linux/src/workbench/mod.rs b/linux/src/workbench/mod.rs index e397bbc73..811610eac 100644 --- a/linux/src/workbench/mod.rs +++ b/linux/src/workbench/mod.rs @@ -1,6 +1,8 @@ pub mod activity_rail; pub mod bottom_panel; +pub mod command_palette; pub mod editor; +pub mod quick_open; pub mod search_everywhere; pub mod settings_dialog; pub mod sidebar; @@ -8,23 +10,30 @@ pub mod status_bar; pub mod terminal; pub mod toolbar; pub mod view; +pub mod welcome_screen; #[allow(unused_imports)] -pub use activity_rail::{ActivityRailEvent, ActivityRailView, ActivityTab}; +pub use activity_rail::{ + ActivityRailEvent, ActivityRailView, PluginActivityRailView, PluginRailEvent, +}; #[allow(unused_imports)] pub use bottom_panel::BottomPanelView; #[allow(unused_imports)] +pub use command_palette::{CommandPaletteEvent, CommandPaletteModal}; +#[allow(unused_imports)] pub use editor::EditorView; #[allow(unused_imports)] +pub use quick_open::{QuickOpenEvent, QuickOpenModal}; +#[allow(unused_imports)] pub use search_everywhere::{SearchEverywhereEvent, SearchEverywhereModal, SearchScope}; #[allow(unused_imports)] pub use settings_dialog::{SettingsCategory, SettingsDialog, SettingsEvent}; #[allow(unused_imports)] pub use sidebar::{SidebarEvent, SidebarTab, SidebarView}; #[allow(unused_imports)] -pub use status_bar::StatusBarView; -#[allow(unused_imports)] -pub use terminal::TerminalView; +pub use status_bar::{StatusBarEvent, StatusBarView}; #[allow(unused_imports)] pub use toolbar::{ToolbarEvent, ToolbarView}; pub use view::WorkbenchView; +#[allow(unused_imports)] +pub use welcome_screen::{RecentProject, WelcomeEvent, WelcomeScreenView}; diff --git a/linux/src/workbench/quick_open.rs b/linux/src/workbench/quick_open.rs new file mode 100644 index 000000000..9155d9dca --- /dev/null +++ b/linux/src/workbench/quick_open.rs @@ -0,0 +1,506 @@ +//! 工作台快速打开(Quick Open)模态视图。 +//! +//! 复刻 Tauri 端 `features/quick-open` 的三模式弹层:默认按文件路径筛选, +//! 以 `@` 开头切换到当前文件符号、`#` 开头切换到工作区符号。本模块只维护 +//! 查询、分组与选中态,确认后通过 [`QuickOpenEvent::OpenFile`] 把路径交给上层, +//! 不接入真实文件索引与 LSP 符号查询。 + +use gpui_kit::assets::IconName; +use gpui_kit::component::scroll::ScrollableElement as _; +use gpui_kit::component::{h_flex, v_flex, Icon}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::{ + div, px, rgba, AnyElement, Context, EventEmitter, FocusHandle, FontWeight, + InteractiveElement as _, IntoElement, KeyDownEvent, ParentElement as _, Render, + StatefulInteractiveElement as _, Styled as _, Window, +}; + +use crate::theme::ThemeColors; + +/// 快速打开的工作模式,由查询串前缀决定。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QuickOpenMode { + /// 默认模式:按文件路径筛选。 + File, + /// `@` 前缀:当前文件符号。 + Symbol, + /// `#` 前缀:工作区符号。 + WorkspaceSymbol, +} + +impl QuickOpenMode { + /// 顶部输入行展示的模式徽标文案。 + fn badge_label(self) -> &'static str { + match self { + QuickOpenMode::File => "File", + QuickOpenMode::Symbol => "Symbols", + QuickOpenMode::WorkspaceSymbol => "Workspace Symbols", + } + } + + /// 列表为空时展示的提示文案。 + fn empty_label(self) -> &'static str { + match self { + QuickOpenMode::File => "No matching files", + QuickOpenMode::Symbol => "No matching symbols", + QuickOpenMode::WorkspaceSymbol => "No matching workspace symbols", + } + } +} + +/// 快速打开对外事件。 +#[derive(Debug, Clone)] +pub enum QuickOpenEvent { + /// 打开选中的文件或跳转到选中的符号,携带路径。 + OpenFile(String), + /// 请求关闭快速打开。 + Close, +} + +/// 列表渲染行:分组标题或文件项(携带该项在 `filtered` 中的位置)。 +enum QuickOpenRow { + Header(&'static str), + File(usize, String), +} + +/// 居中的快速打开模态,宽 704、高 420。 +pub struct QuickOpenModal { + pub query: String, + pub mode: QuickOpenMode, + pub files: Vec, + /// 命中筛选的文件路径,保持 `files` 的原始顺序。 + pub filtered: Vec, + pub selected_index: usize, + pub focus_handle: FocusHandle, + /// 最近打开的文件,用于优先分组展示。 + pub recent: Vec, +} + +impl EventEmitter for QuickOpenModal {} + +impl QuickOpenModal { + pub fn new(cx: &mut Context) -> Self { + Self { + query: String::new(), + mode: QuickOpenMode::File, + files: Vec::new(), + filtered: Vec::new(), + selected_index: 0, + focus_handle: cx.focus_handle(), + recent: Vec::new(), + } + } + + /// 替换候选文件列表并重算筛选结果。 + pub fn set_files(&mut self, files: Vec, cx: &mut Context) { + self.files = files; + self.recompute_filtered(); + cx.notify(); + } + + /// 复位到初始状态:清空查询与选中,回到 File 模式。 + pub fn reset(&mut self, cx: &mut Context) { + self.query.clear(); + self.mode = QuickOpenMode::File; + self.selected_index = 0; + self.recompute_filtered(); + cx.notify(); + } + + /// 设置查询串并同步模式(`@`/`#` 前缀)与筛选结果。 + #[allow(dead_code)] + pub fn set_query(&mut self, q: impl Into, cx: &mut Context) { + self.query = q.into(); + self.mode = mode_for_query(&self.query); + self.selected_index = 0; + self.recompute_filtered(); + cx.notify(); + } + + /// 在当前命中列表内移动选中项,`delta` 为正向下、为负向上,越界即夹紧。 + pub fn move_selection(&mut self, delta: i32, cx: &mut Context) { + let total = self.filtered.len(); + if total == 0 { + self.selected_index = 0; + } else { + let current = self.selected_index.min(total - 1) as i32; + self.selected_index = (current + delta).clamp(0, total as i32 - 1) as usize; + } + cx.notify(); + } + + /// 当前有效选中下标(对空列表安全)。 + fn current_index(&self) -> usize { + if self.filtered.is_empty() { + 0 + } else { + self.selected_index.min(self.filtered.len() - 1) + } + } + + /// 当前用于匹配的查询串:符号模式下忽略前缀字符。 + fn effective_query(&self) -> String { + if self.mode == QuickOpenMode::File { + self.query.trim().to_lowercase() + } else { + self.query + .trim_start_matches(|c| c == '@' || c == '#') + .trim() + .to_lowercase() + } + } + + /// 按有效查询串重算命中列表。 + fn recompute_filtered(&mut self) { + let q = self.effective_query(); + self.filtered = self + .files + .iter() + .filter(|path| q.is_empty() || path.to_lowercase().contains(&q)) + .cloned() + .collect(); + if self.selected_index >= self.filtered.len() { + self.selected_index = self.filtered.len().saturating_sub(1); + } + } + + /// 把命中列表展开为“recent / other”分组行序列。 + fn build_rows(&self) -> Vec { + let mut rows = Vec::new(); + let mut recent_emitted = false; + let mut other_emitted = false; + for (position, path) in self.filtered.iter().enumerate() { + if self.recent.iter().any(|r| r == path) { + if !recent_emitted { + rows.push(QuickOpenRow::Header("recent")); + recent_emitted = true; + } + } else if !other_emitted { + rows.push(QuickOpenRow::Header("other")); + other_emitted = true; + } + rows.push(QuickOpenRow::File(position, path.clone())); + } + rows + } + + /// 打开命中列表第 `position` 项。 + fn open_at(&self, position: usize, cx: &mut Context) { + if let Some(path) = self.filtered.get(position) { + cx.emit(QuickOpenEvent::OpenFile(path.clone())); + } + } +} + +/// 根据查询串前缀解析模式。 +fn mode_for_query(query: &str) -> QuickOpenMode { + if query.starts_with('@') { + QuickOpenMode::Symbol + } else if query.starts_with('#') { + QuickOpenMode::WorkspaceSymbol + } else { + QuickOpenMode::File + } +} + +impl Render for QuickOpenModal { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // 请求聚焦以接收按键输入 + window.focus(&self.focus_handle, cx); + + let rows = self.build_rows(); + let current_index = self.current_index(); + let total = self.filtered.len(); + let mode = self.mode; + + // 全屏半透明遮罩:点击空白处关闭 + div() + .id("quick-open-backdrop") + .track_focus(&self.focus_handle) + .absolute() + .inset_0() + .bg(rgba(0x00000088)) + .flex() + .items_center() + .justify_center() + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { + let key = event.keystroke.key.as_str(); + match key { + "escape" => { + cx.emit(QuickOpenEvent::Close); + } + "up" | "arrowup" => this.move_selection(-1, cx), + "down" | "arrowdown" => this.move_selection(1, cx), + "enter" => { + if !this.filtered.is_empty() { + let idx = this.current_index(); + this.open_at(idx, cx); + } + } + "backspace" => { + this.query.pop(); + this.mode = mode_for_query(&this.query); + this.selected_index = 0; + this.recompute_filtered(); + cx.notify(); + } + "space" => { + this.query.push(' '); + this.selected_index = 0; + this.recompute_filtered(); + cx.notify(); + } + _ => { + // 无修饰键时把可打印字符追加到查询串,并随前缀切换模式 + if !event.keystroke.modifiers.control + && !event.keystroke.modifiers.alt + && !event.keystroke.modifiers.platform + { + let mut changed = false; + if let Some(ch) = &event.keystroke.key_char { + this.query.push_str(ch); + changed = true; + } else if key.chars().count() == 1 { + this.query.push_str(key); + changed = true; + } + if changed { + this.mode = mode_for_query(&this.query); + this.selected_index = 0; + this.recompute_filtered(); + cx.notify(); + } + } + } + } + })) + .on_mouse_down( + gpui_kit::MouseButton::Left, + cx.listener(|_this, _event, _window, cx| { + cx.emit(QuickOpenEvent::Close); + }), + ) + .child( + v_flex() + .id("quick-open-card") + .w(px(704.0)) + .h(px(420.0)) + .bg(ThemeColors::surface()) + .border_1() + .border_color(ThemeColors::border()) + .rounded_lg() + .shadow_lg() + .overflow_hidden() + .on_mouse_down( + gpui_kit::MouseButton::Left, + cx.listener(|_this, _event, _window, cx| { + // 卡片内点击不冒泡到遮罩,避免误关闭 + cx.stop_propagation(); + }), + ) + .child( + // 1. 顶部输入行:搜索图标 + 模式徽标 + 查询串/占位 + Esc 徽标 + h_flex() + .h(px(52.0)) + .w_full() + .items_center() + .gap_2p5() + .px_4() + .border_b_1() + .border_color(ThemeColors::border()) + .child( + Icon::new(IconName::Search) + .size(px(16.0)) + .text_color(ThemeColors::primary()), + ) + .child(mode_badge(mode.badge_label())) + .child( + h_flex() + .flex_1() + .items_center() + .gap_1() + .child( + div() + .text_sm() + .text_color(if self.query.is_empty() { + ThemeColors::subtle_foreground() + } else { + ThemeColors::foreground() + }) + .child(if self.query.is_empty() { + "Search files by name (@ symbols, # workspace)" + .to_string() + } else { + self.query.clone() + }), + ) + .child( + // 静态光标条,提示可输入 + div().w(px(2.0)).h(px(16.0)).bg(ThemeColors::primary()), + ), + ) + .child(shortcut_badge("Esc")), + ) + .child( + // 2. 结果列表:按 recent / other 分组,路径分段着色 + div() + .flex_1() + .w_full() + .overflow_y_scrollbar() + .py_1() + .when(rows.is_empty(), |list| { + list.child( + div() + .w_full() + .py_8() + .text_center() + .text_sm() + .text_color(ThemeColors::subtle_foreground()) + .child(mode.empty_label()), + ) + }) + .children(rows.into_iter().map(|row| { + match row { + QuickOpenRow::Header(label) => h_flex() + .px_3() + .pt_2() + .pb_1() + .child( + div() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(ThemeColors::muted_foreground()) + .child(label), + ) + .into_any_element(), + QuickOpenRow::File(position, path) => { + let is_selected = position == current_index; + let is_recent = self.recent.iter().any(|r| *r == path); + let (dir, name) = split_path(&path); + + h_flex() + .id(("quick-open-item", position)) + .h(px(32.0)) + .w_full() + .mx_2() + .px_2p5() + .items_center() + .gap_2() + .rounded_md() + .cursor_pointer() + .when(is_selected, |row| { + row.bg(ThemeColors::selected()) + }) + .when(!is_selected, |row| { + row.hover(|h| h.bg(ThemeColors::accent())) + }) + .child( + Icon::new(IconName::FileText) + .size(px(14.0)) + .text_color(if is_selected { + ThemeColors::primary() + } else { + ThemeColors::muted_foreground() + }), + ) + .child( + div() + .flex_1() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(ThemeColors::foreground()) + .child(name), + ) + .when(!dir.is_empty(), |row| { + row.child( + div() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child(dir), + ) + }) + .when(is_recent, |row| { + row.child( + Icon::new(IconName::Clock) + .size(px(13.0)) + .text_color( + ThemeColors::subtle_foreground(), + ), + ) + }) + .on_click(cx.listener( + move |this, _event, _window, cx| { + this.open_at(position, cx); + }, + )) + .into_any_element() + } + } + })), + ) + .child( + // 3. 底部指引栏:左侧结果数、右侧操作提示 + h_flex() + .h(px(34.0)) + .w_full() + .items_center() + .justify_between() + .px_4() + .bg(ThemeColors::background()) + .border_t_1() + .border_color(ThemeColors::border()) + .child( + div() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child(format!("{} results", total)), + ) + .child( + div() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child("↑↓ Navigate · ↵ Open · Esc Close"), + ), + ), + ) + } +} + +/// 把路径拆成 `(目录, 文件名)`,无目录时目录为空串。 +fn split_path(path: &str) -> (String, String) { + match path.rsplit_once('/') { + Some((dir, name)) => (dir.to_string(), name.to_string()), + None => (String::new(), path.to_string()), + } +} + +/// 模式徽标。 +fn mode_badge(label: &str) -> AnyElement { + div() + .px_2() + .py(px(2.0)) + .rounded_md() + .bg(ThemeColors::accent()) + .border_1() + .border_color(ThemeColors::border()) + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(ThemeColors::primary()) + .child(label.to_string()) + .into_any_element() +} + +/// 快捷键/键位徽标。 +fn shortcut_badge(label: &str) -> AnyElement { + div() + .px_1p5() + .py(px(1.0)) + .rounded_sm() + .bg(ThemeColors::accent()) + .border_1() + .border_color(ThemeColors::border()) + .text_xs() + .text_color(ThemeColors::muted_foreground()) + .child(label.to_string()) + .into_any_element() +} diff --git a/linux/src/workbench/settings_dialog.rs b/linux/src/workbench/settings_dialog.rs index 14dde8f32..86b16465f 100644 --- a/linux/src/workbench/settings_dialog.rs +++ b/linux/src/workbench/settings_dialog.rs @@ -1,3 +1,12 @@ +//! 设置对话框:复刻 Tauri 端 `settings-dialog.tsx` 的 12 分类布局。 +//! +//! 面板尺寸对齐 Tauri 的 820×620:header 44、footer 44、左侧导航 190、右侧内容 +//! 滚动区。所有可持久化项都通过 [`crate::settings::update`] 写入 XDG JSON +//! (`$XDG_CONFIG_HOME/lithe/settings.json`);主题相关项写入后额外调用 +//! [`crate::settings::apply_theme`] 让工作台调色板立即生效。少量 Tauri 侧存在但 +//! Linux `Settings` 尚未覆盖的项(Vim、Git 视图、日志级别等)只作为对话框本地 +//! 占位状态,不落盘,待 `Settings` 补齐字段后再接入。 + use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::component::scroll::ScrollableElement as _; @@ -8,72 +17,227 @@ use gpui_kit::{ ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, Window, }; +use crate::settings::{self, Settings}; use crate::theme::ThemeColors; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// 设置分类,顺序与 Tauri `categories` 数组一致。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum SettingsCategory { + #[default] General, + Project, + Run, Editor, - Git, + Keyboard, Terminal, + Lsp, + Ai, + AiCommit, + Git, + Logs, + Updates, +} + +impl SettingsCategory { + /// 全部分类,供左侧导航按固定顺序渲染。 + pub const ALL: [SettingsCategory; 12] = [ + SettingsCategory::General, + SettingsCategory::Project, + SettingsCategory::Run, + SettingsCategory::Editor, + SettingsCategory::Keyboard, + SettingsCategory::Terminal, + SettingsCategory::Lsp, + SettingsCategory::Ai, + SettingsCategory::AiCommit, + SettingsCategory::Git, + SettingsCategory::Logs, + SettingsCategory::Updates, + ]; + + /// 持久化用的 id,与 Tauri `MacSettingsCategory` 字符串对应。 + pub fn id(self) -> &'static str { + match self { + SettingsCategory::General => "general", + SettingsCategory::Project => "project", + SettingsCategory::Run => "run", + SettingsCategory::Editor => "editor", + SettingsCategory::Keyboard => "keyboard", + SettingsCategory::Terminal => "terminal", + SettingsCategory::Lsp => "lsp", + SettingsCategory::Ai => "ai", + SettingsCategory::AiCommit => "ai-commit", + SettingsCategory::Git => "git", + SettingsCategory::Logs => "logs", + SettingsCategory::Updates => "updates", + } + } + + /// 中文标题,展示在导航项与内容区标题。 + pub fn title(self) -> &'static str { + match self { + SettingsCategory::General => "通用", + SettingsCategory::Project => "项目", + SettingsCategory::Run => "运行", + SettingsCategory::Editor => "编辑器", + SettingsCategory::Keyboard => "快捷键", + SettingsCategory::Terminal => "终端", + SettingsCategory::Lsp => "语言服务", + SettingsCategory::Ai => "AI 助手", + SettingsCategory::AiCommit => "AI 提交", + SettingsCategory::Git => "版本控制", + SettingsCategory::Logs => "日志", + SettingsCategory::Updates => "更新", + } + } + + /// 导航图标;只使用 `IconName` 中确定存在的变体。 + pub fn icon(self) -> IconName { + match self { + SettingsCategory::General => IconName::SlidersHorizontal, + SettingsCategory::Project => IconName::Folder, + SettingsCategory::Run => IconName::Play, + SettingsCategory::Editor => IconName::Code, + SettingsCategory::Keyboard => IconName::Keyboard, + SettingsCategory::Terminal => IconName::Terminal, + SettingsCategory::Lsp => IconName::Database, + SettingsCategory::Ai => IconName::Bot, + SettingsCategory::AiCommit => IconName::Bot, + SettingsCategory::Git => IconName::GitBranch, + SettingsCategory::Logs => IconName::FileText, + SettingsCategory::Updates => IconName::RotateCw, + } + } + + /// 由持久化的 `lastSettingsTab` 反解分类;`language` 归到 LSP,未知值回退通用。 + pub fn from_id(id: &str) -> Self { + match id { + "general" => SettingsCategory::General, + "project" => SettingsCategory::Project, + "run" => SettingsCategory::Run, + "editor" => SettingsCategory::Editor, + "keyboard" => SettingsCategory::Keyboard, + "terminal" => SettingsCategory::Terminal, + "lsp" | "language" => SettingsCategory::Lsp, + "ai" => SettingsCategory::Ai, + "ai-commit" => SettingsCategory::AiCommit, + "git" => SettingsCategory::Git, + "logs" => SettingsCategory::Logs, + "updates" => SettingsCategory::Updates, + _ => SettingsCategory::General, + } + } } +/// 对话框对外事件:关闭,以及设置已变更(宿主可据此同步其它视图)。 #[derive(Debug, Clone)] pub enum SettingsEvent { Close, + Changed, } -/// 居中设置面板(宽 640px,高 440px) +/// 居中设置面板(宽 820px,高 620px)。 pub struct SettingsDialog { + /// 当前激活分类 pub active_category: SettingsCategory, - // General 设置 - pub theme_name: String, - pub ui_scale: String, - pub language: String, - // Editor 设置 - pub font_size: usize, - pub tab_size: usize, - pub show_line_numbers: bool, - pub word_wrap: bool, - // Git 设置 - pub git_auto_fetch: bool, - pub git_format_on_commit: bool, - pub git_gpg_sign: bool, - // Terminal 设置 - pub default_shell: String, - pub scrollback_lines: usize, + /// 当前工作区根路径,Project 分类只读展示 + workspace_root: String, + // 以下字段对应 Tauri 设置但 Linux `Settings` 尚未提供,暂作对话框本地占位。 + open_folders_in_new_window: bool, + keybinding_preset: String, + vim_mode: bool, + vim_relative_line_numbers: bool, + terminal_cursor_style: String, + lsp_auto_completion: bool, + lsp_parameter_hints: bool, + auto_refresh_git_status: bool, + show_untracked_files: bool, + show_staged_first: bool, + git_default_diff_view: String, + enable_inline_git_blame: bool, + log_level: String, } impl EventEmitter for SettingsDialog {} impl SettingsDialog { - pub fn new() -> Self { + /// 从持久化设置读取上次打开的分类;同时缓存工作区路径供 Project 分类展示。 + pub fn new(cx: &mut Context) -> Self { + let active_category = SettingsCategory::from_id(&settings::get(cx).last_settings_tab); + let workspace_root = std::env::current_dir() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + Self { - active_category: SettingsCategory::General, - theme_name: "Dark Default".to_string(), - ui_scale: "100%".to_string(), - language: "English".to_string(), - font_size: 14, - tab_size: 4, - show_line_numbers: true, - word_wrap: false, - git_auto_fetch: true, - git_format_on_commit: true, - git_gpg_sign: false, - default_shell: std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()), - scrollback_lines: 1000, + active_category, + workspace_root, + open_folders_in_new_window: true, + keybinding_preset: "none".to_string(), + vim_mode: false, + vim_relative_line_numbers: false, + terminal_cursor_style: "bar".to_string(), + lsp_auto_completion: true, + lsp_parameter_hints: true, + auto_refresh_git_status: true, + show_untracked_files: true, + show_staged_first: true, + git_default_diff_view: "unified".to_string(), + enable_inline_git_blame: true, + log_level: "info".to_string(), } } + /// 切换分类并把 `lastSettingsTab` 写回磁盘,保证下次打开停在同一页。 pub fn set_category(&mut self, cat: SettingsCategory, cx: &mut Context) { self.active_category = cat; + settings::update(cx, |s| s.last_settings_tab = cat.id().to_string()); + cx.notify(); + } + + /// 打开对话框时按持久化的 `lastSettingsTab` 重置分类。 + pub fn open(&mut self, cx: &mut Context) { + self.active_category = SettingsCategory::from_id(&settings::get(cx).last_settings_tab); + cx.notify(); + } + + /// 恢复占位项的本地默认值(不涉及持久化)。 + fn reset_placeholders(&mut self) { + self.open_folders_in_new_window = true; + self.keybinding_preset = "none".to_string(); + self.vim_mode = false; + self.vim_relative_line_numbers = false; + self.terminal_cursor_style = "bar".to_string(); + self.lsp_auto_completion = true; + self.lsp_parameter_hints = true; + self.auto_refresh_git_status = true; + self.show_untracked_files = true; + self.show_staged_first = true; + self.git_default_diff_view = "unified".to_string(); + self.enable_inline_git_blame = true; + self.log_level = "info".to_string(); + } + + /// 写入设置、广播 `Changed` 并刷新视图。 + fn commit(&self, cx: &mut Context, mutate: impl FnOnce(&mut Settings)) { + settings::update(cx, mutate); + cx.emit(SettingsEvent::Changed); + cx.notify(); + } + + /// 写入主题相关设置后,立即把解析后的主题应用到工作台调色板。 + fn commit_theme(&self, cx: &mut Context, mutate: impl FnOnce(&mut Settings)) { + settings::update(cx, mutate); + let theme_id = settings::resolved_theme_id(settings::get(cx), false); + settings::apply_theme(&theme_id); + cx.emit(SettingsEvent::Changed); cx.notify(); } } impl Render for SettingsDialog { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - // 全屏半透明遮罩 + // 全屏半透明遮罩:点击遮罩关闭,点击卡片不冒泡。 div() .id("settings-dialog-backdrop") .absolute() @@ -89,12 +253,11 @@ impl Render for SettingsDialog { }), ) .child( - // 居中模态面板:宽 640px,高 440px v_flex() .id("settings-dialog-card") - .w(px(640.0)) - .h(px(440.0)) - .bg(ThemeColors::bg_sidebar()) + .w(px(820.0)) + .h(px(620.0)) + .bg(ThemeColors::surface()) .border_1() .border_color(ThemeColors::border()) .rounded_lg() @@ -106,17 +269,18 @@ impl Render for SettingsDialog { // 阻止向遮罩冒泡 }), ) + // 1. 头部:标题、搜索占位、关闭按钮 .child( - // 1. 头部标题栏:带 IconName::Settings 标题与 IconName::Close 关闭按钮 h_flex() - .h(px(42.0)) + .h(px(44.0)) .w_full() .items_center() .justify_between() - .px_4() + .gap_3() + .px_3() .border_b_1() .border_color(ThemeColors::border()) - .bg(ThemeColors::bg_titlebar()) + .bg(ThemeColors::surface()) .child( h_flex() .items_center() @@ -124,18 +288,42 @@ impl Render for SettingsDialog { .child( Icon::new(IconName::Settings) .size(px(16.0)) - .text_color(ThemeColors::accent_blue()), + .text_color(ThemeColors::primary()), ) .child( div() .text_sm() .font_weight(FontWeight::BOLD) - .text_color(ThemeColors::text_primary()) + .text_color(ThemeColors::foreground()) .child("Settings"), ), ) .child( - Button::new("close-settings-btn") + // 搜索输入框占位:仅展示,不接输入。 + h_flex() + .w(px(220.0)) + .h(px(28.0)) + .items_center() + .gap_2() + .px_2p5() + .rounded_sm() + .border_1() + .border_color(ThemeColors::border()) + .bg(ThemeColors::background()) + .child( + Icon::new(IconName::Search) + .size(px(13.0)) + .text_color(ThemeColors::subtle_foreground()), + ) + .child( + div() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child("搜索设置"), + ), + ) + .child( + Button::new("settings-close") .small() .ghost() .icon(IconName::Close) @@ -145,550 +333,1248 @@ impl Render for SettingsDialog { })), ), ) + // 2. 主体:左侧分类导航 + 右侧滚动内容 .child( - // 2. 主体区(左侧分类导航 + 右侧设置内容) h_flex() .flex_1() .w_full() + .min_h_0() + .overflow_hidden() .child( - // 左侧分类导航 v_flex() - .w(px(160.0)) + .w(px(190.0)) .h_full() - .bg(ThemeColors::bg_sidebar()) + .flex_shrink_0() + .gap_0p5() + .p_2() .border_r_1() .border_color(ThemeColors::border()) - .py_2() - .gap_1() - .child(self.render_nav_item( - "nav-general", - IconName::SlidersHorizontal, - "General", - self.active_category == SettingsCategory::General, - SettingsCategory::General, - cx, - )) - .child(self.render_nav_item( - "nav-editor", - IconName::FileCode, - "Editor", - self.active_category == SettingsCategory::Editor, - SettingsCategory::Editor, - cx, - )) - .child(self.render_nav_item( - "nav-git", - IconName::GitBranch, - "Git", - self.active_category == SettingsCategory::Git, - SettingsCategory::Git, - cx, - )) - .child(self.render_nav_item( - "nav-terminal", - IconName::Terminal, - "Terminal", - self.active_category == SettingsCategory::Terminal, - SettingsCategory::Terminal, - cx, - )), + .bg(ThemeColors::surface()) + .children( + SettingsCategory::ALL + .iter() + .map(|cat| self.render_nav_item(*cat, cx)), + ), ) .child( - // 右侧设置内容详情 div() .flex_1() .h_full() - .bg(ThemeColors::bg_editor()) + .min_w_0() + .bg(ThemeColors::background()) .p_4() .overflow_y_scrollbar() - .child(match self.active_category { - SettingsCategory::General => { - self.render_general_settings(cx).into_any_element() - } - SettingsCategory::Editor => { - self.render_editor_settings(cx).into_any_element() - } - SettingsCategory::Git => { - self.render_git_settings(cx).into_any_element() - } - SettingsCategory::Terminal => { - self.render_terminal_settings(cx).into_any_element() - } - }), + .child( + v_flex() + .w_full() + .gap_4() + .child( + div() + .text_lg() + .font_weight(FontWeight::SEMIBOLD) + .text_color(ThemeColors::foreground()) + .child(self.active_category.title()), + ) + .child(match self.active_category { + SettingsCategory::General => self + .render_general_content(cx) + .into_any_element(), + SettingsCategory::Project => self + .render_project_content(cx) + .into_any_element(), + SettingsCategory::Run => { + self.render_run_content(cx).into_any_element() + } + SettingsCategory::Editor => self + .render_editor_content(cx) + .into_any_element(), + SettingsCategory::Keyboard => self + .render_keyboard_content(cx) + .into_any_element(), + SettingsCategory::Terminal => self + .render_terminal_content(cx) + .into_any_element(), + SettingsCategory::Lsp => { + self.render_lsp_content(cx).into_any_element() + } + SettingsCategory::Ai => { + self.render_ai_content(cx).into_any_element() + } + SettingsCategory::AiCommit => self + .render_ai_commit_content(cx) + .into_any_element(), + SettingsCategory::Git => { + self.render_git_content(cx).into_any_element() + } + SettingsCategory::Logs => { + self.render_logs_content(cx).into_any_element() + } + SettingsCategory::Updates => self + .render_updates_content(cx) + .into_any_element(), + }), + ), + ), + ) + // 3. 底部:左侧恢复默认,右侧主按钮关闭 + .child( + h_flex() + .h(px(44.0)) + .w_full() + .items_center() + .justify_between() + .px_3() + .border_t_1() + .border_color(ThemeColors::border()) + .bg(ThemeColors::surface()) + .child( + Button::new("settings-restore-defaults") + .small() + .ghost() + .label("Restore Defaults") + .on_click(cx.listener(|this, _event, _window, cx| { + // 恢复默认:整体替换为 Settings::default 并落盘。 + settings::update(cx, |s| *s = Settings::default()); + let theme_id = + settings::resolved_theme_id(settings::get(cx), false); + settings::apply_theme(&theme_id); + this.active_category = SettingsCategory::from_id( + &settings::get(cx).last_settings_tab, + ); + this.reset_placeholders(); + cx.emit(SettingsEvent::Changed); + cx.notify(); + })), + ) + .child( + Button::new("settings-done") + .small() + .primary() + .label("Done") + .on_click(cx.listener(|_this, _event, _window, cx| { + cx.emit(SettingsEvent::Close); + })), ), ), ) } } +/// 通用渲染块:分组、行、控件与分类内容。 impl SettingsDialog { - fn render_nav_item( - &self, - id: &'static str, - icon: IconName, - label: &'static str, - is_active: bool, - cat: SettingsCategory, - cx: &mut Context, - ) -> impl IntoElement { + /// 左侧导航项:active 使用 accent 背景 + 左侧 primary 竖条 + 加粗前景。 + fn render_nav_item(&self, cat: SettingsCategory, cx: &mut Context) -> impl IntoElement { + let is_active = self.active_category == cat; h_flex() - .id(id) + .id(cat.id()) .h(px(32.0)) .w_full() .items_center() .gap_2p5() - .px_3() + .px_2p5() + .rounded_sm() + .border_l_2() .cursor_pointer() .text_xs() .when(is_active, |row| { - row.bg(ThemeColors::subtle_selection()) - .border_l_2() - .border_color(ThemeColors::accent_blue()) - .text_color(ThemeColors::text_primary()) + row.bg(ThemeColors::accent()) + .border_color(ThemeColors::primary()) + .text_color(ThemeColors::foreground()) .font_weight(FontWeight::BOLD) }) .when(!is_active, |row| { - row.text_color(ThemeColors::text_muted()).hover(|h| { - h.bg(ThemeColors::bg_tab_hover()) - .text_color(ThemeColors::text_primary()) - }) + row.border_color(rgba(0x00000000)) + .text_color(ThemeColors::subtle_foreground()) + .hover(|h| { + h.bg(ThemeColors::accent()) + .text_color(ThemeColors::foreground()) + }) }) .child( - Icon::new(icon) + Icon::new(cat.icon()) .size(px(14.0)) .text_color(if is_active { - ThemeColors::accent_blue() + ThemeColors::foreground() } else { - ThemeColors::text_muted() + ThemeColors::subtle_foreground() }), ) - .child(label) + .child(cat.title()) .on_click(cx.listener(move |this, _event, _window, cx| { this.set_category(cat, cx); })) } - fn render_general_settings(&self, cx: &mut Context) -> impl IntoElement { + /// 分组容器:标题条 + 内容区,对应 Tauri `SettingsGroup`。 + fn render_group(&self, title: &'static str, content: impl IntoElement) -> impl IntoElement { v_flex() - .size_full() - .gap_4() - .child(self.render_section_title("Appearance & General")) - // 外观主题 - .child(self.render_setting_row( - "Theme", - "Color theme for editor and workbench interface", - h_flex() - .gap_1p5() - .child(self.render_choice_pill( - "theme-dark", - "Dark Default", - self.theme_name == "Dark Default", - cx.listener(|this, _event, _window, cx| { - this.theme_name = "Dark Default".to_string(); - cx.notify(); - }), - )) - .child(self.render_choice_pill( - "theme-contrast", - "High Contrast", - self.theme_name == "High Contrast", - cx.listener(|this, _event, _window, cx| { - this.theme_name = "High Contrast".to_string(); - cx.notify(); - }), - )), - )) - // 界面缩放 - .child(self.render_setting_row( - "UI Scale", - "Display scaling factor for the entire workspace", - h_flex() - .gap_1p5() - .child(self.render_choice_pill( - "scale-100", - "100%", - self.ui_scale == "100%", - cx.listener(|this, _event, _window, cx| { - this.ui_scale = "100%".to_string(); - cx.notify(); - }), - )) - .child(self.render_choice_pill( - "scale-125", - "125%", - self.ui_scale == "125%", - cx.listener(|this, _event, _window, cx| { - this.ui_scale = "125%".to_string(); - cx.notify(); - }), - )), - )) - // 语言 - .child(self.render_setting_row( - "Language", - "Application display and localization language", - h_flex() - .gap_1p5() - .child(self.render_choice_pill( - "lang-en", - "English", - self.language == "English", - cx.listener(|this, _event, _window, cx| { - this.language = "English".to_string(); - cx.notify(); - }), - )) - .child(self.render_choice_pill( - "lang-zh", - "简体中文", - self.language == "简体中文", - cx.listener(|this, _event, _window, cx| { - this.language = "简体中文".to_string(); - cx.notify(); - }), - )), - )) - } - - fn render_editor_settings(&self, cx: &mut Context) -> impl IntoElement { - v_flex() - .size_full() - .gap_4() - .child(self.render_section_title("Editor Settings")) - // 字体大小 - .child(self.render_setting_row( - "Font Size", - "Controls editor text font size in pixels", - h_flex() - .gap_1p5() - .child(self.render_choice_pill( - "font-13", - "13px", - self.font_size == 13, - cx.listener(|this, _event, _window, cx| { - this.font_size = 13; - cx.notify(); - }), - )) - .child(self.render_choice_pill( - "font-14", - "14px", - self.font_size == 14, - cx.listener(|this, _event, _window, cx| { - this.font_size = 14; - cx.notify(); - }), - )) - .child(self.render_choice_pill( - "font-16", - "16px", - self.font_size == 16, - cx.listener(|this, _event, _window, cx| { - this.font_size = 16; - cx.notify(); - }), - )), - )) - // Tab 缩进 - .child(self.render_setting_row( - "Tab Size", - "Number of spaces per indentation level", - h_flex() - .gap_1p5() - .child(self.render_choice_pill( - "tab-2", - "2 Spaces", - self.tab_size == 2, - cx.listener(|this, _event, _window, cx| { - this.tab_size = 2; - cx.notify(); - }), - )) - .child(self.render_choice_pill( - "tab-4", - "4 Spaces", - self.tab_size == 4, - cx.listener(|this, _event, _window, cx| { - this.tab_size = 4; - cx.notify(); - }), - )), - )) - // 显示行号 - .child(self.render_setting_row( - "Line Numbers", - "Display line number gutter in code editor", - h_flex() - .gap_1p5() - .child(self.render_choice_pill( - "ln-show", - "Show", - self.show_line_numbers, - cx.listener(|this, _event, _window, cx| { - this.show_line_numbers = true; - cx.notify(); - }), - )) - .child(self.render_choice_pill( - "ln-hide", - "Hide", - !self.show_line_numbers, - cx.listener(|this, _event, _window, cx| { - this.show_line_numbers = false; - cx.notify(); - }), - )), - )) - // 自动换行 - .child(self.render_setting_row( - "Word Wrap", - "Wrap long lines to fit the editor viewport width", - h_flex() - .gap_1p5() - .child(self.render_choice_pill( - "wrap-on", - "On", - self.word_wrap, - cx.listener(|this, _event, _window, cx| { - this.word_wrap = true; - cx.notify(); - }), - )) - .child(self.render_choice_pill( - "wrap-off", - "Off", - !self.word_wrap, - cx.listener(|this, _event, _window, cx| { - this.word_wrap = false; - cx.notify(); - }), - )), - )) - } - - fn render_git_settings(&self, cx: &mut Context) -> impl IntoElement { - v_flex() - .size_full() - .gap_4() - .child(self.render_section_title("Version Control & Git")) - // 自动拉取 - .child(self.render_setting_row( - "Auto Fetch", - "Automatically fetch remote branch status periodically", - h_flex() - .gap_1p5() - .child(self.render_choice_pill( - "fetch-on", - "Enabled", - self.git_auto_fetch, - cx.listener(|this, _event, _window, cx| { - this.git_auto_fetch = true; - cx.notify(); - }), - )) - .child(self.render_choice_pill( - "fetch-off", - "Disabled", - !self.git_auto_fetch, - cx.listener(|this, _event, _window, cx| { - this.git_auto_fetch = false; - cx.notify(); - }), - )), - )) - // Commit 前格式化 - .child(self.render_setting_row( - "Format on Commit", - "Run code formatter on staged changes before committing", - h_flex() - .gap_1p5() - .child(self.render_choice_pill( - "format-on", - "Enabled", - self.git_format_on_commit, - cx.listener(|this, _event, _window, cx| { - this.git_format_on_commit = true; - cx.notify(); - }), - )) - .child(self.render_choice_pill( - "format-off", - "Disabled", - !self.git_format_on_commit, - cx.listener(|this, _event, _window, cx| { - this.git_format_on_commit = false; - cx.notify(); - }), - )), - )) - // GPG 签名 - .child(self.render_setting_row( - "GPG Commit Signing", - "Sign commits with user default GPG signing key", - h_flex() - .gap_1p5() - .child(self.render_choice_pill( - "gpg-on", - "Enabled", - self.git_gpg_sign, - cx.listener(|this, _event, _window, cx| { - this.git_gpg_sign = true; - cx.notify(); - }), - )) - .child(self.render_choice_pill( - "gpg-off", - "Disabled", - !self.git_gpg_sign, - cx.listener(|this, _event, _window, cx| { - this.git_gpg_sign = false; - cx.notify(); - }), - )), - )) - } - - fn render_terminal_settings(&self, cx: &mut Context) -> impl IntoElement { - v_flex() - .size_full() - .gap_4() - .child(self.render_section_title("Terminal & PTY")) - // 默认终端 - .child(self.render_setting_row( - "Default Shell", - "System shell binary used for bottom terminal panel", - h_flex() - .gap_1p5() - .child(self.render_choice_pill( - "shell-bash", - "/bin/bash", - self.default_shell.contains("bash"), - cx.listener(|this, _event, _window, cx| { - this.default_shell = "/bin/bash".to_string(); - cx.notify(); - }), - )) - .child(self.render_choice_pill( - "shell-zsh", - "/bin/zsh", - self.default_shell.contains("zsh"), - cx.listener(|this, _event, _window, cx| { - this.default_shell = "/bin/zsh".to_string(); - cx.notify(); - }), - )) - .child(self.render_choice_pill( - "shell-sh", - "/bin/sh", - self.default_shell == "/bin/sh", - cx.listener(|this, _event, _window, cx| { - this.default_shell = "/bin/sh".to_string(); - cx.notify(); - }), - )), - )) - // 滚动缓冲区大小 - .child(self.render_setting_row( - "Scrollback Buffer", - "Maximum number of terminal output lines kept in memory", - h_flex() - .gap_1p5() - .child(self.render_choice_pill( - "scroll-1000", - "1000 lines", - self.scrollback_lines == 1000, - cx.listener(|this, _event, _window, cx| { - this.scrollback_lines = 1000; - cx.notify(); - }), - )) - .child(self.render_choice_pill( - "scroll-5000", - "5000 lines", - self.scrollback_lines == 5000, - cx.listener(|this, _event, _window, cx| { - this.scrollback_lines = 5000; - cx.notify(); - }), - )), - )) - } - - fn render_section_title(&self, title: &'static str) -> impl IntoElement { - div() - .text_sm() - .font_weight(FontWeight::BOLD) - .text_color(ThemeColors::text_primary()) - .pb_1() - .border_b_1() + .w_full() + .rounded_md() + .border_1() .border_color(ThemeColors::border()) - .child(title) + .bg(ThemeColors::surface()) + .overflow_hidden() + .child( + div() + .px_3() + .py_2() + .border_b_1() + .border_color(ThemeColors::border()) + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(ThemeColors::subtle_foreground()) + .child(title), + ) + .child(v_flex().w_full().gap_3().p_3().child(content)) } - fn render_setting_row( + /// 设置行:左侧标签与描述,右侧控件。 + fn render_row( &self, label: &'static str, - desc: &'static str, + description: Option<&'static str>, control: impl IntoElement, ) -> impl IntoElement { + let mut info = v_flex().flex_1().min_w_0().child( + div() + .text_xs() + .text_color(ThemeColors::foreground()) + .child(label), + ); + if let Some(desc) = description { + info = info.child( + div() + .mt_1() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child(desc), + ); + } h_flex() .w_full() + .min_h(px(32.0)) .items_center() - .justify_between() .gap_4() - .child( - v_flex() - .flex_1() - .gap_0p5() - .child( - div() - .text_xs() - .font_weight(FontWeight::MEDIUM) - .text_color(ThemeColors::text_primary()) - .child(label), - ) - .child( - div() - .text_xs() - .text_color(ThemeColors::text_muted()) - .child(desc), - ), - ) - .child(control) + .child(info) + .child(div().flex_shrink_0().child(control)) } - fn render_choice_pill( + /// 开关控件:可点击药丸,On/Off 两种状态。 + fn render_toggle( + &self, + id: &'static str, + value: bool, + cx: &mut Context, + toggle: impl Fn(&mut Self, &mut Context) + 'static, + ) -> impl IntoElement { + h_flex() + .id(id) + .items_center() + .gap_1p5() + .px_2p5() + .py_1() + .rounded_sm() + .border_1() + .cursor_pointer() + .text_xs() + .when(value, |el| { + el.bg(ThemeColors::primary()) + .border_color(ThemeColors::primary()) + .text_color(ThemeColors::foreground()) + }) + .when(!value, |el| { + el.bg(ThemeColors::background()) + .border_color(ThemeColors::border()) + .text_color(ThemeColors::subtle_foreground()) + .hover(|h| { + h.bg(ThemeColors::accent()) + .text_color(ThemeColors::foreground()) + }) + }) + .child(div().w(px(8.0)).h(px(8.0)).rounded_full().bg(if value { + ThemeColors::foreground() + } else { + ThemeColors::subtle_foreground() + })) + .child(if value { "On" } else { "Off" }) + .on_click(cx.listener(move |this, _event, _window, cx| toggle(this, cx))) + } + + /// 单选项:分段药丸,选中态用 primary 背景。 + fn render_segment( &self, id: &'static str, label: &'static str, - is_selected: bool, - click_handler: impl Fn(&gpui_kit::ClickEvent, &mut Window, &mut gpui_kit::App) + 'static, + selected: bool, + cx: &mut Context, + on_select: impl Fn(&mut Self, &mut Context) + 'static, ) -> impl IntoElement { h_flex() .id(id) .items_center() + .justify_center() .px_2p5() - .py(px(3.0)) - .rounded(px(4.0)) + .py_1() + .rounded_sm() .border_1() .cursor_pointer() .text_xs() - .when(is_selected, |pill| { - pill.bg(ThemeColors::accent_blue()) - .border_color(ThemeColors::accent_blue()) - .text_color(ThemeColors::text_primary()) - .font_weight(FontWeight::BOLD) + .when(selected, |el| { + el.bg(ThemeColors::primary()) + .border_color(ThemeColors::primary()) + .text_color(ThemeColors::foreground()) + .font_weight(FontWeight::MEDIUM) }) - .when(!is_selected, |pill| { - pill.bg(ThemeColors::bg_tab_bar()) + .when(!selected, |el| { + el.bg(ThemeColors::background()) .border_color(ThemeColors::border()) - .text_color(ThemeColors::text_muted()) + .text_color(ThemeColors::subtle_foreground()) .hover(|h| { - h.bg(ThemeColors::bg_tab_hover()) - .text_color(ThemeColors::text_primary()) + h.bg(ThemeColors::accent()) + .text_color(ThemeColors::foreground()) }) }) .child(label) - .on_click(click_handler) + .on_click(cx.listener(move |this, _event, _window, cx| on_select(this, cx))) + } + + /// 数值步进器:减号 + 当前值 + 加号。 + fn render_stepper( + &self, + dec_id: &'static str, + inc_id: &'static str, + value: String, + cx: &mut Context, + on_dec: impl Fn(&mut Self, &mut Context) + 'static, + on_inc: impl Fn(&mut Self, &mut Context) + 'static, + ) -> impl IntoElement { + h_flex() + .items_center() + .rounded_sm() + .border_1() + .border_color(ThemeColors::border()) + .bg(ThemeColors::background()) + .overflow_hidden() + .child( + Button::new(dec_id) + .small() + .ghost() + .icon(IconName::Minus) + .on_click(cx.listener(move |this, _event, _window, cx| on_dec(this, cx))), + ) + .child( + div() + .w(px(56.0)) + .text_center() + .text_xs() + .text_color(ThemeColors::foreground()) + .child(value), + ) + .child( + Button::new(inc_id) + .small() + .ghost() + .icon(IconName::Plus) + .on_click(cx.listener(move |this, _event, _window, cx| on_inc(this, cx))), + ) + } + + /// 只读文本值。 + fn render_value(&self, value: String) -> impl IntoElement { + div() + .px_2p5() + .py_1() + .rounded_sm() + .border_1() + .border_color(ThemeColors::border()) + .bg(ThemeColors::background()) + .text_xs() + .text_color(ThemeColors::muted_foreground()) + .child(value) + } + + /// 键位徽标:Keyboard 分类展示用,点击不触发录制。 + fn render_key_badge(&self, label: &'static str) -> impl IntoElement { + div() + .px_2() + .py_0p5() + .rounded_sm() + .border_1() + .border_color(ThemeColors::border()) + .bg(ThemeColors::background()) + .text_xs() + .text_color(ThemeColors::muted_foreground()) + .child(label) + } + + /// 占位空态:图标 + 说明。 + fn render_empty_state(&self, icon: IconName, text: &'static str) -> impl IntoElement { + v_flex() + .w_full() + .items_center() + .justify_center() + .gap_2() + .py_8() + .child( + Icon::new(icon) + .size(px(28.0)) + .text_color(ThemeColors::subtle_foreground()), + ) + .child( + div() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child(text), + ) + } + + /// 分组内的说明文本。 + fn render_note(&self, text: &'static str) -> impl IntoElement { + div() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child(text) + } +} + +/// 各分类内容渲染。 +impl SettingsDialog { + fn render_general_content(&self, cx: &mut Context) -> impl IntoElement { + let s = settings::get(cx).clone(); + v_flex() + .w_full() + .gap_4() + .child( + self.render_group( + "外观", + v_flex() + .w_full() + .gap_3() + .child( + self.render_row( + "主题", + Some("编辑器与工作台的配色方案"), + h_flex() + .gap_1p5() + .child(self.render_segment( + "theme-dark", + "深色", + s.theme == "lithe-dark", + cx, + |this, cx| { + this.commit_theme(cx, |s| { + s.theme = "lithe-dark".to_string() + }) + }, + )) + .child(self.render_segment( + "theme-light", + "浅色", + s.theme == "lithe-light", + cx, + |this, cx| { + this.commit_theme(cx, |s| { + s.theme = "lithe-light".to_string() + }) + }, + )), + ), + ) + .child(self.render_row( + "跟随系统主题", + Some("按系统外观在浅色与深色主题间自动切换"), + self.render_toggle( + "sync-system-theme", + s.sync_system_theme, + cx, + |this, cx| { + this.commit_theme(cx, |s| { + s.sync_system_theme = !s.sync_system_theme + }) + }, + ), + )) + .child( + self.render_row( + "窗口密度", + Some("标题栏与工具栏的紧凑程度"), + h_flex() + .gap_1p5() + .child(self.render_segment( + "chrome-focused", + "聚焦", + s.window_chrome_density == "focused", + cx, + |this, cx| { + this.commit(cx, |s| { + s.window_chrome_density = "focused".to_string() + }) + }, + )) + .child(self.render_segment( + "chrome-comfortable", + "舒适", + s.window_chrome_density == "comfortable", + cx, + |this, cx| { + this.commit(cx, |s| { + s.window_chrome_density = "comfortable".to_string() + }) + }, + )), + ), + ) + .child(self.render_row( + "减少动画", + Some("关闭过渡与动画以降低干扰"), + self.render_toggle("reduce-motion", s.reduce_motion, cx, |this, cx| { + this.commit(cx, |s| s.reduce_motion = !s.reduce_motion) + }), + )) + .child(self.render_row( + "紧凑菜单栏", + None, + self.render_toggle( + "compact-menu-bar", + s.compact_menu_bar, + cx, + |this, cx| { + this.commit(cx, |s| s.compact_menu_bar = !s.compact_menu_bar) + }, + ), + )) + .child(self.render_row( + "显示状态栏", + None, + self.render_toggle( + "show-status-bar", + s.show_status_bar, + cx, + |this, cx| { + this.commit(cx, |s| s.show_status_bar = !s.show_status_bar) + }, + ), + )), + ), + ) + .child( + self.render_group( + "文件与语言", + v_flex() + .w_full() + .gap_3() + .child(self.render_row( + "自动保存", + Some("编辑停止后自动保存当前文件"), + self.render_toggle("auto-save", s.auto_save, cx, |this, cx| { + this.commit(cx, |s| s.auto_save = !s.auto_save) + }), + )) + .child(self.render_row( + "快速打开预览", + Some("快速打开时以预览方式打开文件"), + self.render_toggle( + "quick-open-preview", + s.quick_open_preview, + cx, + |this, cx| { + this.commit(cx, |s| { + s.quick_open_preview = !s.quick_open_preview + }) + }, + ), + )) + .child( + self.render_row( + "显示语言", + None, + h_flex() + .gap_1p5() + .child(self.render_segment( + "lang-en", + "English", + s.display_language == "en-US", + cx, + |this, cx| { + this.commit(cx, |s| { + s.display_language = "en-US".to_string() + }) + }, + )) + .child(self.render_segment( + "lang-zh", + "简体中文", + s.display_language == "zh-CN", + cx, + |this, cx| { + this.commit(cx, |s| { + s.display_language = "zh-CN".to_string() + }) + }, + )), + ), + ), + ), + ) + } + + fn render_project_content(&self, cx: &mut Context) -> impl IntoElement { + v_flex() + .w_full() + .gap_4() + .child(self.render_group( + "当前工作区", + v_flex().w_full().gap_3().child(self.render_row( + "工作区路径", + Some("当前打开项目的根目录(只读)"), + self.render_value(self.workspace_root.clone()), + )), + )) + .child(self.render_group( + "项目打开方式", + v_flex().w_full().gap_3().child(self.render_row( + "在新窗口打开文件夹", + Some("占位项:Linux Settings 尚未提供该字段,暂不落盘"), + self.render_toggle( + "open-folders-in-new-window", + self.open_folders_in_new_window, + cx, + |this, cx| { + this.open_folders_in_new_window = !this.open_folders_in_new_window; + cx.notify(); + }, + ), + )), + )) + } + + fn render_run_content(&self, _cx: &mut Context) -> impl IntoElement { + v_flex().w_full().gap_4().child(self.render_group( + "运行配置", + v_flex().w_full().gap_3().child( + self.render_empty_state(IconName::Play, "暂无运行配置,创建后可在此管理启动项"), + ), + )) + } + + fn render_editor_content(&self, cx: &mut Context) -> impl IntoElement { + let s = settings::get(cx).clone(); + v_flex() + .w_full() + .gap_4() + .child( + self.render_group( + "显示", + v_flex() + .w_full() + .gap_3() + .child(self.render_row( + "字号", + Some("编辑器代码字号(11–20 px)"), + self.render_stepper( + "font-dec", + "font-inc", + format!("{} px", s.font_size as i32), + cx, + |this, cx| { + this.commit(cx, |s| s.font_size = (s.font_size - 1.0).max(11.0)) + }, + |this, cx| { + this.commit(cx, |s| s.font_size = (s.font_size + 1.0).min(20.0)) + }, + ), + )) + .child(self.render_row( + "显示行号", + None, + self.render_toggle( + "editor-line-numbers", + s.line_numbers, + cx, + |this, cx| this.commit(cx, |s| s.line_numbers = !s.line_numbers), + ), + )) + .child(self.render_row( + "自动换行", + None, + self.render_toggle("editor-word-wrap", s.word_wrap, cx, |this, cx| { + this.commit(cx, |s| s.word_wrap = !s.word_wrap) + }), + )) + .child(self.render_row( + "显示缩略图", + None, + self.render_toggle("editor-minimap", s.show_minimap, cx, |this, cx| { + this.commit(cx, |s| s.show_minimap = !s.show_minimap) + }), + )), + ), + ) + .child( + self.render_group( + "标签页", + v_flex() + .w_full() + .gap_3() + .child(self.render_row( + "显示标签图标", + None, + self.render_toggle( + "editor-tab-icons", + s.show_tab_icons, + cx, + |this, cx| { + this.commit(cx, |s| s.show_tab_icons = !s.show_tab_icons) + }, + ), + )) + .child( + self.render_row( + "关闭按钮显示", + Some("标签页关闭按钮的显示时机"), + h_flex() + .gap_1p5() + .child(self.render_segment( + "close-active", + "活动时", + s.tab_close_button_visibility == "active", + cx, + |this, cx| { + this.commit(cx, |s| { + s.tab_close_button_visibility = "active".to_string() + }) + }, + )) + .child(self.render_segment( + "close-hover", + "悬停时", + s.tab_close_button_visibility == "hover", + cx, + |this, cx| { + this.commit(cx, |s| { + s.tab_close_button_visibility = "hover".to_string() + }) + }, + )) + .child(self.render_segment( + "close-always", + "始终", + s.tab_close_button_visibility == "always", + cx, + |this, cx| { + this.commit(cx, |s| { + s.tab_close_button_visibility = "always".to_string() + }) + }, + )), + ), + ), + ), + ) + .child( + self.render_group( + "缩进", + v_flex().w_full().gap_3().child( + self.render_row( + "Tab 宽度", + None, + h_flex() + .gap_1p5() + .child(self.render_segment( + "tab-size-2", + "2 空格", + s.tab_size == 2, + cx, + |this, cx| this.commit(cx, |s| s.tab_size = 2), + )) + .child(self.render_segment( + "tab-size-4", + "4 空格", + s.tab_size == 4, + cx, + |this, cx| this.commit(cx, |s| s.tab_size = 4), + )) + .child(self.render_segment( + "tab-size-8", + "8 空格", + s.tab_size == 8, + cx, + |this, cx| this.commit(cx, |s| s.tab_size = 8), + )), + ), + ), + ), + ) + } + + fn render_keyboard_content(&self, cx: &mut Context) -> impl IntoElement { + v_flex() + .w_full() + .gap_4() + .child( + self.render_group( + "按键映射", + v_flex().w_full().gap_3().child( + self.render_row( + "预设", + Some("占位项:Linux Settings 尚未提供该字段,暂不落盘"), + h_flex() + .gap_1p5() + .child(self.render_segment( + "keymap-none", + "Lithe", + self.keybinding_preset == "none", + cx, + |this, cx| { + this.keybinding_preset = "none".to_string(); + cx.notify(); + }, + )) + .child(self.render_segment( + "keymap-vscode", + "VS Code", + self.keybinding_preset == "vscode", + cx, + |this, cx| { + this.keybinding_preset = "vscode".to_string(); + cx.notify(); + }, + )) + .child(self.render_segment( + "keymap-intellij", + "IntelliJ", + self.keybinding_preset == "intellij", + cx, + |this, cx| { + this.keybinding_preset = "intellij".to_string(); + cx.notify(); + }, + )), + ), + ), + ), + ) + .child( + self.render_group( + "Vim", + v_flex() + .w_full() + .gap_3() + .child(self.render_row( + "启用 Vim 模式", + None, + self.render_toggle("vim-mode", self.vim_mode, cx, |this, cx| { + this.vim_mode = !this.vim_mode; + cx.notify(); + }), + )) + .child(self.render_row( + "相对行号", + None, + self.render_toggle( + "vim-relative-line-numbers", + self.vim_relative_line_numbers, + cx, + |this, cx| { + this.vim_relative_line_numbers = + !this.vim_relative_line_numbers; + cx.notify(); + }, + ), + )), + ), + ) + .child( + self.render_group( + "快捷键", + v_flex() + .w_full() + .gap_3() + .child(self.render_row("保存文件", None, self.render_key_badge("Ctrl S"))) + .child(self.render_row("快速打开", None, self.render_key_badge("Ctrl P"))) + .child(self.render_row( + "全局搜索", + None, + self.render_key_badge("Ctrl Shift F"), + )) + .child(self.render_row("打开设置", None, self.render_key_badge("Ctrl ,"))) + .child(self.render_note("快捷键表为只读占位,暂不支持点击录制。")), + ), + ) + } + + fn render_terminal_content(&self, cx: &mut Context) -> impl IntoElement { + let s = settings::get(cx).clone(); + v_flex() + .w_full() + .gap_4() + .child( + self.render_group( + "回滚缓冲", + v_flex().w_full().gap_3().child( + self.render_row( + "缓冲行数", + Some("终端保留的最大输出行数"), + h_flex() + .gap_1p5() + .child(self.render_segment( + "scrollback-1000", + "1000", + s.terminal_scrollback == 1000, + cx, + |this, cx| this.commit(cx, |s| s.terminal_scrollback = 1000), + )) + .child(self.render_segment( + "scrollback-5000", + "5000", + s.terminal_scrollback == 5000, + cx, + |this, cx| this.commit(cx, |s| s.terminal_scrollback = 5000), + )) + .child(self.render_segment( + "scrollback-10000", + "10000", + s.terminal_scrollback == 10000, + cx, + |this, cx| this.commit(cx, |s| s.terminal_scrollback = 10000), + )), + ), + ), + ), + ) + .child( + self.render_group( + "光标", + v_flex().w_full().gap_3().child( + self.render_row( + "光标样式", + Some("占位项:Linux Settings 尚未提供该字段,暂不落盘"), + h_flex() + .gap_1p5() + .child(self.render_segment( + "cursor-bar", + "竖线", + self.terminal_cursor_style == "bar", + cx, + |this, cx| { + this.terminal_cursor_style = "bar".to_string(); + cx.notify(); + }, + )) + .child(self.render_segment( + "cursor-block", + "方块", + self.terminal_cursor_style == "block", + cx, + |this, cx| { + this.terminal_cursor_style = "block".to_string(); + cx.notify(); + }, + )) + .child(self.render_segment( + "cursor-underline", + "下划线", + self.terminal_cursor_style == "underline", + cx, + |this, cx| { + this.terminal_cursor_style = "underline".to_string(); + cx.notify(); + }, + )), + ), + ), + ), + ) + } + + fn render_lsp_content(&self, cx: &mut Context) -> impl IntoElement { + v_flex() + .w_full() + .gap_4() + .child( + self.render_group( + "语言服务", + v_flex() + .w_full() + .gap_3() + .child(self.render_row( + "自动补全", + Some("占位项:Linux Settings 尚未提供该字段,暂不落盘"), + self.render_toggle( + "lsp-auto-completion", + self.lsp_auto_completion, + cx, + |this, cx| { + this.lsp_auto_completion = !this.lsp_auto_completion; + cx.notify(); + }, + ), + )) + .child(self.render_row( + "参数提示", + None, + self.render_toggle( + "lsp-parameter-hints", + self.lsp_parameter_hints, + cx, + |this, cx| { + this.lsp_parameter_hints = !this.lsp_parameter_hints; + cx.notify(); + }, + ), + )), + ), + ) + .child( + self.render_group( + "已检测到的服务器", + v_flex() + .w_full() + .gap_3() + .child(self.render_row( + "Rust", + None, + self.render_value("rust-analyzer".to_string()), + )) + .child(self.render_row( + "TypeScript", + None, + self.render_value("typescript-language-server".to_string()), + )) + .child(self.render_row( + "Java", + None, + self.render_value("jdtls".to_string()), + )), + ), + ) + } + + fn render_ai_content(&self, _cx: &mut Context) -> impl IntoElement { + v_flex() + .w_full() + .gap_4() + .child( + self.render_group( + "模型", + v_flex() + .w_full() + .gap_3() + .child(self.render_row( + "提供方", + Some("来自默认设置,占位展示"), + self.render_value("anthropic".to_string()), + )) + .child(self.render_row( + "模型", + None, + self.render_value("claude-sonnet-4-6".to_string()), + )), + ), + ) + .child(self.render_group("说明", self.render_note("AI 助手配置将在后续版本接入。"))) + } + + fn render_ai_commit_content(&self, _cx: &mut Context) -> impl IntoElement { + v_flex().w_full().gap_4().child(self.render_group( + "提交信息生成", + v_flex().w_full().gap_3().child( + self.render_empty_state(IconName::Bot, "AI 提交信息生成尚未接入,后续版本提供"), + ), + )) + } + + fn render_git_content(&self, cx: &mut Context) -> impl IntoElement { + v_flex() + .w_full() + .gap_4() + .child( + self.render_group( + "状态", + v_flex() + .w_full() + .gap_3() + .child(self.render_row( + "自动刷新状态", + Some("占位项:Linux Settings 尚未提供该字段,暂不落盘"), + self.render_toggle( + "git-auto-refresh", + self.auto_refresh_git_status, + cx, + |this, cx| { + this.auto_refresh_git_status = !this.auto_refresh_git_status; + cx.notify(); + }, + ), + )) + .child(self.render_row( + "显示未跟踪文件", + None, + self.render_toggle( + "git-show-untracked", + self.show_untracked_files, + cx, + |this, cx| { + this.show_untracked_files = !this.show_untracked_files; + cx.notify(); + }, + ), + )) + .child(self.render_row( + "暂存区优先", + None, + self.render_toggle( + "git-show-staged-first", + self.show_staged_first, + cx, + |this, cx| { + this.show_staged_first = !this.show_staged_first; + cx.notify(); + }, + ), + )), + ), + ) + .child( + self.render_group( + "差异视图", + v_flex().w_full().gap_3().child( + self.render_row( + "默认视图", + None, + h_flex() + .gap_1p5() + .child(self.render_segment( + "diff-unified", + "统一", + self.git_default_diff_view == "unified", + cx, + |this, cx| { + this.git_default_diff_view = "unified".to_string(); + cx.notify(); + }, + )) + .child(self.render_segment( + "diff-split", + "分栏", + self.git_default_diff_view == "split", + cx, + |this, cx| { + this.git_default_diff_view = "split".to_string(); + cx.notify(); + }, + )), + ), + ), + ), + ) + .child(self.render_group( + "行内 Blame", + v_flex().w_full().gap_3().child(self.render_row( + "启用行内 Blame", + None, + self.render_toggle( + "git-inline-blame", + self.enable_inline_git_blame, + cx, + |this, cx| { + this.enable_inline_git_blame = !this.enable_inline_git_blame; + cx.notify(); + }, + ), + )), + )) + } + + fn render_logs_content(&self, cx: &mut Context) -> impl IntoElement { + v_flex() + .w_full() + .gap_4() + .child( + self.render_group( + "日志", + v_flex().w_full().gap_3().child( + self.render_row( + "日志级别", + Some("占位项:Linux Settings 尚未提供该字段,暂不落盘"), + h_flex() + .gap_1p5() + .child(self.render_segment( + "log-trace", + "TRACE", + self.log_level == "trace", + cx, + |this, cx| { + this.log_level = "trace".to_string(); + cx.notify(); + }, + )) + .child(self.render_segment( + "log-debug", + "DEBUG", + self.log_level == "debug", + cx, + |this, cx| { + this.log_level = "debug".to_string(); + cx.notify(); + }, + )) + .child(self.render_segment( + "log-info", + "INFO", + self.log_level == "info", + cx, + |this, cx| { + this.log_level = "info".to_string(); + cx.notify(); + }, + )) + .child(self.render_segment( + "log-warn", + "WARN", + self.log_level == "warn", + cx, + |this, cx| { + this.log_level = "warn".to_string(); + cx.notify(); + }, + )) + .child(self.render_segment( + "log-error", + "ERROR", + self.log_level == "error", + cx, + |this, cx| { + this.log_level = "error".to_string(); + cx.notify(); + }, + )), + ), + ), + ), + ) + .child(self.render_group("输出", self.render_note("日志会同时输出到终端与日志文件。"))) + } + + fn render_updates_content(&self, cx: &mut Context) -> impl IntoElement { + v_flex().w_full().gap_4().child( + self.render_group( + "软件更新", + v_flex().w_full().gap_3().child( + self.render_row( + "Lithe", + Some("当前版本为占位展示,检查更新尚未接入"), + Button::new("check-updates") + .small() + .primary() + .label("检查更新") + .on_click(cx.listener(|_this, _event, _window, cx| { + // 占位:仅刷新视图,不发起真实更新检查。 + cx.notify(); + })), + ), + ), + ), + ) } } diff --git a/linux/src/workbench/status_bar.rs b/linux/src/workbench/status_bar.rs index b9dd03a48..46cc6a94d 100644 --- a/linux/src/workbench/status_bar.rs +++ b/linux/src/workbench/status_bar.rs @@ -1,11 +1,30 @@ +//! 底部状态栏:复刻 Tauri `footer.tsx` / `footer-editor-status.tsx` / +//! `footer-status-chip.tsx`。 +//! +//! 左右两组项及其顺序完全由设置(`footer_leading_items_order`、 +//! `footer_trailing_items_order`)决定,与 Tauri `FOOTER_LEADING_ITEM_IDS` / +//! `FOOTER_TRAILING_ITEM_IDS` 对应。`gitChanges` chip 点击时只向外广播 +//! [`StatusBarEvent::OpenGit`],由 `view.rs` 打开 Git 侧边视图。 + use gpui_kit::assets::IconName; use gpui_kit::component::{h_flex, Icon}; use gpui_kit::{ - div, px, Context, FontWeight, IntoElement, ParentElement as _, Render, Styled as _, Window, + div, px, AnyElement, Context, EventEmitter, InteractiveElement as _, IntoElement, + ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, Window, }; +use crate::settings; use crate::theme::ThemeColors; +/// 状态栏事件。 +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub enum StatusBarEvent { + /// 点击 gitChanges chip,请求打开 Git 视图。 + OpenGit, +} + +#[allow(dead_code)] pub struct StatusBarView { pub git_branch: Option, pub current_file: Option, @@ -13,8 +32,18 @@ pub struct StatusBarView { pub cursor_col: usize, pub language: String, pub encoding: String, + /// 缩进宽度(空格数),来自设置的 `tab_size`。 + pub indent_size: usize, + /// 当前编辑器是否为只读。 + pub read_only: bool, + /// 进程内存占用(MB)。 + pub memory_mb: usize, + /// 工作区变更文件数。 + pub git_changes: usize, } +impl EventEmitter for StatusBarView {} + impl StatusBarView { pub fn new() -> Self { Self { @@ -24,9 +53,14 @@ impl StatusBarView { cursor_col: 1, language: "Plain Text".to_string(), encoding: "UTF-8".to_string(), + indent_size: 4, + read_only: false, + memory_mb: 0, + git_changes: 0, } } + /// 编辑器状态更新入口。签名保持不变,`view.rs` 现有调用无需改动。 pub fn set_file_info( &mut self, file: Option, @@ -47,119 +81,180 @@ impl StatusBarView { self.git_branch = branch; cx.notify(); } + + #[allow(dead_code)] + pub fn set_git_changes(&mut self, count: usize, cx: &mut Context) { + self.git_changes = count; + cx.notify(); + } + + #[allow(dead_code)] + pub fn set_memory_mb(&mut self, mb: usize, cx: &mut Context) { + self.memory_mb = mb; + cx.notify(); + } +} + +impl Default for StatusBarView { + fn default() -> Self { + Self::new() + } +} + +/// 项之间的 1px 竖向分隔线。 +fn separator() -> AnyElement { + div() + .w(px(1.0)) + .h(px(10.0)) + .flex_shrink_0() + .bg(ThemeColors::border()) + .into_any_element() +} + +/// 带图标的标签:图标 + 文本共用同一颜色,避免两段颜色不一致。 +fn labeled(icon: IconName, label: String, color: gpui_kit::Rgba) -> AnyElement { + h_flex() + .items_center() + .gap_1() + .text_color(color) + .child(Icon::new(icon).size(px(12.0)).text_color(color)) + .child(div().child(label)) + .into_any_element() +} + +impl StatusBarView { + fn render_leading_item(&self, id: &str) -> Option { + match id { + "filePath" => { + let label = self + .current_file + .clone() + .unwrap_or_else(|| "No file active".to_string()); + Some(labeled( + IconName::FileText, + label, + ThemeColors::subtle_foreground(), + )) + } + // 分支未知时整项不渲染,避免用占位名误导用户。 + "branch" => self + .git_branch + .as_ref() + .map(|branch| labeled(IconName::GitBranch, branch.clone(), ThemeColors::success())), + _ => None, + } + } + + fn render_trailing_item(&self, id: &str, cx: &mut Context) -> Option { + match id { + "cursor" => Some( + div() + .child(format!("Ln {}, Col {}", self.cursor_line, self.cursor_col)) + .into_any_element(), + ), + "encoding" => Some(div().child(self.encoding.clone()).into_any_element()), + "indent" => Some( + div() + .child(format!("{} spaces", self.indent_size)) + .into_any_element(), + ), + "readOnly" => Some(labeled( + if self.read_only { + IconName::Lock + } else { + IconName::LockOpen + }, + if self.read_only { + "Read-only" + } else { + "Editable" + } + .to_string(), + ThemeColors::subtle_foreground(), + )), + "memory" => Some(labeled( + IconName::MemoryStick, + format!("{} MB", self.memory_mb), + ThemeColors::subtle_foreground(), + )), + "gitChanges" => Some(self.render_git_changes(cx)), + _ => None, + } + } + + fn render_git_changes(&self, cx: &mut Context) -> AnyElement { + if self.git_changes == 0 { + // 无变更时用成功色对勾,和 Tauri 的 CheckCircleIcon text-success 对齐。 + labeled( + IconName::CircleCheck, + "0".to_string(), + ThemeColors::success(), + ) + } else { + let count = self.git_changes; + h_flex() + .id("status-git-changes") + .items_center() + .gap_1() + .cursor_pointer() + .text_color(ThemeColors::git_modified()) + .hover(|h| h.bg(ThemeColors::accent())) + .child(div().child(count.to_string())) + .on_click(cx.listener(|_this, _event, _window, cx| { + cx.emit(StatusBarEvent::OpenGit); + })) + .into_any_element() + } + } } impl Render for StatusBarView { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - let branch = self.git_branch.clone().unwrap_or_else(|| "main".to_string()); - let file_label = self - .current_file - .clone() - .unwrap_or_else(|| "No file active".to_string()); + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + // 只读快照顺序,随后释放对全局设置的借用。 + let (leading_order, trailing_order) = { + let s = settings::get(cx); + ( + s.footer_leading_items_order.clone(), + s.footer_trailing_items_order.clone(), + ) + }; + + let leading = join_items( + leading_order + .iter() + .filter_map(|id| self.render_leading_item(id)), + ); + let trailing = join_items( + trailing_order + .iter() + .filter_map(|id| self.render_trailing_item(id, cx)), + ); h_flex() .h(px(24.0)) .w_full() - .bg(ThemeColors::bg_statusbar()) + .flex_shrink_0() + .bg(ThemeColors::background()) .border_t_1() .border_color(ThemeColors::border()) .items_center() .justify_between() .px_3() .text_xs() - .text_color(ThemeColors::text_muted()) - .child( - // 左侧状态栏项目(分支 + 文件状态) - h_flex() - .items_center() - .gap_2() - .child( - h_flex() - .items_center() - .gap_1p5() - .text_color(ThemeColors::accent_green()) - .child( - Icon::new(IconName::GitBranch) - .size(px(12.0)) - .text_color(ThemeColors::accent_green()), - ) - .child( - div() - .font_weight(FontWeight::SEMIBOLD) - .child(branch), - ), - ) - .child( - div() - .h(px(10.0)) - .w(px(1.0)) - .bg(ThemeColors::border()) - .mx_1(), - ) - .child( - h_flex() - .items_center() - .gap_1p5() - .child( - Icon::new(IconName::FileText) - .size(px(12.0)) - .text_color(ThemeColors::text_muted()), - ) - .child( - div() - .text_color(ThemeColors::text_primary()) - .child(file_label), - ), - ), - ) - .child( - // 右侧状态栏项目(行列号、缩进、编码、语言、核心状态) - h_flex() - .items_center() - .gap_2() - .child(format!("Ln {}, Col {}", self.cursor_line, self.cursor_col)) - .child( - div() - .h(px(10.0)) - .w(px(1.0)) - .bg(ThemeColors::border()), - ) - .child("4 spaces") - .child( - div() - .h(px(10.0)) - .w(px(1.0)) - .bg(ThemeColors::border()), - ) - .child(self.encoding.clone()) - .child( - div() - .h(px(10.0)) - .w(px(1.0)) - .bg(ThemeColors::border()), - ) - .child(self.language.clone()) - .child( - div() - .h(px(10.0)) - .w(px(1.0)) - .bg(ThemeColors::border()), - ) - .child( - h_flex() - .items_center() - .gap_1() - .child( - Icon::new(IconName::Check) - .size(px(12.0)) - .text_color(ThemeColors::accent_green()), - ) - .child( - div() - .text_color(ThemeColors::accent_blue()) - .child("Lithe Core"), - ), - ), - ) + .text_color(ThemeColors::subtle_foreground()) + .child(h_flex().items_center().gap_2().min_w_0().children(leading)) + .child(h_flex().items_center().gap_2().children(trailing)) + } +} + +/// 用分隔线拼接一组项。 +fn join_items(items: impl Iterator) -> Vec { + let mut out: Vec = Vec::new(); + for item in items { + if !out.is_empty() { + out.push(separator()); + } + out.push(item); } + out } diff --git a/linux/src/workbench/toolbar.rs b/linux/src/workbench/toolbar.rs index 781991340..78fc10da8 100644 --- a/linux/src/workbench/toolbar.rs +++ b/linux/src/workbench/toolbar.rs @@ -1,17 +1,25 @@ +//! 顶部标题栏/工具栏:复刻 Tauri `title-bar.tsx` + `window-menu-bar.tsx`。 +//! +//! 结构从左到右:应用菜单栏(紧凑或九宫格)、品牌、项目胶囊、分支胶囊、 +//! 居中全局搜索条、运行目标胶囊、运行/调试/停止、主题与设置、窗口控件。 +//! 所有交互只通过 [`ToolbarEvent`] 向外广播,具体业务由 `view.rs` 订阅处理。 + use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants as _}; -use gpui_kit::component::menu::{DropdownMenu as _, PopupMenuItem}; +use gpui_kit::component::menu::{DropdownMenu as _, PopupMenu, PopupMenuItem}; use gpui_kit::component::{h_flex, Icon, Sizable as _}; use gpui_kit::{ - div, px, Context, EventEmitter, FontWeight, InteractiveElement as _, IntoElement, - ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, Window, + div, px, AnyElement, Context, EventEmitter, FontWeight, InteractiveElement as _, IntoElement, + ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, Styled as _, Window, }; +use crate::settings; use crate::theme::ThemeColors; #[derive(Debug, Clone)] #[allow(dead_code)] pub enum ToolbarEvent { + // ---- 旧事件(保留以兼容 view.rs 现有订阅) ---- NewFile, Save, CloseTab, @@ -26,6 +34,19 @@ pub enum ToolbarEvent { OpenSettings, About, Exit, + // ---- 新增:菜单项统一出口 ---- + /// 任意应用菜单项被点击,载荷为稳定 id(如 `file.new_file`)。 + MenuAction(String), + /// 切换浅色/深色主题。 + ToggleTheme, + WindowMinimize, + WindowMaximize, + WindowClose, + // ---- 新增:项目胶囊动作 ---- + NewProject, + OpenProject, + CloneRepository, + OpenRecent, } pub struct ToolbarView { @@ -54,180 +75,383 @@ impl ToolbarView { } } +/// 单个菜单项:显示文本 + 稳定动作 id。 +struct MenuEntry { + label: &'static str, + action: &'static str, +} + +/// 便于在 `const` 菜单表中声明条目。 +const fn entry(label: &'static str, action: &'static str) -> MenuEntry { + MenuEntry { label, action } +} + +/// 一个顶层应用菜单:标题 + 分组(分组之间渲染分隔线)。 +struct AppMenu { + title: &'static str, + groups: &'static [&'static [MenuEntry]], +} + +// ---- 九个应用菜单的静态定义,与 Tauri `window-menu-bar.tsx` 对齐 ---- +const FILE_MENU: &[&[MenuEntry]] = &[ + &[ + entry("New File", "file.new_file"), + entry("New Window", "file.new_window"), + ], + &[ + entry("Open File...", "file.open_file"), + entry("Open Folder...", "file.open_folder"), + ], + &[ + entry("Save", "file.save"), + entry("Save All", "file.save_all"), + ], + &[ + entry("Close Editor", "file.close_editor"), + entry("Close Window", "file.close_window"), + ], + &[entry("Exit", "file.exit")], +]; + +const EDIT_MENU: &[&[MenuEntry]] = &[ + &[entry("Undo", "edit.undo"), entry("Redo", "edit.redo")], + &[ + entry("Cut", "edit.cut"), + entry("Copy", "edit.copy"), + entry("Paste", "edit.paste"), + ], + &[entry("Find", "edit.find"), entry("Replace", "edit.replace")], +]; + +const VIEW_MENU: &[&[MenuEntry]] = &[ + &[ + entry("Toggle Sidebar", "view.toggle_sidebar"), + entry("Toggle Bottom Panel", "view.toggle_bottom_panel"), + entry("Toggle Status Bar", "view.toggle_status_bar"), + ], + &[ + entry("Command Palette", "view.command_palette"), + entry("Quick Open", "view.quick_open"), + ], + &[ + entry("Zoom In", "view.zoom_in"), + entry("Zoom Out", "view.zoom_out"), + entry("Reset Zoom", "view.reset_zoom"), + ], +]; + +const GO_MENU: &[&[MenuEntry]] = &[ + &[entry("Back", "go.back"), entry("Forward", "go.forward")], + &[ + entry("Go to File", "go.go_to_file"), + entry("Go to Symbol", "go.go_to_symbol"), + entry("Go to Line", "go.go_to_line"), + ], +]; + +const TERMINAL_MENU: &[&[MenuEntry]] = &[&[ + entry("New Terminal", "terminal.new"), + entry("Split Terminal", "terminal.split"), + entry("Clear Terminal", "terminal.clear"), +]]; + +const RUN_MENU: &[&[MenuEntry]] = &[ + &[ + entry("Run", "run.run"), + entry("Debug", "run.debug"), + entry("Stop", "run.stop"), + ], + &[entry("Run Without Debugging", "run.run_without_debugging")], +]; + +const TOOLS_MENU: &[&[MenuEntry]] = &[ + &[ + entry("Settings", "tools.settings"), + entry("Extensions", "tools.extensions"), + ], + &[ + entry("Database", "tools.database"), + entry("Diagnostics", "tools.diagnostics"), + ], +]; + +const WINDOW_MENU: &[&[MenuEntry]] = &[&[ + entry("Minimize", "window.minimize"), + entry("Maximize", "window.maximize"), + entry("Close", "window.close"), +]]; + +const HELP_MENU: &[&[MenuEntry]] = &[ + &[ + entry("Documentation", "help.documentation"), + entry("Keyboard Shortcuts", "help.keyboard_shortcuts"), + ], + &[entry("About Lithe", "help.about")], +]; + +const APP_MENUS: &[AppMenu] = &[ + AppMenu { + title: "File", + groups: FILE_MENU, + }, + AppMenu { + title: "Edit", + groups: EDIT_MENU, + }, + AppMenu { + title: "View", + groups: VIEW_MENU, + }, + AppMenu { + title: "Go", + groups: GO_MENU, + }, + AppMenu { + title: "Terminal", + groups: TERMINAL_MENU, + }, + AppMenu { + title: "Run", + groups: RUN_MENU, + }, + AppMenu { + title: "Tools", + groups: TOOLS_MENU, + }, + AppMenu { + title: "Window", + groups: WINDOW_MENU, + }, + AppMenu { + title: "Help", + groups: HELP_MENU, + }, +]; + +/// 构造一个发出 `MenuAction(id)` 的菜单项。 +fn menu_action_item( + label: impl Into, + action: &'static str, + view: &gpui_kit::Entity, +) -> PopupMenuItem { + let v = view.clone(); + PopupMenuItem::new(label).on_click(move |_, _, cx| { + v.update(cx, |_, cx| { + cx.emit(ToolbarEvent::MenuAction(action.to_string())) + }); + }) +} + +/// 构造一个直接发出指定事件的菜单项(用于项目胶囊等固定动作)。 +fn event_item( + label: impl Into, + make: fn() -> ToolbarEvent, + view: &gpui_kit::Entity, +) -> PopupMenuItem { + let v = view.clone(); + PopupMenuItem::new(label).on_click(move |_, _, cx| { + let event = make(); + v.update(cx, |_, cx| cx.emit(event)); + }) +} + +/// 按分组把菜单项灌入 `PopupMenu`,分组之间自动插入分隔线。 +fn build_menu( + mut menu: PopupMenu, + groups: &[&[MenuEntry]], + view: &gpui_kit::Entity, +) -> PopupMenu { + let mut first_group = true; + for group in groups { + if !first_group { + menu = menu.separator(); + } + first_group = false; + for item in *group { + menu = menu.item(menu_action_item(item.label, item.action, view)); + } + } + menu +} + impl Render for ToolbarView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let branch = self.git_branch.clone().unwrap_or_else(|| "main".to_string()); + let branch = self + .git_branch + .clone() + .unwrap_or_else(|| "main".to_string()); + let project_name = self.workspace_name.clone(); + + // 读取渲染所需的设置快照,随后立刻释放对 cx 的只读借用。 + let (compact_menu, theme_icon) = { + let s = settings::get(cx); + let icon = if s.sync_system_theme { + IconName::Moon + } else if s.theme == "lithe-light" { + IconName::Sun + } else { + IconName::Moon + }; + (s.compact_menu_bar, icon) + }; + let view = cx.entity(); + // 应用菜单栏:紧凑模式为单个 Menu 按钮 + 九个子菜单;否则平铺九个顶层菜单。 + let app_menu: AnyElement = if compact_menu { + let v = view.clone(); + Button::new("tb-app-menu") + .small() + .ghost() + .icon(IconName::Menu) + .tooltip("Menu") + .dropdown_menu(move |menu, window, cx| { + let mut menu = menu; + for app in APP_MENUS { + let v_sub = v.clone(); + menu = menu.submenu(app.title, window, cx, move |sub, _window, _cx| { + build_menu(sub, app.groups, &v_sub) + }); + } + menu + }) + .into_any_element() + } else { + h_flex() + .items_center() + .children(APP_MENUS.iter().map(|app| { + let v = view.clone(); + Button::new(format!("tb-menu-{}", app.title)) + .small() + .ghost() + .label(app.title) + .dropdown_menu(move |menu, _window, _cx| build_menu(menu, app.groups, &v)) + })) + .into_any_element() + }; + h_flex() .h(px(40.0)) .w_full() - .bg(ThemeColors::bg_titlebar()) + .bg(ThemeColors::background()) .border_b_1() .border_color(ThemeColors::border()) .items_center() - .justify_between() - .px_3() + .pl_3() + // 1. 左侧:应用菜单 + 品牌 + 项目胶囊 + 分支胶囊 .child( - // 1. 左侧:Lithe Logo + 项目选择器胶囊 + 分支选择器胶囊 h_flex() + .flex_1() + .min_w_0() .items_center() - .gap_3() + .gap_2() + .child(app_menu) .child( - // Lithe Logo + 品牌名 + // Lithe 品牌标识 h_flex() .items_center() .gap_1p5() - .pr_1() + .pl_1() .child( Icon::new(IconName::Zap) .size(px(16.0)) - .text_color(ThemeColors::accent_blue()), + .text_color(ThemeColors::primary()), ) .child( div() .font_weight(FontWeight::BOLD) .text_sm() - .text_color(ThemeColors::text_primary()) + .text_color(ThemeColors::foreground()) .child("Lithe"), ), ) - // 项目选择器胶囊(点击可展开项目切换下拉菜单) .child({ + // 项目选择器胶囊:Folder 图标 + 项目名 + ChevronDown,点击展开项目菜单 let v = view.clone(); + let name = project_name.clone(); Button::new("tb-project-selector") .small() .ghost() - .icon(IconName::Folder) - .label(self.workspace_name.clone()) - .bg(ThemeColors::bg_tab_hover()) + .rounded_md() + .bg(ThemeColors::surface()) .border_1() .border_color(ThemeColors::border()) - .rounded_md() + .child( + Icon::new(IconName::Folder) + .size(px(13.0)) + .text_color(ThemeColors::muted_foreground()), + ) + .child( + div() + .text_xs() + .text_color(ThemeColors::foreground()) + .child(name.clone()), + ) + .child( + Icon::new(IconName::ChevronDown) + .size(px(12.0)) + .text_color(ThemeColors::subtle_foreground()), + ) .dropdown_menu(move |menu, _window, _cx| { - let v_refresh = v.clone(); - let v_new_file = v.clone(); - let v_save = v.clone(); - let v_close = v.clone(); - let v_sidebar = v.clone(); - let v_terminal = v.clone(); - let v_settings = v.clone(); - let v_about = v.clone(); - let v_exit = v.clone(); - - menu.item( - PopupMenuItem::new("New File (Ctrl+N)") - .on_click(move |_, _, cx| { - v_new_file.update(cx, |_, cx| { - cx.emit(ToolbarEvent::NewFile) - }); - }), - ) - .item( - PopupMenuItem::new("Save (Ctrl+S)") - .on_click(move |_, _, cx| { - v_save.update(cx, |_, cx| { - cx.emit(ToolbarEvent::Save) - }); - }), - ) - .item( - PopupMenuItem::new("Close Active Tab (Ctrl+W)") - .on_click(move |_, _, cx| { - v_close.update(cx, |_, cx| { - cx.emit(ToolbarEvent::CloseTab) - }); - }), - ) + menu.item(event_item( + "New Project", + || ToolbarEvent::NewProject, + &v, + )) + .item(event_item("Open Project", || ToolbarEvent::OpenProject, &v)) + .item(event_item( + "Clone Repository", + || ToolbarEvent::CloneRepository, + &v, + )) + .item(event_item("Open Recent", || ToolbarEvent::OpenRecent, &v)) + .item(event_item( + "Open Projects", + || ToolbarEvent::OpenProject, + &v, + )) .separator() - .item( - PopupMenuItem::new("Toggle Sidebar (Ctrl+B)") - .on_click(move |_, _, cx| { - v_sidebar.update(cx, |_, cx| { - cx.emit(ToolbarEvent::ToggleSidebar) - }); - }), - ) - .item( - PopupMenuItem::new("Toggle Terminal (Ctrl+`)") - .on_click(move |_, _, cx| { - v_terminal.update(cx, |_, cx| { - cx.emit(ToolbarEvent::ToggleTerminal) - }); - }), - ) - .item( - PopupMenuItem::new("Refresh Workspace") - .on_click(move |_, _, cx| { - v_refresh.update(cx, |_, cx| { - cx.emit(ToolbarEvent::RefreshWorkspace) - }); - }), - ) - .separator() - .item( - PopupMenuItem::new("Settings") - .on_click(move |_, _, cx| { - v_settings.update(cx, |_, cx| { - cx.emit(ToolbarEvent::OpenSettings) - }); - }), - ) - .item( - PopupMenuItem::new("About Lithe") - .on_click(move |_, _, cx| { - v_about.update(cx, |_, cx| { - cx.emit(ToolbarEvent::About) - }); - }), - ) - .separator() - .item( - PopupMenuItem::new("Exit") - .on_click(move |_, _, cx| { - v_exit.update(cx, |_, cx| { - cx.emit(ToolbarEvent::Exit) - }); - }), - ) + // 当前项目打勾标记,仅展示 + .item(PopupMenuItem::new(name.clone()).icon(IconName::CircleCheck)) }) }) - // 分支选择器胶囊:带 GitBranch 图标、分支名和 ChevronDown .child( + // 分支胶囊:只展示当前分支,不接真实切换 h_flex() .items_center() .gap_1p5() .px_2() .py(px(3.0)) .rounded_md() - .bg(ThemeColors::bg_tab_hover()) + .bg(ThemeColors::surface()) .border_1() .border_color(ThemeColors::border()) .child( Icon::new(IconName::GitBranch) .size(px(13.0)) - .text_color(ThemeColors::accent_green()), + .text_color(ThemeColors::success()), ) .child( div() .text_xs() - .text_color(ThemeColors::accent_green()) + .text_color(ThemeColors::success()) .child(branch), ) .child( Icon::new(IconName::ChevronDown) .size(px(12.0)) - .text_color(ThemeColors::text_muted()), + .text_color(ThemeColors::subtle_foreground()), ), ), ) + // 2. 中间:全局搜索条 .child( - // 2. 中间:全局搜索栏 (Search Everywhere) div() .id("search-everywhere-bar") .w(px(280.0)) .h(px(28.0)) - .bg(ThemeColors::bg_tab_active()) + .flex_shrink_0() + .bg(ThemeColors::surface()) .border_1() .border_color(ThemeColors::border()) .rounded(px(6.0)) @@ -236,7 +460,7 @@ impl Render for ToolbarView { .items_center() .justify_between() .cursor_pointer() - .hover(|h| h.border_color(ThemeColors::accent_blue())) + .hover(|h| h.border_color(ThemeColors::primary())) .on_click(cx.listener(|_this, _event, _window, cx| { cx.emit(ToolbarEvent::QuickOpen); })) @@ -247,32 +471,35 @@ impl Render for ToolbarView { .child( Icon::new(IconName::Search) .size(px(13.0)) - .text_color(ThemeColors::text_muted()), + .text_color(ThemeColors::subtle_foreground()), ) .child( div() .text_xs() - .text_color(ThemeColors::text_muted()) + .text_color(ThemeColors::subtle_foreground()) .child("Search files, symbols..."), ), ) .child( div() .text_xs() - .text_color(ThemeColors::text_muted()) + .text_color(ThemeColors::subtle_foreground()) .px_1p5() .py(px(1.0)) - .bg(ThemeColors::bg_titlebar()) + .bg(ThemeColors::background()) .border_1() .border_color(ThemeColors::border()) .rounded(px(4.0)) .child("Ctrl+P"), ), ) + // 3. 右侧工具组:运行目标、运行/调试/停止、主题、设置 .child( - // 3. 右侧:运行目标胶囊 + 运行/调试/停止/设置控制组 h_flex() + .flex_1() + .min_w_0() .items_center() + .justify_end() .gap_2() .child( // 运行目标胶囊 @@ -282,19 +509,19 @@ impl Render for ToolbarView { .px_2() .py(px(3.0)) .rounded_md() - .bg(ThemeColors::bg_tab_hover()) + .bg(ThemeColors::surface()) .border_1() .border_color(ThemeColors::border()) .child( div() .text_xs() - .text_color(ThemeColors::text_primary()) + .text_color(ThemeColors::foreground()) .child("[Project] Default"), ) .child( Icon::new(IconName::ChevronDown) .size(px(12.0)) - .text_color(ThemeColors::text_muted()), + .text_color(ThemeColors::subtle_foreground()), ), ) .child( @@ -327,6 +554,17 @@ impl Render for ToolbarView { cx.emit(ToolbarEvent::Stop); })), ) + .child( + // 主题切换:跟随系统时恒显示 Moon,否则按当前主题显示 Sun/Moon + Button::new("tb-theme-toggle") + .small() + .ghost() + .icon(theme_icon) + .tooltip("Toggle Theme") + .on_click(cx.listener(|_this, _event, _window, cx| { + cx.emit(ToolbarEvent::ToggleTheme); + })), + ) .child( Button::new("tb-settings") .small() @@ -338,5 +576,64 @@ impl Render for ToolbarView { })), ), ) + // 4. 窗口控件:46x40 直角按钮,Close 悬停使用 destructive + .child( + h_flex() + .items_center() + .flex_shrink_0() + .child(window_control( + "tb-window-minimize", + IconName::Minus, + false, + cx.listener(|_this, _event: &gpui_kit::ClickEvent, _window, cx| { + cx.emit(ToolbarEvent::WindowMinimize); + }), + )) + .child(window_control( + "tb-window-maximize", + IconName::Square, + false, + cx.listener(|_this, _event: &gpui_kit::ClickEvent, _window, cx| { + cx.emit(ToolbarEvent::WindowMaximize); + }), + )) + .child(window_control( + "tb-window-close", + IconName::Close, + true, + cx.listener(|_this, _event: &gpui_kit::ClickEvent, _window, cx| { + cx.emit(ToolbarEvent::WindowClose); + }), + )), + ) } } + +/// 构建一个窗口控件按钮:固定 46x40、直角,悬停高亮,Close 使用 destructive。 +fn window_control( + id: &'static str, + icon: IconName, + destructive: bool, + on_click: impl Fn(&gpui_kit::ClickEvent, &mut Window, &mut gpui_kit::App) + 'static, +) -> impl IntoElement { + let hover_bg = if destructive { + ThemeColors::destructive() + } else { + ThemeColors::accent() + }; + div() + .id(id) + .w(px(46.0)) + .h(px(40.0)) + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .hover(move |h| h.bg(hover_bg)) + .on_click(on_click) + .child( + Icon::new(icon) + .size(px(14.0)) + .text_color(ThemeColors::muted_foreground()), + ) +} diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index 2a7b2950c..727c4ffdb 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -1,3 +1,8 @@ +//! 工作台集成层:装配各区域视图、订阅事件、处理快捷键与布局调整。 +//! +//! 结构对齐 Tauri `MainLayout`:顶栏 / 多项目标签条 / 活动栏 + 侧边栏 + 编辑区 + +//! 右侧插件活动栏 / 底部面板 / 状态栏;无项目时显示欢迎页,浮层由模态状态控制。 + use gpui_kit::component::{h_flex, v_flex}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ @@ -7,27 +12,31 @@ use gpui_kit::{ }; use crate::core::CoreClient; +use crate::settings; use crate::theme::ThemeColors; -use crate::workbench::activity_rail::{ActivityRailEvent, ActivityRailView, ActivityTab}; -use crate::workbench::bottom_panel::BottomPanelView; -use crate::workbench::editor::EditorView; -use crate::workbench::search_everywhere::{ - SearchEverywhereEvent, SearchEverywhereModal, +use crate::workbench::activity_rail::{ + ActivityRailEvent, ActivityRailView, PluginActivityRailView, PluginRailEvent, }; +use crate::workbench::bottom_panel::{BottomPanelView, BottomTab}; +use crate::workbench::command_palette::{CommandPaletteEvent, CommandPaletteModal}; +use crate::workbench::editor::EditorView; +use crate::workbench::quick_open::{QuickOpenEvent, QuickOpenModal}; +use crate::workbench::search_everywhere::{SearchEverywhereEvent, SearchEverywhereModal}; use crate::workbench::settings_dialog::{SettingsDialog, SettingsEvent}; use crate::workbench::sidebar::{FileEntry, SidebarEvent, SidebarTab, SidebarView}; -use crate::workbench::status_bar::StatusBarView; +use crate::workbench::status_bar::{StatusBarEvent, StatusBarView}; use crate::workbench::toolbar::{ToolbarEvent, ToolbarView}; +use crate::workbench::welcome_screen::{WelcomeEvent, WelcomeScreenView}; -/// Linux 前端主工作台视图组件(复刻 macOS LitheTheme / IntelliJ 架构) +/// 主工作台视图。 pub struct WorkbenchView { /// 项目工作区根路径 pub workspace_root: String, /// 侧边栏是否展开 pub sidebar_visible: bool, - /// 侧边栏宽度(默认 260.0,支持拖拽调节) + /// 侧边栏宽度(支持拖拽调节) pub sidebar_width: f32, - /// 底部面板高度(默认 240.0,支持拖拽调节) + /// 底部面板高度(支持拖拽调节) pub bottom_panel_height: f32, /// 是否正在调节侧边栏宽度 pub is_resizing_sidebar: bool, @@ -37,8 +46,14 @@ pub struct WorkbenchView { drag_start_y: f32, initial_resize_size: f32, + /// 是否显示欢迎页(无打开项目时) + pub show_welcome: bool, /// 全局搜索弹窗浮层是否可见 pub show_search_everywhere: bool, + /// 快速打开浮层是否可见 + pub show_quick_open: bool, + /// 命令面板浮层是否可见 + pub show_command_palette: bool, /// 设置模态对话框是否可见 pub show_settings_dialog: bool, @@ -46,6 +61,8 @@ pub struct WorkbenchView { pub toolbar: Entity, /// 左侧垂直活动栏 (Activity Rail) pub activity_rail: Entity, + /// 右侧插件活动栏 (Extensions / Notifications / Maven) + pub plugin_rail: Entity, /// 侧边栏面板 (Files / Git / Search) pub sidebar: Entity, /// 主代码编辑器区 @@ -56,8 +73,14 @@ pub struct WorkbenchView { pub status_bar: Entity, /// 全局搜索弹窗 pub search_everywhere: Entity, + /// 快速打开弹窗 + pub quick_open: Entity, + /// 命令面板 + pub command_palette: Entity, /// 设置模态对话框 pub settings_dialog: Entity, + /// 欢迎页 + pub welcome_screen: Entity, focus_handle: FocusHandle, client: CoreClient, @@ -73,73 +96,77 @@ impl WorkbenchView { Self::with_root(workspace_root, window, cx) } - pub fn with_root( - workspace_root: String, - window: &mut Window, - cx: &mut Context, - ) -> Self { + pub fn with_root(workspace_root: String, window: &mut Window, cx: &mut Context) -> Self { let root = workspace_root.clone(); let toolbar = cx.new(|_cx| ToolbarView::new(&root)); - let activity_rail = cx.new(|_cx| ActivityRailView::new()); + let activity_rail = cx.new(|cx| ActivityRailView::new(cx)); + let plugin_rail = cx.new(|_cx| PluginActivityRailView::new()); let sidebar = cx.new(|cx| SidebarView::new(root.clone(), cx)); let editor = cx.new(|cx| EditorView::new(root.clone(), window, cx)); let bottom_panel = cx.new(|cx| BottomPanelView::new(root.clone(), cx)); let status_bar = cx.new(|_cx| StatusBarView::new()); let search_everywhere = cx.new(|cx| SearchEverywhereModal::new(cx)); - let settings_dialog = cx.new(|_cx| SettingsDialog::new()); + let quick_open = cx.new(|cx| QuickOpenModal::new(cx)); + let command_palette = cx.new(|cx| CommandPaletteModal::new(cx)); + let settings_dialog = cx.new(|cx| SettingsDialog::new(cx)); + let welcome_screen = cx.new(|cx| WelcomeScreenView::new(cx)); let focus_handle = cx.focus_handle(); // 1. 订阅侧边栏事件(打开文件、新建文件、提交 Git) let status_bar_clone = status_bar.clone(); let bottom_panel_sidebar = bottom_panel.clone(); - let sub_sidebar = cx.subscribe(&sidebar, move |this, sidebar, event: &SidebarEvent, cx| { - match event { - SidebarEvent::OpenFile(path) => { - this.open_file(path, cx); - let _ = status_bar_clone.update(cx, |sb, cx| { - sb.set_file_info( - Some(path.clone()), - 1, - 1, - EditorView::language_name(path).to_string(), - cx, - ); - }); - } - SidebarEvent::NewFile => { - let _ = this.editor.update(cx, |ed, cx| { - ed.open_file("untitled.txt".to_string(), String::new(), cx); - }); - let _ = status_bar_clone.update(cx, |sb, cx| { - sb.set_file_info( - Some("untitled.txt".to_string()), - 1, - 1, - "Plain Text".to_string(), - cx, - ); - }); - } - SidebarEvent::Commit(msg) => { - let log_msg = format!("[Git Commit] {}", msg); - let _ = bottom_panel_sidebar.update(cx, |bp, cx| { - bp.append_log(log_msg.clone(), cx); - let _ = bp.terminal.update(cx, |term, cx| { - term.send_command(&format!("git commit -m \"{}\"", msg), cx); + let sub_sidebar = + cx.subscribe( + &sidebar, + move |this, sidebar, event: &SidebarEvent, cx| match event { + SidebarEvent::OpenFile(path) => { + this.open_file(path, cx); + let _ = status_bar_clone.update(cx, |sb, cx| { + sb.set_file_info( + Some(path.clone()), + 1, + 1, + EditorView::language_name(path).to_string(), + cx, + ); }); - }); - let _ = sidebar.update(cx, |sb, cx| { - sb.refresh_git(cx); - }); - } - } - }); + } + SidebarEvent::NewFile => { + let _ = this.editor.update(cx, |ed, cx| { + ed.open_file("untitled.txt".to_string(), String::new(), cx); + }); + let _ = status_bar_clone.update(cx, |sb, cx| { + sb.set_file_info( + Some("untitled.txt".to_string()), + 1, + 1, + "Plain Text".to_string(), + cx, + ); + }); + } + SidebarEvent::Commit(msg) => { + let log_msg = format!("[Git Commit] {}", msg); + let _ = bottom_panel_sidebar.update(cx, |bp, cx| { + bp.append_log(log_msg.clone(), cx); + let _ = bp.terminal.update(cx, |term, cx| { + term.send_command(&format!("git commit -m \"{}\"", msg), cx); + }); + }); + let _ = sidebar.update(cx, |sb, cx| { + sb.refresh_git(cx); + }); + } + }, + ); - // 2. 观察侧边栏状态变化(分支同步与工作区文件快照同步给全局搜索) + // 2. 观察侧边栏状态变化(分支同步、文件快照同步给全局搜索与快速打开) let toolbar_branch_sync = toolbar.clone(); let status_bar_branch_sync = status_bar.clone(); let search_sync = search_everywhere.clone(); + let quick_open_sync = quick_open.clone(); + let status_bar_git_sync = status_bar.clone(); let obs_sidebar = cx.observe(&sidebar, move |_this, sidebar, cx| { let branch = sidebar.read(cx).git_branch.clone(); let _ = toolbar_branch_sync.update(cx, |tb, cx| { @@ -148,144 +175,206 @@ impl WorkbenchView { let _ = status_bar_branch_sync.update(cx, |sb, cx| { sb.set_git_branch(branch, cx); }); + let _ = status_bar_git_sync.update(cx, |sb, cx| { + sb.set_git_changes(sidebar.read(cx).git_changes.len(), cx); + }); - // 收集所有文件供全局搜索 + // 收集所有文件供全局搜索与快速打开 let mut file_list = Vec::new(); if let Some(root_node) = &sidebar.read(cx).root_node { Self::collect_all_file_paths(root_node, &mut file_list); } if !file_list.is_empty() { let _ = search_sync.update(cx, |search, cx| { - search.set_files(file_list, cx); + search.set_files(file_list.clone(), cx); + }); + let _ = quick_open_sync.update(cx, |qo, cx| { + qo.set_files(file_list, cx); }); } }); - // 3. 订阅左侧活动栏 (Activity Rail) 事件 + // 3. 订阅左侧活动栏事件 let sidebar_clone = sidebar.clone(); let bottom_panel_clone = bottom_panel.clone(); - let sub_rail = cx.subscribe(&activity_rail, move |this, rail, event: &ActivityRailEvent, cx| { - match event { - ActivityRailEvent::SelectTab(tab) => { - let sb_tab = match tab { - ActivityTab::Explorer => SidebarTab::Explorer, - ActivityTab::Git => SidebarTab::Git, - ActivityTab::Search => SidebarTab::Search, - }; - + let sub_rail = cx.subscribe( + &activity_rail, + move |this, rail, event: &ActivityRailEvent, cx| match event { + ActivityRailEvent::SelectView(view_id) => { let was_active = this.sidebar_visible - && rail.read(cx).active_tab == Some(*tab); + && rail.read(cx).active_view.as_deref() == Some(view_id); if was_active { this.sidebar_visible = false; - let _ = rail.update(cx, |r, cx| r.set_active_tab(None, cx)); + let _ = rail.update(cx, |r, cx| r.set_active_view(None, cx)); } else { this.sidebar_visible = true; - let _ = rail.update(cx, |r, cx| r.set_active_tab(Some(*tab), cx)); - let _ = sidebar_clone.update(cx, |sb, cx| sb.set_tab(sb_tab, cx)); + let _ = + rail.update(cx, |r, cx| r.set_active_view(Some(view_id.clone()), cx)); + if let Some(tab) = sidebar_tab_for(view_id) { + let _ = sidebar_clone.update(cx, |sb, cx| sb.set_tab(tab, cx)); + } } cx.notify(); } - ActivityRailEvent::ToggleTerminal => { - let _ = bottom_panel_clone.update(cx, |bp, cx| { - bp.toggle_collapsed(cx); - }); + ActivityRailEvent::ToggleBottomPane(pane_id) => { + let target = bottom_tab_for(pane_id); + let collapsed = bottom_panel_clone.read(cx).is_collapsed; + let already = !collapsed + && target.is_some() + && bottom_panel_clone.read(cx).active_tab == target.unwrap(); + if already { + let _ = bottom_panel_clone.update(cx, |bp, cx| bp.toggle_collapsed(cx)); + let _ = rail.update(cx, |r, cx| r.set_active_bottom(None, cx)); + } else { + if let Some(tab) = target { + let _ = bottom_panel_clone.update(cx, |bp, cx| bp.set_tab(tab, cx)); + } + let _ = + rail.update(cx, |r, cx| r.set_active_bottom(Some(pane_id.clone()), cx)); + } + cx.notify(); } ActivityRailEvent::OpenSettings => { - this.show_settings_dialog = true; - cx.notify(); + this.open_settings(cx); } - } - }); + }, + ); - // 4. 订阅顶部工具栏事件 - let sub_toolbar = cx.subscribe(&toolbar, |this, _toolbar, event: &ToolbarEvent, cx| { - match event { - ToolbarEvent::NewFile => { - let _ = this.editor.update(cx, |ed, cx| { - ed.open_file("untitled.txt".to_string(), String::new(), cx); - }); - } - ToolbarEvent::Save => { - let _ = this.editor.update(cx, |ed, cx| { - ed.save_active(cx); - }); - } - ToolbarEvent::CloseTab => { - let _ = this.editor.update(cx, |ed, cx| { - if let Some(idx) = ed.active_tab_index { - ed.close_tab(idx, cx); - } - }); + // 4. 订阅右侧插件活动栏事件 + let sub_plugin_rail = cx.subscribe( + &plugin_rail, + |this, _rail, event: &PluginRailEvent, cx| match event { + PluginRailEvent::OpenExtensions => { + this.append_log("[Extensions] Opening extensions marketplace...", cx); + cx.notify(); } - ToolbarEvent::ToggleSidebar => { - this.sidebar_visible = !this.sidebar_visible; - let tab = if this.sidebar_visible { - Some(ActivityTab::Explorer) - } else { - None - }; - let _ = this.activity_rail.update(cx, |r, cx| r.set_active_tab(tab, cx)); + PluginRailEvent::ToggleNotifications => { + this.append_log("[Notifications] Toggling notifications tool window...", cx); cx.notify(); } - ToolbarEvent::ToggleTerminal => { - let _ = this.bottom_panel.update(cx, |bp, cx| { - bp.toggle_collapsed(cx); - }); + PluginRailEvent::ToggleMaven => { + let _ = this + .bottom_panel + .update(cx, |bp, cx| bp.set_tab(BottomTab::Output, cx)); + this.append_log("[Maven] Opening Maven tool window...", cx); + cx.notify(); } - ToolbarEvent::ClearTerminal => { - let _ = this.bottom_panel.update(cx, |bp, cx| { - let _ = bp.terminal.update(cx, |term, cx| { - term.clear(cx); + }, + ); + + // 5. 订阅顶部工具栏事件 + let sub_toolbar = + cx.subscribe( + &toolbar, + |this, _toolbar, event: &ToolbarEvent, cx| match event { + ToolbarEvent::NewFile => { + let _ = this.editor.update(cx, |ed, cx| { + ed.open_file("untitled.txt".to_string(), String::new(), cx); }); - }); - } - ToolbarEvent::RefreshWorkspace => { - let _ = this.sidebar.update(cx, |sb, cx| { - sb.refresh(cx); - sb.refresh_git(cx); - }); - } - ToolbarEvent::QuickOpen => { - this.open_search_everywhere(cx); - } - ToolbarEvent::Run => { - let _ = this.bottom_panel.update(cx, |bp, cx| { - bp.append_log("[Run] Executing default run configuration...".to_string(), cx); - let _ = bp.terminal.update(cx, |term, cx| { - term.send_command("echo '[Lithe Run]' && cargo check", cx); + } + ToolbarEvent::Save => { + let _ = this.editor.update(cx, |ed, cx| { + ed.save_active(cx); }); - }); - } - ToolbarEvent::Debug => { - let _ = this.bottom_panel.update(cx, |bp, cx| { - bp.append_log("[Debug] Launching DAP debug session...".to_string(), cx); - }); - } - ToolbarEvent::Stop => { - let _ = this.bottom_panel.update(cx, |bp, cx| { - bp.append_log("[Stop] Session terminated by user.".to_string(), cx); - }); - } - ToolbarEvent::OpenSettings => { - this.show_settings_dialog = true; - cx.notify(); - } - ToolbarEvent::About => { - let _ = this.bottom_panel.update(cx, |bp, cx| { - bp.append_log("[About] Lithe IDE for Linux (Powered by GPUI Kit & Rust Core)".to_string(), cx); - }); - } - ToolbarEvent::Exit => { - cx.quit(); - } - } - }); + } + ToolbarEvent::CloseTab => { + let _ = this.editor.update(cx, |ed, cx| { + if let Some(idx) = ed.active_tab_index { + ed.close_tab(idx, cx); + } + }); + } + ToolbarEvent::ToggleSidebar => { + this.toggle_sidebar(cx); + } + ToolbarEvent::ToggleTerminal => { + let _ = this.bottom_panel.update(cx, |bp, cx| { + bp.toggle_collapsed(cx); + }); + } + ToolbarEvent::ClearTerminal => { + let _ = this.bottom_panel.update(cx, |bp, cx| { + let _ = bp.terminal.update(cx, |term, cx| { + term.clear(cx); + }); + }); + } + ToolbarEvent::RefreshWorkspace => { + let _ = this.sidebar.update(cx, |sb, cx| { + sb.refresh(cx); + sb.refresh_git(cx); + }); + } + ToolbarEvent::QuickOpen => { + this.open_quick_open(cx); + } + ToolbarEvent::Run => { + this.append_log("[Run] Executing default run configuration...", cx); + let _ = this.bottom_panel.update(cx, |bp, cx| { + let _ = bp.terminal.update(cx, |term, cx| { + term.send_command("echo '[Lithe Run]' && cargo check", cx); + }); + }); + } + ToolbarEvent::Debug => { + this.append_log("[Debug] Launching DAP debug session...", cx); + } + ToolbarEvent::Stop => { + this.append_log("[Stop] Session terminated by user.", cx); + } + ToolbarEvent::OpenSettings => { + this.open_settings(cx); + } + ToolbarEvent::About => { + this.append_log( + "[About] Lithe IDE for Linux (Powered by GPUI Kit & Rust Core)", + cx, + ); + } + ToolbarEvent::Exit | ToolbarEvent::WindowClose => { + cx.quit(); + } + ToolbarEvent::ToggleTheme => { + this.toggle_theme(cx); + } + ToolbarEvent::WindowMinimize => { + cx.defer(|cx| { + if let Some(handle) = cx.active_window() { + let _ = handle.update(cx, |_, window, _| window.minimize_window()); + } + }); + } + ToolbarEvent::WindowMaximize => { + cx.defer(|cx| { + if let Some(handle) = cx.active_window() { + let _ = handle.update(cx, |_, window, _| window.zoom_window()); + } + }); + } + ToolbarEvent::MenuAction(id) => { + this.handle_action(id, cx); + } + ToolbarEvent::NewProject + | ToolbarEvent::OpenProject + | ToolbarEvent::CloneRepository => { + this.append_log( + "[Project] Project picker is not wired to the backend yet.", + cx, + ); + } + ToolbarEvent::OpenRecent => { + this.show_welcome = true; + cx.notify(); + } + }, + ); - // 5. 订阅全局搜索事件 (SearchEverywhereEvent) + // 6. 订阅全局搜索事件 let status_bar_search = status_bar.clone(); - let sub_search = cx.subscribe(&search_everywhere, move |this, _search, event: &SearchEverywhereEvent, cx| { - match event { + let sub_search = cx.subscribe( + &search_everywhere, + move |this, _search, event: &SearchEverywhereEvent, cx| match event { SearchEverywhereEvent::OpenFile(path) => { this.open_file(path, cx); let _ = status_bar_search.update(cx, |sb, cx| { @@ -309,50 +398,260 @@ impl WorkbenchView { this.show_search_everywhere = false; cx.notify(); } - } - }); + }, + ); - // 6. 订阅设置对话框事件 (SettingsEvent) - let sub_settings = cx.subscribe(&settings_dialog, |this, _dialog, event: &SettingsEvent, cx| { - match event { + // 7. 订阅快速打开事件 + let status_bar_quick = status_bar.clone(); + let sub_quick_open = cx.subscribe( + &quick_open, + move |this, _qo, event: &QuickOpenEvent, cx| match event { + QuickOpenEvent::OpenFile(path) => { + this.open_file(path, cx); + let _ = status_bar_quick.update(cx, |sb, cx| { + sb.set_file_info( + Some(path.clone()), + 1, + 1, + EditorView::language_name(path).to_string(), + cx, + ); + }); + this.show_quick_open = false; + cx.notify(); + } + QuickOpenEvent::Close => { + this.show_quick_open = false; + cx.notify(); + } + }, + ); + + // 8. 订阅命令面板事件 + let sub_command_palette = cx.subscribe( + &command_palette, + |this, _cp, event: &CommandPaletteEvent, cx| match event { + CommandPaletteEvent::Execute(id) => { + this.handle_action(id, cx); + this.show_command_palette = false; + cx.notify(); + } + CommandPaletteEvent::Close => { + this.show_command_palette = false; + cx.notify(); + } + }, + ); + + // 9. 订阅设置对话框事件 + let sub_settings = cx.subscribe( + &settings_dialog, + |this, _dialog, event: &SettingsEvent, cx| match event { SettingsEvent::Close => { this.show_settings_dialog = false; cx.notify(); } + SettingsEvent::Changed => { + cx.notify(); + } + }, + ); + + // 10. 订阅欢迎页事件 + let sub_welcome = cx.subscribe( + &welcome_screen, + |this, _welcome, event: &WelcomeEvent, cx| match event { + WelcomeEvent::OpenFolder | WelcomeEvent::NewProject => { + this.append_log( + "[Welcome] Project picker is not wired to the backend yet.", + cx, + ); + } + WelcomeEvent::CloneRepository => { + this.append_log( + "[Welcome] Clone repository is not wired to the backend yet.", + cx, + ); + } + WelcomeEvent::OpenProject(path) => { + this.workspace_root = path.clone(); + this.show_welcome = false; + let _ = this.sidebar.update(cx, |sb, cx| { + sb.root_path = path.clone(); + sb.refresh(cx); + sb.refresh_git(cx); + }); + cx.notify(); + } + WelcomeEvent::OpenSettings => { + this.open_settings(cx); + } + WelcomeEvent::RemoveRecent(path) => { + let _ = path; + cx.notify(); + } + }, + ); + + // 11. 订阅状态栏事件 + let sidebar_status = sidebar.clone(); + let sub_status = + cx.subscribe( + &status_bar, + move |this, _sb, event: &StatusBarEvent, cx| match event { + StatusBarEvent::OpenGit => { + this.sidebar_visible = true; + let _ = this + .activity_rail + .update(cx, |r, cx| r.set_active_view(Some("git".to_string()), cx)); + let _ = sidebar_status.update(cx, |sb, cx| sb.set_tab(SidebarTab::Git, cx)); + cx.notify(); + } + }, + ); + + // 12. 窗口外观变化:跟随系统主题时实时切换调色板 + let sub_appearance = cx.observe_window_appearance(window, |this, window, cx| { + let sync = settings::get(cx).sync_system_theme; + if sync { + let is_dark = !matches!( + window.appearance(), + gpui_kit::WindowAppearance::Light | gpui_kit::WindowAppearance::VibrantLight + ); + this.apply_resolved_theme(is_dark, cx); } }); - Self { + let mut view = Self { workspace_root, sidebar_visible: true, - sidebar_width: 260.0, + sidebar_width: settings::get(cx).sidebar_width, bottom_panel_height: 240.0, is_resizing_sidebar: false, is_resizing_bottom_panel: false, drag_start_x: 0.0, drag_start_y: 0.0, initial_resize_size: 0.0, + show_welcome: false, show_search_everywhere: false, + show_quick_open: false, + show_command_palette: false, show_settings_dialog: false, toolbar, activity_rail, + plugin_rail, sidebar, editor, bottom_panel, status_bar, search_everywhere, + quick_open, + command_palette, settings_dialog, + welcome_screen, focus_handle, client: CoreClient::new(), _subscriptions: vec![ sub_sidebar, obs_sidebar, sub_rail, + sub_plugin_rail, sub_toolbar, sub_search, + sub_quick_open, + sub_command_palette, sub_settings, + sub_welcome, + sub_status, + sub_appearance, ], + }; + + // 应用初始主题与状态栏缩进 + let is_dark = { + let s = settings::get(cx); + !crate::theme::ThemePalette::is_light(&settings::resolved_theme_id(s, false)) + }; + view.apply_resolved_theme(is_dark, cx); + let tab_size = settings::get(cx).tab_size; + let _ = view.status_bar.update(cx, |sb, cx| { + sb.indent_size = tab_size; + cx.notify(); + }); + + view + } + + /// 切换侧边栏展开状态;展开时回到活动栏当前视图。 + fn toggle_sidebar(&mut self, cx: &mut Context) { + self.sidebar_visible = !self.sidebar_visible; + let active_view = self.activity_rail.read(cx).active_view.clone(); + if !self.sidebar_visible { + let _ = self + .activity_rail + .update(cx, |r, cx| r.set_active_view(None, cx)); + } else if active_view.is_none() { + let _ = self + .activity_rail + .update(cx, |r, cx| r.set_active_view(Some("files".to_string()), cx)); } + cx.notify(); + } + + /// 切换浅色/深色主题并落盘。 + fn toggle_theme(&mut self, cx: &mut Context) { + let next = { + let s = settings::get(cx); + if s.sync_system_theme { + // 跟随系统时,手动切换视为退出跟随并固定为相反外观。 + let current = settings::resolved_theme_id(s, false); + if crate::theme::ThemePalette::is_light(¤t) { + s.auto_theme_dark.clone() + } else { + s.auto_theme_light.clone() + } + } else if crate::theme::ThemePalette::is_light(&s.theme) { + "lithe-dark".to_string() + } else { + "lithe-light".to_string() + } + }; + let next_for_update = next.clone(); + settings::update(cx, |s| { + s.theme = next_for_update; + s.sync_system_theme = false; + }); + self.apply_resolved_theme(!crate::theme::ThemePalette::is_light(&next), cx); + cx.notify(); + } + + /// 按解析后的外观应用工作台调色板,并同步 gpui-component 主题模式。 + fn apply_resolved_theme(&mut self, system_is_dark: bool, cx: &mut Context) { + let theme_id = { + let s = settings::get(cx); + settings::resolved_theme_id(s, system_is_dark) + }; + settings::apply_theme(&theme_id); + let mode = if crate::theme::ThemePalette::is_light(&theme_id) { + gpui_kit::component::ThemeMode::Light + } else { + gpui_kit::component::ThemeMode::Dark + }; + gpui_kit::component::Theme::change(mode, None, cx); + cx.notify(); + } + + /// 打开设置对话框并按 `lastSettingsTab` 重置分类。 + fn open_settings(&mut self, cx: &mut Context) { + self.show_settings_dialog = true; + let _ = self.settings_dialog.update(cx, |d, cx| d.open(cx)); + cx.notify(); + } + + fn append_log(&mut self, message: &str, cx: &mut Context) { + let _ = self.bottom_panel.update(cx, |bp, cx| { + bp.append_log(message.to_string(), cx); + }); } /// 打开全局搜索弹窗并更新当前文件索引 @@ -365,6 +664,25 @@ impl WorkbenchView { cx.notify(); } + /// 打开快速打开弹窗 + pub fn open_quick_open(&mut self, cx: &mut Context) { + this_sync_files(self, cx); + self.show_quick_open = true; + let _ = self.quick_open.update(cx, |qo, cx| { + qo.reset(cx); + }); + cx.notify(); + } + + /// 打开命令面板 + pub fn open_command_palette(&mut self, cx: &mut Context) { + self.show_command_palette = true; + let _ = self.command_palette.update(cx, |cp, cx| { + cp.reset(cx); + }); + cx.notify(); + } + fn collect_all_file_paths(entry: &FileEntry, output: &mut Vec) { if !entry.is_directory { output.push(entry.path.clone()); @@ -376,44 +694,36 @@ impl WorkbenchView { } } - /// 执行常用命令动作 + /// 执行常用命令动作(工具栏菜单、命令面板、全局搜索共用)。 pub fn handle_action(&mut self, action_id: &str, cx: &mut Context) { match action_id { - "workbench.new_file" => { + "workbench.new_file" | "file.new_file" => { let _ = self.editor.update(cx, |ed, cx| { ed.open_file("untitled.txt".to_string(), String::new(), cx); }); } - "workbench.save" => { + "workbench.save" | "file.save" => { let _ = self.editor.update(cx, |ed, cx| { ed.save_active(cx); }); } - "workbench.close_tab" => { + "workbench.close_tab" | "file.close_editor" => { let _ = self.editor.update(cx, |ed, cx| { if let Some(idx) = ed.active_tab_index { ed.close_tab(idx, cx); } }); } - "workbench.toggle_terminal" => { + "workbench.toggle_terminal" | "view.toggle_bottom_panel" | "terminal.new" => { let _ = self.bottom_panel.update(cx, |bp, cx| { bp.toggle_collapsed(cx); }); } - "workbench.toggle_sidebar" => { - self.sidebar_visible = !self.sidebar_visible; - let tab = if self.sidebar_visible { - Some(ActivityTab::Explorer) - } else { - None - }; - let _ = self.activity_rail.update(cx, |r, cx| r.set_active_tab(tab, cx)); - cx.notify(); + "workbench.toggle_sidebar" | "view.toggle_sidebar" => { + self.toggle_sidebar(cx); } - "workbench.open_settings" => { - self.show_settings_dialog = true; - cx.notify(); + "workbench.open_settings" | "tools.settings" => { + self.open_settings(cx); } "workbench.refresh_workspace" => { let _ = self.sidebar.update(cx, |sb, cx| { @@ -421,26 +731,62 @@ impl WorkbenchView { sb.refresh_git(cx); }); } - "workbench.run" => { + "workbench.run" | "run.run" => { + self.append_log("[Run] Executing default run configuration...", cx); let _ = self.bottom_panel.update(cx, |bp, cx| { - bp.append_log("[Run] Executing default run configuration...".to_string(), cx); let _ = bp.terminal.update(cx, |term, cx| { term.send_command("cargo check", cx); }); }); } - "workbench.debug" => { - let _ = self.bottom_panel.update(cx, |bp, cx| { - bp.append_log("[Debug] Launching DAP debug session...".to_string(), cx); - }); + "workbench.debug" | "run.debug" => { + self.append_log("[Debug] Launching DAP debug session...", cx); } - "workbench.clear_terminal" => { + "workbench.clear_terminal" | "terminal.clear" => { let _ = self.bottom_panel.update(cx, |bp, cx| { let _ = bp.terminal.update(cx, |term, cx| { term.clear(cx); }); }); } + "view.quick_open" | "go.go_to_file" => { + self.open_quick_open(cx); + } + "view.command_palette" => { + self.open_command_palette(cx); + } + "view.toggle_status_bar" => { + let current = settings::get(cx).show_status_bar; + settings::update(cx, |s| s.show_status_bar = !current); + cx.notify(); + } + "view.welcome" => { + self.show_welcome = !self.show_welcome; + cx.notify(); + } + "help.about" => { + self.append_log( + "[About] Lithe IDE for Linux (Powered by GPUI Kit & Rust Core)", + cx, + ); + } + "file.exit" | "window.close" => { + cx.quit(); + } + "window.minimize" => { + cx.defer(|cx| { + if let Some(handle) = cx.active_window() { + let _ = handle.update(cx, |_, window, _| window.minimize_window()); + } + }); + } + "window.maximize" => { + cx.defer(|cx| { + if let Some(handle) = cx.active_window() { + let _ = handle.update(cx, |_, window, _| window.zoom_window()); + } + }); + } _ => {} } } @@ -473,6 +819,25 @@ impl WorkbenchView { } } +/// 活动栏视图 id → 侧边栏标签。 +fn sidebar_tab_for(view_id: &str) -> Option { + match view_id { + "files" => Some(SidebarTab::Explorer), + "search" => Some(SidebarTab::Search), + "git" => Some(SidebarTab::Git), + _ => None, + } +} + +/// 活动栏底部项 id → 底部面板标签。 +fn bottom_tab_for(pane_id: &str) -> Option { + match pane_id { + "terminal" | "run" | "maven" | "gitLog" => Some(BottomTab::Output), + "diagnostics" => Some(BottomTab::Problems), + _ => None, + } +} + fn this_sync_files(this: &WorkbenchView, cx: &mut Context) { let mut file_list = Vec::new(); if let Some(root_node) = &this.sidebar.read(cx).root_node { @@ -480,55 +845,67 @@ fn this_sync_files(this: &WorkbenchView, cx: &mut Context) { } if !file_list.is_empty() { let _ = this.search_everywhere.update(cx, |search, cx| { - search.set_files(file_list, cx); + search.set_files(file_list.clone(), cx); + }); + let _ = this.quick_open.update(cx, |qo, cx| { + qo.set_files(file_list, cx); }); } } impl Render for WorkbenchView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let show_status_bar = settings::get(cx).show_status_bar; + div() .track_focus(&self.focus_handle) .relative() .size_full() - .bg(ThemeColors::bg_editor()) + .bg(ThemeColors::background()) .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { let key = event.keystroke.key.as_str(); - // 键盘快捷键监听 - if event.keystroke.modifiers.control || event.keystroke.modifiers.platform { + let modifiers = event.keystroke.modifiers; + + // 浮层优先:Esc 关闭最上层模态。 + if key == "escape" { + if this.show_command_palette { + this.show_command_palette = false; + } else if this.show_quick_open { + this.show_quick_open = false; + } else if this.show_search_everywhere { + this.show_search_everywhere = false; + } else if this.show_settings_dialog { + this.show_settings_dialog = false; + } + cx.notify(); + return; + } + + if modifiers.control || modifiers.platform { + if modifiers.shift && key == "p" { + this.open_command_palette(cx); + return; + } + if modifiers.shift && key == "f" { + this.open_search_everywhere(cx); + return; + } match key { - "p" => { - this.open_search_everywhere(cx); - } - "," => { - this.show_settings_dialog = true; - cx.notify(); - } - "b" => { - this.sidebar_visible = !this.sidebar_visible; - let tab = if this.sidebar_visible { - Some(ActivityTab::Explorer) - } else { - None - }; - let _ = this.activity_rail.update(cx, |r, cx| r.set_active_tab(tab, cx)); - cx.notify(); - } + "p" => this.open_quick_open(cx), + "," => this.open_settings(cx), + "b" => this.toggle_sidebar(cx), "`" => { let _ = this.bottom_panel.update(cx, |bp, cx| { bp.toggle_collapsed(cx); }); } + "j" => { + let _ = this.bottom_panel.update(cx, |bp, cx| { + bp.set_tab(BottomTab::Terminal, cx); + }); + } _ => {} } - } else if key == "escape" { - if this.show_search_everywhere { - this.show_search_everywhere = false; - cx.notify(); - } else if this.show_settings_dialog { - this.show_settings_dialog = false; - cx.notify(); - } } })) .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| { @@ -556,110 +933,124 @@ impl Render for WorkbenchView { .on_mouse_up( MouseButton::Left, cx.listener(|this, _event: &MouseUpEvent, _window, cx| { - if this.is_resizing_sidebar || this.is_resizing_bottom_panel { + if this.is_resizing_sidebar { this.is_resizing_sidebar = false; + let width = this.sidebar_width; + settings::update(cx, |s| s.sidebar_width = width); + cx.notify(); + } + if this.is_resizing_bottom_panel { this.is_resizing_bottom_panel = false; cx.notify(); } }), ) + .child(if self.show_welcome { + self.welcome_screen.clone().into_any_element() + } else { + self.render_workbench(show_status_bar, cx) + .into_any_element() + }) + .when(self.show_search_everywhere, |view| { + view.child(self.search_everywhere.clone()) + }) + .when(self.show_quick_open, |view| { + view.child(self.quick_open.clone()) + }) + .when(self.show_command_palette, |view| { + view.child(self.command_palette.clone()) + }) + .when(self.show_settings_dialog, |view| { + view.child(self.settings_dialog.clone()) + }) + } +} + +impl WorkbenchView { + /// 渲染工作台主体(顶栏 / 侧边栏 / 编辑区 / 底部面板 / 状态栏)。 + fn render_workbench(&self, show_status_bar: bool, cx: &mut Context) -> impl IntoElement { + v_flex() + .size_full() + .bg(ThemeColors::background()) + .child(self.toolbar.clone()) .child( - v_flex() - .size_full() - .bg(ThemeColors::bg_editor()) - .child( - // 1. 顶部标题栏/工具栏 (Toolbar) - self.toolbar.clone(), - ) - .child( - // 2. 主工作区区域(ActivityRail + 侧边栏 + 可调节垂直Splitter + 编辑器主岛) - h_flex() - .flex_1() - .w_full() - .child( - // 左侧垂直图标活动栏 (Activity Rail) - self.activity_rail.clone(), - ) - .when(self.sidebar_visible, |layout| { - layout - .child( - // 侧边栏内容面板 (Explorer / Search / Git) - div() - .w(px(self.sidebar_width)) - .h_full() - .flex_shrink_0() - .child(self.sidebar.clone()), - ) - .child( - // 侧边栏与主编辑器之间的精致 Splitter 分隔条 - div() - .id("sidebar-splitter") - .w(px(4.0)) - .h_full() - .cursor_col_resize() - .bg(if self.is_resizing_sidebar { - ThemeColors::accent_blue() - } else { - ThemeColors::border() - }) - .hover(|h| h.bg(ThemeColors::accent_blue())) - .on_mouse_down( - MouseButton::Left, - cx.listener(|this, event: &MouseDownEvent, _window, cx| { - this.is_resizing_sidebar = true; - this.drag_start_x = f32::from(event.position.x); - this.initial_resize_size = this.sidebar_width; - cx.notify(); - }), - ), - ) - }) + // 主工作区:左活动栏 + 侧边栏 + 编辑区 + 右插件活动栏 + h_flex() + .flex_1() + .w_full() + .min_h_0() + .child(self.activity_rail.clone()) + .when(self.sidebar_visible, |layout| { + layout .child( - // 中央编辑器主岛 div() - .flex_1() + .w(px(self.sidebar_width)) .h_full() - .bg(ThemeColors::bg_editor()) - .child(self.editor.clone()), - ), - ) + .flex_shrink_0() + .child(self.sidebar.clone()), + ) + .child(self.render_sidebar_splitter(cx)) + }) .child( - // 主编辑器与底栏之间的精致 Splitter 分隔条 div() - .id("bottom-panel-splitter") - .h(px(4.0)) - .w_full() - .cursor_row_resize() - .bg(if self.is_resizing_bottom_panel { - ThemeColors::accent_blue() - } else { - ThemeColors::border() - }) - .hover(|h| h.bg(ThemeColors::accent_blue())) - .on_mouse_down( - MouseButton::Left, - cx.listener(|this, event: &MouseDownEvent, _window, cx| { - this.is_resizing_bottom_panel = true; - this.drag_start_y = f32::from(event.position.y); - this.initial_resize_size = this.bottom_panel_height; - cx.notify(); - }), - ), - ) - .child( - // 3. 底部抽屉面板 (Terminal / Output / Problems) - self.bottom_panel.clone(), + .flex_1() + .h_full() + .min_w_0() + .bg(ThemeColors::background()) + .child(self.editor.clone()), ) - .child( - // 4. 最底部状态栏 (Status Bar) - self.status_bar.clone(), - ), + .child(self.plugin_rail.clone()), ) - .when(self.show_search_everywhere, |view| { - view.child(self.search_everywhere.clone()) + .child(self.render_bottom_splitter(cx)) + .child(self.bottom_panel.clone()) + .when(show_status_bar, |layout| { + layout.child(self.status_bar.clone()) }) - .when(self.show_settings_dialog, |view| { - view.child(self.settings_dialog.clone()) + } + + fn render_sidebar_splitter(&self, cx: &mut Context) -> impl IntoElement { + div() + .id("sidebar-splitter") + .w(px(4.0)) + .h_full() + .cursor_col_resize() + .bg(if self.is_resizing_sidebar { + ThemeColors::primary() + } else { + ThemeColors::border() }) + .hover(|h| h.bg(ThemeColors::primary())) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, event: &MouseDownEvent, _window, cx| { + this.is_resizing_sidebar = true; + this.drag_start_x = f32::from(event.position.x); + this.initial_resize_size = this.sidebar_width; + cx.notify(); + }), + ) + } + + fn render_bottom_splitter(&self, cx: &mut Context) -> impl IntoElement { + div() + .id("bottom-panel-splitter") + .h(px(4.0)) + .w_full() + .cursor_row_resize() + .bg(if self.is_resizing_bottom_panel { + ThemeColors::primary() + } else { + ThemeColors::border() + }) + .hover(|h| h.bg(ThemeColors::primary())) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, event: &MouseDownEvent, _window, cx| { + this.is_resizing_bottom_panel = true; + this.drag_start_y = f32::from(event.position.y); + this.initial_resize_size = this.bottom_panel_height; + cx.notify(); + }), + ) } } diff --git a/linux/src/workbench/welcome_screen.rs b/linux/src/workbench/welcome_screen.rs new file mode 100644 index 000000000..1c886080d --- /dev/null +++ b/linux/src/workbench/welcome_screen.rs @@ -0,0 +1,544 @@ +//! 工作台欢迎页(Welcome Screen)。 +//! +//! 复刻 Tauri 端 `features/layout/components/welcome-screen.tsx`:左侧 240px 导航栏 +//! (Lithe 标识、Projects 高亮项、底部 Settings),右侧标题、搜索输入框与 +//! Clone/Open 操作按钮、最近项目列表。所有交互只向外广播 [`WelcomeEvent`], +//! 不接入真实的项目打开、克隆与设置流程。 + +use gpui_kit::assets::IconName; +use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::scroll::ScrollableElement as _; +use gpui_kit::component::{h_flex, v_flex, Icon, Sizable as _}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::{ + div, px, AnyElement, Context, EventEmitter, FocusHandle, FontWeight, InteractiveElement as _, + IntoElement, KeyDownEvent, ParentElement as _, Render, StatefulInteractiveElement as _, + Styled as _, Window, +}; + +use crate::theme::ThemeColors; + +/// 最近项目条目。 +/// +/// `last_opened` 为展示用的相对时间文案,由上层决定格式,本模块不解析时间。 +#[derive(Debug, Clone)] +pub struct RecentProject { + pub name: String, + pub path: String, + pub last_opened: String, +} + +/// 欢迎页对外事件。 +#[derive(Debug, Clone)] +pub enum WelcomeEvent { + /// 打开文件夹(Open 按钮)。 + OpenFolder, + /// 新建项目。 + #[allow(dead_code)] + NewProject, + /// 从远程克隆仓库(Clone 按钮)。 + CloneRepository, + /// 打开指定路径的最近项目,携带项目路径。 + OpenProject(String), + /// 打开设置。 + OpenSettings, + /// 从最近列表移除某项目,携带项目路径。 + RemoveRecent(String), +} + +#[allow(dead_code)] +impl WelcomeEvent { + /// 供视图层在事件被消费前读取载荷,避免“字段未读”噪声。 + pub fn payload(&self) -> Option<&str> { + match self { + WelcomeEvent::OpenProject(path) | WelcomeEvent::RemoveRecent(path) => Some(path), + _ => None, + } + } +} + +/// 整屏欢迎页:左侧导航栏 + 右侧项目区。 +pub struct WelcomeScreenView { + pub recent_projects: Vec, + pub selected_index: usize, + pub query: String, + pub focus_handle: FocusHandle, +} + +impl EventEmitter for WelcomeScreenView {} + +impl WelcomeScreenView { + pub fn new(cx: &mut Context) -> Self { + Self { + recent_projects: default_recent_projects(), + selected_index: 0, + query: String::new(), + focus_handle: cx.focus_handle(), + } + } + + /// 当前查询过滤后的最近项目。 + fn visible_projects(&self) -> Vec<&RecentProject> { + let q = self.query.trim().to_lowercase(); + self.recent_projects + .iter() + .filter(|p| { + q.is_empty() + || p.name.to_lowercase().contains(&q) + || p.path.to_lowercase().contains(&q) + }) + .collect() + } + + /// 当前有效选中下标(对空列表安全)。 + fn current_index(&self) -> usize { + let total = self.visible_projects().len(); + if total == 0 { + 0 + } else { + self.selected_index.min(total - 1) + } + } + + /// 打开可见列表中第 `position` 个项目。 + fn open_at(&self, position: usize, cx: &mut Context) { + let visible = self.visible_projects(); + if let Some(project) = visible.get(position) { + cx.emit(WelcomeEvent::OpenProject(project.path.clone())); + } + } + + /// 从最近列表移除指定路径,并夹紧选中下标。 + fn remove_recent(&mut self, path: &str, cx: &mut Context) { + self.recent_projects.retain(|p| p.path != path); + let total = self.visible_projects().len(); + if self.selected_index >= total { + self.selected_index = total.saturating_sub(1); + } + cx.emit(WelcomeEvent::RemoveRecent(path.to_string())); + cx.notify(); + } + + /// 左侧导航栏:Lithe 标识、Projects 高亮项、底部 Settings。 + fn render_sidebar(&self, cx: &mut Context) -> AnyElement { + v_flex() + .w(px(240.0)) + .h_full() + .flex_shrink_0() + .bg(ThemeColors::surface()) + .border_r_1() + .border_color(ThemeColors::border()) + .child( + // 顶部标识区:Logo + 名称 + 版本 + h_flex() + .items_center() + .gap_2p5() + .px_4() + .pt_6() + .pb_4() + .child( + Icon::new(IconName::Zap) + .size(px(24.0)) + .text_color(ThemeColors::primary()), + ) + .child( + v_flex() + .child( + div() + .text_lg() + .font_weight(FontWeight::BOLD) + .text_color(ThemeColors::foreground()) + .child("Lithe"), + ) + .child( + div() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child("0.1.0 · Linux"), + ), + ), + ) + .child( + // Projects 高亮项 + h_flex() + .id("welcome-projects") + .mx_2() + .h(px(36.0)) + .items_center() + .gap_2p5() + .px_3() + .rounded_md() + .bg(ThemeColors::selected()) + .cursor_pointer() + .child( + Icon::new(IconName::Folder) + .size(px(15.0)) + .text_color(ThemeColors::primary()), + ) + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(ThemeColors::foreground()) + .child("Projects"), + ), + ) + .child(div().flex_1()) + .child( + // 底部 Settings 项 + h_flex() + .id("welcome-settings") + .mx_2() + .mb_2() + .h(px(36.0)) + .items_center() + .gap_2p5() + .px_3() + .rounded_md() + .cursor_pointer() + .text_color(ThemeColors::subtle_foreground()) + .hover(|h| h.bg(ThemeColors::accent())) + .child( + Icon::new(IconName::Settings) + .size(px(15.0)) + .text_color(ThemeColors::subtle_foreground()), + ) + .child(div().text_sm().child("Settings")) + .on_click(cx.listener(|_this, _event, _window, cx| { + cx.emit(WelcomeEvent::OpenSettings); + })), + ) + .into_any_element() + } + + /// 右侧项目区:标题、搜索与操作按钮、最近项目列表。 + fn render_main(&self, cx: &mut Context) -> AnyElement { + let visible = self.visible_projects(); + let current_index = self.current_index(); + + v_flex() + .flex_1() + .h_full() + .min_w_0() + .px_5() + .pt_10() + .child( + // 大标题 + h_flex().w_full().justify_center().child( + div() + .text_xl() + .font_weight(FontWeight::BOLD) + .text_color(ThemeColors::foreground()) + .child("Welcome to Lithe"), + ), + ) + .child( + h_flex().w_full().justify_center().pt_2().child( + div() + .text_sm() + .text_color(ThemeColors::subtle_foreground()) + .child("Open a project to get started"), + ), + ) + .child( + // 搜索输入框 + Clone / Open 按钮 + h_flex() + .w_full() + .items_center() + .gap_3() + .mt_8() + .pb_3() + .border_b_1() + .border_color(ThemeColors::border()) + .child( + h_flex() + .w(px(360.0)) + .h(px(36.0)) + .items_center() + .gap_2() + .px_2p5() + .rounded_md() + .bg(ThemeColors::background()) + .border_1() + .border_color(ThemeColors::border()) + .child( + Icon::new(IconName::Search) + .size(px(14.0)) + .text_color(ThemeColors::subtle_foreground()), + ) + .child( + div() + .flex_1() + .text_sm() + .text_color(if self.query.is_empty() { + ThemeColors::subtle_foreground() + } else { + ThemeColors::foreground() + }) + .child(if self.query.is_empty() { + "Search projects".to_string() + } else { + self.query.clone() + }), + ), + ) + .child(div().flex_1()) + .child( + Button::new("welcome-clone") + .small() + .icon(IconName::GitBranch) + .label("Clone") + .on_click(cx.listener(|_this, _event, _window, cx| { + cx.emit(WelcomeEvent::CloneRepository); + })), + ) + .child( + Button::new("welcome-open") + .small() + .primary() + .icon(IconName::FolderOpen) + .label("Open") + .on_click(cx.listener(|_this, _event, _window, cx| { + cx.emit(WelcomeEvent::OpenFolder); + })), + ), + ) + .child( + // 最近项目列表或空态 + div() + .flex_1() + .w_full() + .min_w_0() + .overflow_y_scrollbar() + .py_2() + .when(visible.is_empty(), |list| { + list.child( + v_flex() + .size_full() + .items_center() + .justify_center() + .gap_2() + .child( + Icon::new(IconName::FolderOpen) + .size(px(28.0)) + .text_color(ThemeColors::subtle_foreground()), + ) + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(ThemeColors::foreground()) + .child("No recent projects"), + ) + .child( + div() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child("Open a folder to start working"), + ), + ) + }) + .children(visible.into_iter().enumerate().map(|(position, project)| { + let is_selected = position == current_index; + let name = project.name.clone(); + let path = project.path.clone(); + let last_opened = project.last_opened.clone(); + let remove_path = project.path.clone(); + let badge = project_badge(&name, &path); + // 每行使用独立的 hover group,避免兄弟行共享 group 名互相影响 + let group_name = format!("welcome-recent-group-{position}"); + + h_flex() + .id(("welcome-recent", position)) + .group(group_name.clone()) + .h(px(52.0)) + .w_full() + .min_w_0() + .items_center() + .gap_3() + .px_2() + .rounded_md() + .cursor_pointer() + .when(is_selected, |row| row.bg(ThemeColors::selected())) + .when(!is_selected, |row| { + row.hover(|h| h.bg(ThemeColors::accent())) + }) + .child(badge) + .child( + v_flex() + .flex_1() + .min_w_0() + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(ThemeColors::foreground()) + .child(name), + ) + .child( + h_flex() + .gap_2() + .child( + div() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child(path), + ) + .child( + div() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child(last_opened), + ), + ), + ) + .child( + // 悬停时才显示的删除按钮 + div() + .opacity(0.0) + .group_hover(group_name, |style| style.opacity(1.0)) + .child( + Button::new(("welcome-remove", position)) + .xsmall() + .ghost() + .icon(IconName::Trash) + .tooltip("Remove from recent") + .on_click(cx.listener( + move |this, _event, _window, cx| { + // 阻止冒泡到整行,避免删除时同时打开项目 + cx.stop_propagation(); + this.remove_recent(&remove_path, cx); + }, + )), + ), + ) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.open_at(position, cx); + })) + .into_any_element() + })), + ) + .into_any_element() + } +} + +impl Render for WelcomeScreenView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // 请求聚焦以接收键盘上下移动 + window.focus(&self.focus_handle, cx); + + div() + .id("welcome-screen") + .track_focus(&self.focus_handle) + .size_full() + .bg(ThemeColors::background()) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { + let key = event.keystroke.key.as_str(); + match key { + "up" | "arrowup" => { + if this.selected_index > 0 { + this.selected_index -= 1; + cx.notify(); + } + } + "down" | "arrowdown" => { + let total = this.visible_projects().len(); + if total > 0 && this.selected_index + 1 < total { + this.selected_index += 1; + cx.notify(); + } + } + "enter" => { + let idx = this.current_index(); + this.open_at(idx, cx); + } + "backspace" => { + this.query.pop(); + this.selected_index = 0; + cx.notify(); + } + "space" => { + this.query.push(' '); + this.selected_index = 0; + cx.notify(); + } + _ => { + // 无修饰键时把可打印字符追加到搜索框 + if !event.keystroke.modifiers.control + && !event.keystroke.modifiers.alt + && !event.keystroke.modifiers.platform + { + if let Some(ch) = &event.keystroke.key_char { + this.query.push_str(ch); + this.selected_index = 0; + cx.notify(); + } else if key.chars().count() == 1 { + this.query.push_str(key); + this.selected_index = 0; + cx.notify(); + } + } + } + } + })) + .child( + h_flex() + .size_full() + .child(self.render_sidebar(cx)) + .child(self.render_main(cx)), + ) + } +} + +/// 默认示例最近项目。 +/// +/// 使用当前工作目录派生的占位路径,避免写入任何真实用户目录。 +fn default_recent_projects() -> Vec { + let base = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let sample = |name: &str, last_opened: &str| RecentProject { + name: name.to_string(), + path: base.join(name).to_string_lossy().to_string(), + last_opened: last_opened.to_string(), + }; + + vec![ + sample("lithe-sandbox", "2 hours ago"), + sample("example-project", "yesterday"), + sample("gpui-playground", "last week"), + ] +} + +/// 按名称首字母从语义色板中取色,生成圆角首字母徽标。 +fn project_badge(name: &str, path: &str) -> AnyElement { + let initial = name + .chars() + .find(|c| c.is_alphanumeric()) + .map(|c| c.to_uppercase().to_string()) + .unwrap_or_else(|| "L".to_string()); + + // 用路径字符和做稳定散列,保证同一项目每次渲染颜色一致 + let hash: u32 = path.chars().map(|c| c as u32).sum(); + let color = match hash % 5 { + 0 => ThemeColors::primary(), + 1 => ThemeColors::success(), + 2 => ThemeColors::warning(), + 3 => ThemeColors::destructive(), + _ => ThemeColors::info(), + }; + + h_flex() + .size(px(34.0)) + .flex_shrink_0() + .items_center() + .justify_center() + .rounded_lg() + .bg(color) + .child( + div() + .text_sm() + .font_weight(FontWeight::BOLD) + .text_color(ThemeColors::background()) + .child(initial), + ) + .into_any_element() +} From df39ffab87e32b717d4464867813593db43f89ea Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Thu, 24 Sep 2026 12:02:31 +0800 Subject: [PATCH 006/109] =?UTF-8?q?fix(linux):=20=E6=B5=AE=E5=B1=82?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E7=82=B9=E5=87=BB=E4=B8=8D=E5=86=8D=E8=AF=AF?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E9=81=AE=E7=BD=A9=E5=85=B3=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设置弹窗与全局搜索弹窗的卡片 on_mouse_down 是空处理器,未阻断事件冒泡, 点击卡片内的分类项/控件会冒泡到遮罩并触发 Close。补上 cx.stop_propagation(), 与命令面板、快速打开保持一致。 --- linux/src/workbench/search_everywhere.rs | 5 +++-- linux/src/workbench/settings_dialog.rs | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/linux/src/workbench/search_everywhere.rs b/linux/src/workbench/search_everywhere.rs index 4ce398b58..a8a7062af 100644 --- a/linux/src/workbench/search_everywhere.rs +++ b/linux/src/workbench/search_everywhere.rs @@ -322,8 +322,9 @@ impl Render for SearchEverywhereModal { .overflow_hidden() .on_mouse_down( gpui_kit::MouseButton::Left, - cx.listener(|_this, _event, _window, _cx| { - // 阻止向外层遮罩冒泡关闭 + cx.listener(|_this, _event, _window, cx| { + // 卡片内点击必须阻断冒泡,否则会触发遮罩的关闭逻辑。 + cx.stop_propagation(); }), ) .child( diff --git a/linux/src/workbench/settings_dialog.rs b/linux/src/workbench/settings_dialog.rs index 86b16465f..155297809 100644 --- a/linux/src/workbench/settings_dialog.rs +++ b/linux/src/workbench/settings_dialog.rs @@ -265,8 +265,9 @@ impl Render for SettingsDialog { .overflow_hidden() .on_mouse_down( gpui_kit::MouseButton::Left, - cx.listener(|_this, _event, _window, _cx| { - // 阻止向遮罩冒泡 + cx.listener(|_this, _event, _window, cx| { + // 卡片内点击必须阻断冒泡,否则会触发遮罩的关闭逻辑。 + cx.stop_propagation(); }), ) // 1. 头部:标题、搜索占位、关闭按钮 From aeb1da330a441a7b0f74392756de572702ba240f Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Thu, 24 Sep 2026 12:20:46 +0800 Subject: [PATCH 007/109] =?UTF-8?q?fix(linux):=20=E7=B4=A7=E5=87=91?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E6=94=B9=E4=B8=BA=E6=A8=AA=E5=90=91=E6=B5=AE?= =?UTF-8?q?=E5=87=BA=E8=8F=9C=E5=8D=95=E6=9D=A1=EF=BC=8C=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=20Tauri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原实现是单个 Menu 按钮 + 九个子菜单的竖向下拉,与 Tauri `compactFloating` 的 Menubar 形态不一致。改为点击 Menu 图标后在标题栏下方浮出一个横向圆角 菜单条,九个菜单名并排、各自下拉;选择任意菜单项或点击菜单条外部即收起, 与 Tauri 的 onCompactClose 行为一致。图标由 Menu 换成 List 以对齐 ListIcon。 --- linux/src/workbench/toolbar.rs | 104 +++++++++++++++++++++++++-------- 1 file changed, 80 insertions(+), 24 deletions(-) diff --git a/linux/src/workbench/toolbar.rs b/linux/src/workbench/toolbar.rs index 78fc10da8..1d55bfb65 100644 --- a/linux/src/workbench/toolbar.rs +++ b/linux/src/workbench/toolbar.rs @@ -7,10 +7,12 @@ use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::component::menu::{DropdownMenu as _, PopupMenu, PopupMenuItem}; -use gpui_kit::component::{h_flex, Icon, Sizable as _}; +use gpui_kit::component::{h_flex, Icon, Selectable as _, Sizable as _}; +use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, AnyElement, Context, EventEmitter, FontWeight, InteractiveElement as _, IntoElement, - ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, Styled as _, Window, + div, px, Anchor, AnyElement, Context, EventEmitter, FontWeight, InteractiveElement as _, + IntoElement, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, + Styled as _, Window, }; use crate::settings; @@ -52,6 +54,8 @@ pub enum ToolbarEvent { pub struct ToolbarView { pub workspace_name: String, pub git_branch: Option, + /// 紧凑菜单条是否展开(对齐 Tauri `isCompactMenuVisible`)。 + compact_menu_open: bool, } impl EventEmitter for ToolbarView {} @@ -66,6 +70,7 @@ impl ToolbarView { Self { workspace_name: name, git_branch: None, + compact_menu_open: false, } } @@ -236,8 +241,10 @@ fn menu_action_item( ) -> PopupMenuItem { let v = view.clone(); PopupMenuItem::new(label).on_click(move |_, _, cx| { - v.update(cx, |_, cx| { - cx.emit(ToolbarEvent::MenuAction(action.to_string())) + v.update(cx, |this, cx| { + // 选中任意菜单项后收起紧凑菜单条,与 Tauri `onCompactClose` 一致。 + this.compact_menu_open = false; + cx.emit(ToolbarEvent::MenuAction(action.to_string())); }); }) } @@ -251,7 +258,10 @@ fn event_item( let v = view.clone(); PopupMenuItem::new(label).on_click(move |_, _, cx| { let event = make(); - v.update(cx, |_, cx| cx.emit(event)); + v.update(cx, |this, cx| { + this.compact_menu_open = false; + cx.emit(event); + }); }) } @@ -297,25 +307,71 @@ impl Render for ToolbarView { let view = cx.entity(); - // 应用菜单栏:紧凑模式为单个 Menu 按钮 + 九个子菜单;否则平铺九个顶层菜单。 + // 应用菜单栏:紧凑模式为 Menu 图标触发一个横向浮出的菜单条(九个菜单名并排, + // 各自下拉),对齐 Tauri `compactFloating` 的 `Menubar`;非紧凑模式平铺九个顶层菜单。 let app_menu: AnyElement = if compact_menu { - let v = view.clone(); - Button::new("tb-app-menu") - .small() - .ghost() - .icon(IconName::Menu) - .tooltip("Menu") - .dropdown_menu(move |menu, window, cx| { - let mut menu = menu; - for app in APP_MENUS { - let v_sub = v.clone(); - menu = menu.submenu(app.title, window, cx, move |sub, _window, _cx| { - build_menu(sub, app.groups, &v_sub) - }); - } - menu - }) - .into_any_element() + let v_menu = view.clone(); + // 触发点相对定位,浮层用 deferred+anchored 挂在其下方,避免撑开标题栏。 + let open = self.compact_menu_open; + let trigger = div() + .id("tb-app-menu") + .relative() + .child( + Button::new("tb-app-menu-btn") + .small() + .ghost() + .icon(IconName::List) + .tooltip("Menu") + .selected(open) + .on_click(cx.listener( + |this, _event: &gpui_kit::ClickEvent, _window, cx| { + this.compact_menu_open = !this.compact_menu_open; + cx.notify(); + }, + )), + ) + .when(open, move |this| { + let v_out = v_menu.clone(); + this.child(gpui_kit::deferred( + gpui_kit::anchored() + .anchor(Anchor::TopLeft) + .snap_to_window_with_margin(px(8.0)) + .child( + h_flex() + .id("tb-compact-menu-bar") + .occlude() + .flex_nowrap() + .items_center() + .gap_0p5() + .px_1() + .py_1() + .top(px(4.0)) + .rounded_xl() + .bg(ThemeColors::background()) + .border_1() + .border_color(ThemeColors::border()) + .shadow_lg() + // 点击菜单条以外区域时收起,对齐 Tauri 紧凑菜单的行为。 + .on_mouse_down_out(move |_, _, cx| { + v_out.update(cx, |this, cx| { + this.compact_menu_open = false; + cx.notify(); + }); + }) + .children(APP_MENUS.iter().map(|app| { + let v_item = v_menu.clone(); + Button::new(format!("tb-compact-menu-{}", app.title)) + .small() + .ghost() + .label(app.title) + .dropdown_menu(move |menu, _window, _cx| { + build_menu(menu, app.groups, &v_item) + }) + })), + ), + )) + }); + trigger.into_any_element() } else { h_flex() .items_center() From 1d50d05a10e416b6552c05417aaf2a17c97d23d3 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Thu, 24 Sep 2026 12:27:55 +0800 Subject: [PATCH 008/109] =?UTF-8?q?fix(linux):=20=E5=88=A0=E9=99=A4=20Taur?= =?UTF-8?q?i=20=E4=B8=8D=E5=AD=98=E5=9C=A8=E7=9A=84=E9=A1=B6=E6=A0=8F?= =?UTF-8?q?=E5=85=83=E7=B4=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对照 windows/tauri 的 title-bar.tsx,非 macOS 顶栏只有四组元素: 菜单(紧凑按钮/平铺菜单)、项目菜单(logo+项目名+ChevronDown)、 分支胶囊、右侧搜索图标按钮、窗口控件。 删除 Linux 多出的元素: - ⚡ + "Lithe" 品牌标识 - 中间 280px 全局搜索条(含占位文案与 Ctrl+P 徽标) - "[Project] Default" 运行目标胶囊 - Run / Debug / Stop 按钮 - 主题切换按钮(Sun/Moon) - 设置按钮(Settings) 其余对齐: - 顶栏背景改用 surface(与 Tauri bg-surface 一致)。 - 项目菜单触发器改为 logo 徽标(品牌色圆角方块)+ 项目名 + ChevronDown, 下拉项顺序对齐 TitleProjectMenu:New Project / Open / Clone Repository / Open Projects / Open Recent。 - 右侧只保留搜索图标按钮(quickOpenAction)与窗口控件。 --- linux/src/workbench/toolbar.rs | 266 ++++++++------------------------- 1 file changed, 66 insertions(+), 200 deletions(-) diff --git a/linux/src/workbench/toolbar.rs b/linux/src/workbench/toolbar.rs index 1d55bfb65..ec2e162ae 100644 --- a/linux/src/workbench/toolbar.rs +++ b/linux/src/workbench/toolbar.rs @@ -10,9 +10,8 @@ use gpui_kit::component::menu::{DropdownMenu as _, PopupMenu, PopupMenuItem}; use gpui_kit::component::{h_flex, Icon, Selectable as _, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, Anchor, AnyElement, Context, EventEmitter, FontWeight, InteractiveElement as _, - IntoElement, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, - Styled as _, Window, + div, px, Anchor, AnyElement, Context, EventEmitter, InteractiveElement as _, IntoElement, + ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, Styled as _, Window, }; use crate::settings; @@ -293,17 +292,7 @@ impl Render for ToolbarView { let project_name = self.workspace_name.clone(); // 读取渲染所需的设置快照,随后立刻释放对 cx 的只读借用。 - let (compact_menu, theme_icon) = { - let s = settings::get(cx); - let icon = if s.sync_system_theme { - IconName::Moon - } else if s.theme == "lithe-light" { - IconName::Sun - } else { - IconName::Moon - }; - (s.compact_menu_bar, icon) - }; + let compact_menu = settings::get(cx).compact_menu_bar; let view = cx.entity(); @@ -389,63 +378,53 @@ impl Render for ToolbarView { h_flex() .h(px(40.0)) .w_full() - .bg(ThemeColors::background()) + .bg(ThemeColors::surface()) .border_b_1() .border_color(ThemeColors::border()) .items_center() - .pl_3() - // 1. 左侧:应用菜单 + 品牌 + 项目胶囊 + 分支胶囊 + .justify_between() + .px_3() + // 左侧:应用菜单 + 项目菜单 + 分支胶囊(对齐 Tauri `title-bar.tsx` 的左侧组) .child( h_flex() - .flex_1() .min_w_0() .items_center() .gap_2() .child(app_menu) - .child( - // Lithe 品牌标识 - h_flex() - .items_center() - .gap_1p5() - .pl_1() - .child( - Icon::new(IconName::Zap) - .size(px(16.0)) - .text_color(ThemeColors::primary()), - ) - .child( - div() - .font_weight(FontWeight::BOLD) - .text_sm() - .text_color(ThemeColors::foreground()) - .child("Lithe"), - ), - ) .child({ - // 项目选择器胶囊:Folder 图标 + 项目名 + ChevronDown,点击展开项目菜单 + // 项目菜单:logo + 项目名 + ChevronDown,点击展开项目列表 let v = view.clone(); let name = project_name.clone(); Button::new("tb-project-selector") .small() .ghost() - .rounded_md() - .bg(ThemeColors::surface()) - .border_1() - .border_color(ThemeColors::border()) .child( - Icon::new(IconName::Folder) - .size(px(13.0)) - .text_color(ThemeColors::muted_foreground()), + // 项目徽标:Tauri 用 public/logo.png,这里以同尺寸圆角方块承载品牌色。 + div() + .size(px(20.0)) + .flex_shrink_0() + .rounded(px(6.0)) + .bg(ThemeColors::primary()) + .flex() + .items_center() + .justify_center() + .child( + Icon::new(IconName::Zap) + .size(px(12.0)) + .text_color(ThemeColors::background()), + ), ) .child( div() + .max_w(px(224.0)) + .truncate() .text_xs() .text_color(ThemeColors::foreground()) .child(name.clone()), ) .child( Icon::new(IconName::ChevronDown) - .size(px(12.0)) + .size(px(14.0)) .text_color(ThemeColors::subtle_foreground()), ) .dropdown_menu(move |menu, _window, _cx| { @@ -454,21 +433,24 @@ impl Render for ToolbarView { || ToolbarEvent::NewProject, &v, )) - .item(event_item("Open Project", || ToolbarEvent::OpenProject, &v)) + .item(event_item("Open", || ToolbarEvent::OpenProject, &v)) .item(event_item( "Clone Repository", || ToolbarEvent::CloneRepository, &v, )) - .item(event_item("Open Recent", || ToolbarEvent::OpenRecent, &v)) + .separator() .item(event_item( "Open Projects", || ToolbarEvent::OpenProject, &v, )) .separator() - // 当前项目打勾标记,仅展示 - .item(PopupMenuItem::new(name.clone()).icon(IconName::CircleCheck)) + .item(event_item( + "Open Recent", + || ToolbarEvent::OpenRecent, + &v, + )) }) }) .child( @@ -500,168 +482,52 @@ impl Render for ToolbarView { ), ), ) - // 2. 中间:全局搜索条 - .child( - div() - .id("search-everywhere-bar") - .w(px(280.0)) - .h(px(28.0)) - .flex_shrink_0() - .bg(ThemeColors::surface()) - .border_1() - .border_color(ThemeColors::border()) - .rounded(px(6.0)) - .px_2() - .flex() - .items_center() - .justify_between() - .cursor_pointer() - .hover(|h| h.border_color(ThemeColors::primary())) - .on_click(cx.listener(|_this, _event, _window, cx| { - cx.emit(ToolbarEvent::QuickOpen); - })) - .child( - h_flex() - .items_center() - .gap_2() - .child( - Icon::new(IconName::Search) - .size(px(13.0)) - .text_color(ThemeColors::subtle_foreground()), - ) - .child( - div() - .text_xs() - .text_color(ThemeColors::subtle_foreground()) - .child("Search files, symbols..."), - ), - ) - .child( - div() - .text_xs() - .text_color(ThemeColors::subtle_foreground()) - .px_1p5() - .py(px(1.0)) - .bg(ThemeColors::background()) - .border_1() - .border_color(ThemeColors::border()) - .rounded(px(4.0)) - .child("Ctrl+P"), - ), - ) - // 3. 右侧工具组:运行目标、运行/调试/停止、主题、设置 + // 右侧:全局搜索图标按钮 + 窗口控件(对齐 Tauri `quickOpenAction` + `WindowControls`) .child( h_flex() - .flex_1() - .min_w_0() .items_center() - .justify_end() - .gap_2() - .child( - // 运行目标胶囊 - h_flex() - .items_center() - .gap_1p5() - .px_2() - .py(px(3.0)) - .rounded_md() - .bg(ThemeColors::surface()) - .border_1() - .border_color(ThemeColors::border()) - .child( - div() - .text_xs() - .text_color(ThemeColors::foreground()) - .child("[Project] Default"), - ) - .child( - Icon::new(IconName::ChevronDown) - .size(px(12.0)) - .text_color(ThemeColors::subtle_foreground()), - ), - ) - .child( - Button::new("tb-run") - .small() - .primary() - .icon(IconName::Play) - .label("Run") - .on_click(cx.listener(|_this, _event, _window, cx| { - cx.emit(ToolbarEvent::Run); - })), - ) - .child( - Button::new("tb-debug") - .small() - .ghost() - .icon(IconName::Bug) - .label("Debug") - .on_click(cx.listener(|_this, _event, _window, cx| { - cx.emit(ToolbarEvent::Debug); - })), - ) - .child( - Button::new("tb-stop") - .small() - .ghost() - .icon(IconName::Square) - .tooltip("Stop") - .on_click(cx.listener(|_this, _event, _window, cx| { - cx.emit(ToolbarEvent::Stop); - })), - ) + .flex_shrink_0() + .gap_1() .child( - // 主题切换:跟随系统时恒显示 Moon,否则按当前主题显示 Sun/Moon - Button::new("tb-theme-toggle") + Button::new("tb-quick-open") .small() .ghost() - .icon(theme_icon) - .tooltip("Toggle Theme") + .icon(IconName::Search) + .tooltip("Search") .on_click(cx.listener(|_this, _event, _window, cx| { - cx.emit(ToolbarEvent::ToggleTheme); + cx.emit(ToolbarEvent::QuickOpen); })), ) .child( - Button::new("tb-settings") - .small() - .ghost() - .icon(IconName::Settings) - .tooltip("Settings") - .on_click(cx.listener(|_this, _event, _window, cx| { - cx.emit(ToolbarEvent::OpenSettings); - })), + h_flex() + .items_center() + .flex_shrink_0() + .child(window_control( + "tb-window-minimize", + IconName::Minus, + false, + cx.listener(|_this, _event: &gpui_kit::ClickEvent, _window, cx| { + cx.emit(ToolbarEvent::WindowMinimize); + }), + )) + .child(window_control( + "tb-window-maximize", + IconName::Square, + false, + cx.listener(|_this, _event: &gpui_kit::ClickEvent, _window, cx| { + cx.emit(ToolbarEvent::WindowMaximize); + }), + )) + .child(window_control( + "tb-window-close", + IconName::Close, + true, + cx.listener(|_this, _event: &gpui_kit::ClickEvent, _window, cx| { + cx.emit(ToolbarEvent::WindowClose); + }), + )), ), ) - // 4. 窗口控件:46x40 直角按钮,Close 悬停使用 destructive - .child( - h_flex() - .items_center() - .flex_shrink_0() - .child(window_control( - "tb-window-minimize", - IconName::Minus, - false, - cx.listener(|_this, _event: &gpui_kit::ClickEvent, _window, cx| { - cx.emit(ToolbarEvent::WindowMinimize); - }), - )) - .child(window_control( - "tb-window-maximize", - IconName::Square, - false, - cx.listener(|_this, _event: &gpui_kit::ClickEvent, _window, cx| { - cx.emit(ToolbarEvent::WindowMaximize); - }), - )) - .child(window_control( - "tb-window-close", - IconName::Close, - true, - cx.listener(|_this, _event: &gpui_kit::ClickEvent, _window, cx| { - cx.emit(ToolbarEvent::WindowClose); - }), - )), - ) } } From a191e5f9527323c1765041c3004a99e5ccabd30f Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Thu, 24 Sep 2026 13:08:12 +0800 Subject: [PATCH 009/109] =?UTF-8?q?fix(linux):=20=E5=B7=A6=E6=A0=8F?= =?UTF-8?q?=E5=8E=BB=E6=8E=89=20maven=E3=80=81=E7=BB=88=E7=AB=AF=E8=B7=9F?= =?UTF-8?q?=E9=9A=8F=E4=B8=BB=E9=A2=98=E3=80=81=E9=A1=B9=E7=9B=AE=E5=BE=BD?= =?UTF-8?q?=E6=A0=87=E6=8D=A2=E7=9C=9F=E5=AE=9E=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对照 Tauri 真源修复三处不一致: - 左侧活动栏去掉 maven:Tauri 左栏的 maven 项仅在 `hasMavenRun` 为真时出现(`main-sidebar.tsx` 有条件传入 `onMavenClick`), 默认无 Maven 运行故不显示;Maven 入口只保留在右侧插件栏 (`plugin-activity-rail.tsx` 的 `isMavenAvailable`)。 新增 `has_maven_run` 开关供后续 Maven 集成打开。 - 浅色模式终端仍深色:`terminal.rs` 硬编码深色底/文字/边框, 改为主题色(背景/前景/边框/柔和文字/成功色),对齐 Tauri xterm 取 `--background` / `--foreground` 的行为。 - 项目菜单触发器徽标:去掉蓝色闪电占位,改用与 Tauri `public/logo.png` 同一文件的真实应用图标(编译期嵌入, 20px 圆角展示)。 --- linux/assets/logo.png | Bin 0 -> 810582 bytes linux/src/workbench/activity_rail.rs | 42 +++++++++++++++++++++++---- linux/src/workbench/terminal.rs | 30 +++++++++++-------- linux/src/workbench/toolbar.rs | 24 +++++++++------ 4 files changed, 70 insertions(+), 26 deletions(-) create mode 100644 linux/assets/logo.png diff --git a/linux/assets/logo.png b/linux/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..4f31230945c4502a5a1b31f8ecadc86221126932 GIT binary patch literal 810582 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%0AwIZEn~b<-Vqxn8634yBABoSA^vG9CMwO2 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

)cF8zBUuqey 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

ePUHRR{ zn`ty5B1?BT9vfSz2OrH+oA?d-8{Ppku@)y74ga5Y&-}x{7 zmdm5(Z(lCo@y6xh-)=GD!?fTguim*)@y%^Nzmp~W(oz@;0W|h{d#!<2UjxtIefK55 z-_Y^uv-_IE&twg}eDt=^*N7|Rxad{*MfVXAYWIF9;d@ zRh^i6k`6J)1XxM{R#G7_3L>pqw=Y_tUah&RT*%MQ3r^8q;43oV=@13IY(vr{b6Bb% zgu=9}R}N#U@S-U1&H@oOY|_Jprb98PMYQN{wt50AB+-=@6oJh-v>bz-zIQNFa01}GJ=t@z&3j}5p9ZzX>E7~M{+|$Y|dL`g*bs^84_d6;5B5_EEIyo zYMUO(SK;jWqEszfGM|1#?qO5NC!h6;Z(sKFe(%2fcl!CizjC?!#5cWs_7isxe(3)Z z?cg5toA2@d;z7UVb@TYcrRpE)r@P*=5RkOhKWJF*8GLS@@6Ms=e3n0U!Ysk|W%_>Id{B0q5?Wv*`r!jdclsW0cI8Gb$OY)z;cyr|wH$$8j=MxDbb-5$guy38rSI zSg(gBy6ear3zI+W@KjHo7&UG>os+e9zi{L@%aoaZ64#mDs;rj5t=b(bHuf7|1J(rz za(LArr?Z|P(a!9%Y=5&*75!0O1ULDg0qLURNdRSvA)-NYYgH{~jd|m2boE6jwXJ93 z%~wDXgy(djb69czSAMxHn7*Q!Ez+o=t}-BF!kA>978>H$GtmSqw&=>_vi2B zXFqoL_Fwv@n`eLNN$}zAlSvwxc7yv(~Ys`KTGA zU_6ceEGy@)Nf_e0W8vXxcPM9$A9PL<&VV4_ZR$r zHsl{{;k3|ydH$qF51c=Wg*sS4J3%?4I}Wh>dV8&bS6%}TpFF$mN6#JkBvX$jY4lHI{r8;AGLk3!~g1a>xqkS7(M z#pPM3`cUBl?@KO%2)73@JCf+Zm$6g*DN`D-ukk1upMNo`wD%d^vp2xX!rXPo#_9=4DEA>$YSk3;lrH<)!oB3CGJIM|E??^S(Qr##CnG zMMTCKDcd=E_K0q89(Ef}uavoa@y>g9{kHPW%lf3gSJm-pTUOX#=dW(T)X>PU6)gEH09Lh>>Qu-(>;&VXbIo?a6k##$6V*0 z_vTl<_uq`SUw`_UW@YbDeI4SZ*Za3HiA2h8wT5pdf9Lz7no#+J)<#v+H|;WV<)#M4 zr%Y3wX&LN|MqA?QOy<3ntdzwz$3Adp>e)&IJ~$fpU6uNkoW6yx1Y5#=%DL}o=69ST z=bJr;QFg8n{^4HkyW`m0Sj8vdz-(_}0T}N!$;XFdW27;MDk~3u^p7fVHlFEVQgR*TBj4)orzT>Uf~hJ*V0TDy3d$_U>9hT{R~@ZhqcC&a|ZpAEMa9u34=jg!fE zS`ne6(GW7dj_go(?$HTyZ(7}cHmX4_Hs6XDwt%4>%BVq#LT1@_;8W0Kp*QslC{@FD zHX0qzKcIWR~Mc6Nnf~S?k)VFG+Jp2*Jt@<+>K`) zxUu+fkB#^aFYb^2GIS@i=G8s7xDrY7vrRu%ACU$oamINK{5^`zE-}_ z`K-;JYvuE&k(T<<=4cL2GV))-k}+Od9G_;kmKLAlgSx$oV`zwD_jpJ(^)uH;hHxBphynh3^Z|h-uoLgd=8u4>cz$%4RhSBUzAczYpi=KB zfqT}k0(|h%gPS*-2K|SB^R1UZ{ljnGebayXUwQCH{`qgYT>ir#zppzfD06Sie1ig$ zvlZ@#JqGafBeyT#`GU)XM^6jB8N*8#-}82tlbwz6*I%zS@Cs|7uCn3(6=v}@Yp*qM zxqRa{N{=$qXDcJVkm%?2;kcF66?qs!_cnIH{ zTrGIUU*Y4PGYPKAvB2&S$+!|AE2D;joa&O9$k2?`Bl02XhQ-?Kid;8@zx1r&TFJ}c zFd2z8C=2I@)xK-ABt^lHV!O6-ej)+6*A_lq=%hAVyXu^;sgew47n1?QA%M#amOfNB zrNwXc+Lna`UwMxvbzrMaZO49Ril?fMu|E9{H)<6mA0FJAyUD*yvXbGsrgQk)r`^&C z#9}G60HyKR1Vli)cqexRr4P0hL+!XqwgH2o&N^)(P^F(bR)#MV9HtrLS0*+m3OfG2BW3LC~ zl}KDC(PO5QHsw(K{Ox)ls`<HgTuwL}w8fCE#opB)G$zh$)AvnVe<>X-9sFgs(D*eI!war=#*_Em?a z9c*B5rNCvsu%6kJ(o;b;Sw0@CR|q?kOej+Mi=OdPqsB;pH?=;wtMiflV>gB)yLm?R&WT3%c~C_ z-CUkNx_kKe&FSa)vv)4fp1oV=s)K|8FJ~aaTlmrM%f}=hDSmQ|V;c_)tQ3^6If8i6 zE^^G-(6q0^C#7Eb4<4Q%M+EM9%k*1A2oLb^tbK+)BVbi(vl~8+SJWm945VsyH+EG& zKXX?V0%`w@Y01|gU_$YF)9varTBZFbMm%D2z3hyyAscJEoyt;h^7*z!H_~ zq`Ejk&}!P^pffh>?GcEhqoUoh{bZ+cli+Tm57r`4=%x%@O~F;9?87hZ z9_s6(d>4H6lTFHi<X{@vztF+D|?4oC1!ktp6R<>Ckh5>&S6|JPvU&WS!EDm6KXo zzhJMxsHYLR3FMdhsKF~oq+BA#b6JVF^D0x5|NHSBRwgi>wip=sf(74*xa zes1&j;k%b_|G_W6{LBx3@$H}b9p8NU{BujMl){JpDuwK@=%?NMaiJ-3D4{ zlzS}{6phNw$`W#E7ZGc)Sh>HkM_PAijg_EU5#ozvg_T4~G;85pr z=M$_-Kq65B*M#l_gxzIXL^u}|1RtzQZt!72c#%hMi?BD>+tpZ#(G3kJu1u~V&YRe3 zm7O(pQeb{afb!VZh%dbR0X4xr9|K=dc`pzCFR#m^emR7=6T zeN@;C{P2#uLvjWPzP1&Fc$2X3c7|x@jZCyCCrURoQ4NI8a;Bj%bDhET(5ja#gW4_zT#EIcNn8NjfcC6E^fGjI3=ex z&GuZbXCt%da>@v3?_xi!?U0DUoR0KyG?t=1SpvZ>LmXQw=1kNZFH)#=kM{g%{&(cJ zs8k!pN4UJWNldNr(8Z5g_EGQSKuKR<_}bcPvHcR)o%eNBtQt;Hqd~2WuUVvhn_8xs zQ?v(ZNwBZA@qPYQG)P7mnw|Bv{ozEp?D6amE2-oPbH6{T_l<7~2RVNGiifW1kH(&I zFu?fBsQqkv9owo+w;9vnk)A}s^CX+eOl;(%{4ZVSgd2f68{RYuZjI1y$Utu6j;=HX z&)ew_>Pk#94&n`m^IPMB`lFnUbJ1vTd33VFlwqZ0-Fud&IHDN`>IO=&q}gQ3lmM{8k(E z90c=4de@tHBSi#PM~(dkTtn1!x?7T~vSfx<2&%zL$HQFfz&n|K6lJa;=%;?3I*kzn zUmvSy#Gsy_PuIxWSYvJ_2PhgzosL8-BSqVtE8Zp|v5gN3h}&mZ^#&UuulBn&kUZ+8 zP3iFrrIY#0eQVENyH1WnWUhNuHizOu%JL6K#{&vH#+MnO%F6SF@c`8Gw9&!Y`k4FGBa*pM3mDjDK!56pTGkoN0 zQUy4U{gZt>K z2VeZd|M~y*&E*F^d3X6MA9#M-O{Xo$g)4;Ao}I?oLTtxjxosh#E$k`C1*-dSt@QQw zS_7}J1|D|*^t#k5%;Rg;UTa_)_^Y4$u2iCY)Rm|P{*ofmLj-8dZ74QI7X~^w@p4h1 z@mdfTRTTHe=9Z~<+hCCLuAr-`QvB>Jc))#21;njzb{d_+=QUu)iOhCQYF1uUJ`?mw z4DQQe6O^zTu5o66x$)xkk&I9vs;?6=G-bGO6pJMyLUX7Jg)y0s5X8`x>j1BMa4o{w zs>Vad{ROyheZBJvhREgd;7b)89FdD-fH#>`8?=(m`Y>9h8OM-oB7KZGK~RPm;zdFs zQ<5eP*_$DicNChn#@v;mF_pAbJr3ZlMTJ?pO8j z#xMw-V~!+CRs>ND_@^A*HlU3Gu|B4%3_hCf7M~NW-BcMzYYd^bEInM1EjrVS;ImTeWlDiG`d(#5#-Zk zy?_kEpe4jRyY8tyidbz!9~By&J7Ao#qUdEV5y3nT;6|5J!*= zthP5M9U8Sxb68!ypYw^QkeDcrMFb*!Ix*%eZ7ryQuK)Bn(iOJndu{m*OKN`&VQ{prOMw1#Yj9h2#y8(#pVR3zw94|d~3Hno%;iW361X;JUmfh|9sFAzz1*Ny?f`Kx9-0CyT0VX=e+ZiH$VRSzwzc*eD$AwaQ8hu^zy@RUT&Ve z`|$FV7h{(2vXYsl;~bS{{kkChJxHC4RM2|9&is0Nt$|-48pyl#C+L6x6t`Nd*GED_pP+ zleQAYh!m2rP^?W2#Qn-;5Pb`T1=J-~!dvwEwV_Ct2&w}n z?2OKdubmFBuq!JNbC%=_j0qwIi4Dn2;+-;?p+MU?Ap(Q-Kobd$@s7dTABZEq*Cn*! zj9Roh|FJX8rp2>p5rlZ%ghMiif2TtX)4lW8Ej~g)_$y3^r zjjJ9Nkwm%UlQs^>+(c4ZvE_-HnFX~Y_UQF|dR~%>qVef+6f;P_3sK?m9AvQ&#{=x_ zg&PXt3V($-C-3x(5llvC;LVV0jqV;k@E!xN&75GhEJ5n{@H8W-Iv=g0uuX4-*haxSK=TTF z>dw`CZHS`9*?KfLSU%U5 ztkq<7XKqazXY~)B*BIGp#Bz-KOd$P*QcpWRh3LG>L~XpR{)}hHRJuir;PQL|VU*K_ z8NMa%dOPw#kUL z9JBMS`d@6^Hb0_qL{e_lqS^0r!~u)6^GQT6imwN30HMY{qTW^TX$h|(J7YK5LPcIM zv9EGkBUDAn19(VMA+6Bhp&BaAVBXv=*@N>B+z2)O4`;;{cF2~wP(XNXkfPnBy6Qwl z_KOZQbh7p39Vh*CI+grvtYdXKBTbfal6a+TtG}fJ>$)`G=I2LaDWlKpCChv8J@$NH9e$aaE#}39Ylci{suAg$28W#@doxL z*E%7LO;%#SHCLCc8;+`KC*9VtfuqHWG(yzQhlynUD8%5>CmuB+iZrD z^{pDX_21aqi)ngVbb9>gleh0a^{LOk`R?!j*zK?W@LzoS*Z#n7y1ey^e)RIQ-iBP!-?(3_6J^AXRT!N&FrVO~44h)g=Nc+mF1IwG@wybHB1waE zxUexb3m#oc3xVl)NDS`bINzxBZtwemgM<*Ijo4HW=%ek3pI96gtHziCr$!(Vm}^s` zWkc$Qtw<~AE#0vv;S%sL!?I?sbva79G$Iy_vn^x??Yd2Fk5bmi@PGIgT(K3@OfpK3y!IwCMmKtgtehg{k5Y7r( zd?|lExZw{8W~XQ72z+-Az@tvmzWmAbPSRJ?-Fwk-%nku+4*6H&Y6&Ol9QZz8SId!= z7S&1~)nf3-7WAhw2=@Ii(kKG*}wN}?Z>^ja-^gX2gbb9tzH21Vv!kmzEPrqw6 z`|M~PaV-yj=*aiAZ|hm`-s9OUw7a%@SwkiBs#N4H0K6>!Y7}Xg@NJtgP)j1Ph3?Df zuIg51iL$;|`vmTq)f^#Ww|=lEtI=}*!*xA7h>_wT?Ix~$sIuPbxlEPoWv8Br`rU|J z6r*kH6ef^*b3RagBw=irxQdI%%CK<7l7AaaOyXJBP)U)s3$C&=eIwJ_V5_TyR}`tN zXDFE0HnW8!k7`38JY$)MRuuQaRG6`Hu?p+!9kiQ&DrVa4{Z$zRCTOA;g zl}@=4)D<=w(sAsPV5)0>+&PGNuMIb#y0j(CMYIY!gZ;>vo+u@HE4%Wv3&vOjcQwt9 zm4pF5Y^EhG;9|CD6%=pt7315140o_Knliv@7r~i*XVHQxS+Bu&9P)lIc2x(jp`d2- zzlKpPhpG6Skp3FcgA>QwM+~mcY6LkpX}}gvowfr0NJqqA{YHceCzB!sCSmj^z||MV ze1Ia@p!7>1I8E-F;|pD&ZGK-b7fe3p|}vUk29=Gf`+be%dd(e8J;STz>GUzWnB| z{NTqf-~4NT|K;2N$ZxxS>jOV>x&5wK`;<2&^#I#-Wr{0V(}RCPk!YQTa;K7NuD5dq zh5g?B8u07wwFX{&4ZQQIZXw&-Y+ij{Uvu~wu7QW|exf1vM5vAV*Q7zHw}Nh+ctLJs z^%=CgRkZS=->$(H%4-d@@VvQ^(p5k(0tmY*P0&A5tCQ%p=1%$rxKD)a!9kW8U zV9j6rJ6W#0;vi6>GZ%IS5z=sWkwac3?e<637Qf-@zBR}C)V0Rf&`h@@lspb1kiFccC2dE=5DJ;0`QrRKQE_5svBU>$xH<5sv zd2ukb>2I0-K^VSdDSf7*nof5s3_RFPiM5rajW00O9PL+=fZdK0Xiv}PM%|&Aq;8kU zKiz1G&iP);jtNobHY$e;^|k|f&W@JmI%}`~w7f67$CLmn+Ya~l*k+xTX$LzMp4ub8 zDK%t#y8Ir7%E8`$ly94=4ljr9__%ewzsDfT3(KqdWlY}JHLV}!4)SNcMe6A!yH`WlJ zsZ959(`ja{E9uNftGcP18RlTEyLKJ?3uI*X$YuLH+;u9*KEAD;XXm}Axh=X6G3 z6UHOsRRKEl4uQ_y_Sjc6iEt&S_wD#}V!b1K9h)iCx`5<^V|8efUv1)`Mz+Jw1w=1F^B z2wm(7eitF{ZB7nd6n{&CLOYIBZ7ndX>+EXdAv$6ytFmw6L?Ak;=sL>hI`e_Fius5~a(Lj2+iW;7I;9(i zNgM2`psFypRTiEN;@;*+1>A_#@Kejc;|Y2Y@JCQ2hzG3IsNw3t2^Uly8GO^{s6;Cg zbOA+JcQn?wb}?|#cNMkNx(}O%1FL;k>rcN|FobJ?Q*r5R$aLaA19&VVy}`1=T5WvT zgkBMuNF4R>Qt{CWQpS`q;5*>7wIGf@MFiR)Rk>jz@0BxM+uIrA=M@p4QMS0}ntU)U z-b_!|RjqNKpVX#%0H*>3)_iiecoZwFezY+4s6KLsH=L30){2!Wo?SV5{N;vjz~ld1 zy?>AX@GIlpWa6qv;2QRK`1b95`0g(6-93Hu;^l{)zJ2-5zy9}L{?-qD@rytC>wnkX zAN;0oy*&OqpSs+BXSe)o!A}{zAARsRL3HO{>(`+gkUgWKQ^Wti3OY^4HsI z4ZOMhV)EX~$}dMUSq`{AqPsTp?E z#%4Jdm+0?AaQv2aj^8ll9;4isGSl54ksLi(&*BkDl)=(h#7Q_KS1?jFqjIK|1$G8m z`znGt(|(+kan+tBxZsXc8kBii6tDPT-8ByORHLF=h%$X*C46W)t?e9bvIZzu;)tcx z$6A;Pc#ZUqeow2Ky)#nZP={*iTMCiz$8nlTiU5_X4+0&tqtiLC6pgkZUU+Xde|Ygc zoj<->L^&;N3wnFba2#Q7daVUE_icaJ4mw4Szu>`AXHP`X=Z<~r@3>QP0-rlmfk z{*>HzbhQWeU_Y*+?7*M4N=dKGo{@#iorg2MkljK;Xl~zl-tV+^*D7CRrmg@#*VcguaIBs+b2QwjW1F4&OxpMSsTLv@U1@t zCr@72$UTCCe`~UuA*a;DqmxF}8dhP_ZE+(0>0$@*vvD+Rr(eL7S*ku{lWn#z@Nprk zi#a~sXlKM;KkA!9)Ot)~A7Ub&uY>-?4~o+u#_-mEKmicYX@7LK!C9rU&}XUel5hek zU5~*FTH4aqIBhMfMV72TmTr372Y`haH#=`VgHCV)i4=wNs*0=eb z+Zh~o;A@JEXN=AuRk2$9b&6NTPOo;v)%3K#daI* zCBX!x8LUpE0ovD=Vc@3(3QLRaG6hQ~*4?`^32R~N<1k#l+Tv|1@k-HHQEa-Z9w~0f zFT0AKe-)qSb*XEyr~1G`JBT^fRTK3e?P`ClwTu2FR~|N+n?A78BL|NkJiU3`ZTf%q zN51av^7emx^OyeVZ+P%^|G=NWJpX~uzPtUQcd}KJ{Z!Sj`xVD(Yv2o4iw`>0RT#Wb zQFJKAEmAv(3D5Pk@;!1xR{iVkwFX{s4ZQu{dpYg`*DKEHYxX|lHSlGZzyAEiBU!0n z(eIy^7xZ$tetf)VwR)l5!m_v`OK8xRVoAJy(GEB~@3Dd&0;McJbyy&#pw0K-glXwM?S0+ey7;_kN+QW{a^}sfD z`W>Wn*{!(2ls=vWE=J<_%16OIoOFosa246d<56K}_(Y=vpP>5ETE%3Mo5^i};txh5 z9V+px{b{yVr)@kDr4#4cipyx!w!dQ=63a~j1%Y(505-gVt`iSU+{7TT zS&FJl5>FN)pBxXBt(i6z%_Q)*v2p*Jy-yphWQjf6tMax9awSv(a$JtT+LwA9R99H= z6#(AeE7WDYfC${?8Z)_;Cn`8OSABv$gTZX2XNIET+Tnm-m8hhX)~ITE>L$SR>1j0O zX|dy&?g*Di5LZwsnZq9#-e7E@=h@2_@A#?7hj(w*xF4T>dGoY-?=5gAyonqog>L)W ze0m!_xRa^+K&J$INfx)$)+dv#{(gUOR(LJ{`R8C#$>7!B*X?()IzRRI`$P90&K`Zr z8$ahTVNa(MGnLOVLiFylO{|~yVvc9Mr@iM~k9w_6q0>&vT%%g$H6{e92Kp@AahoA1eOygJC4lM5;5u2>za64&rr9%>c#BiJGfx_3M5zBiDf&y zm&L=2@96L{{&2TXs zxbZVDyV`Mh4iU7}w@*WpY07{%opnsJo`As<9m0(obxh~MP2HW+GRvbgoj~7My}4y= zu47IOf-otx&%+wv*cHZPgBDNW()IC15&zQm}h*cQE{>LAV)stV}?GWC&O2#;J2Fw(6Q!{Is zF(=(^Pj(5zWve5hu`<9NX0a#j?`Y0!JZPU2F=Ue6TDJ7+8w7bi$xk3 zqf^Mu%YOXU&+)2z@WjIWwawStC2g$Vn8=rI;)KSO`azeGx|M zsYyBg%#e{7j}zwbUVTOHf0!-?M~_nBAUmy?7qFjXiT$RuTB2CGoex#L%S!P97SH|N z>(l|eW}eA$G(7Dh0BnrM4~mIS`VQ06{$k#Qq;?aI>d)_q*FYKBX2Wax10W{6t&lgo5(Ent8l=fm1F_-<1VpRVIOy-(#xoL-2DhP+JHbWt<@69yB+PLh9W z4k=TuqyMnh9itEwl-jZLVb1$jDOX4mWt2i!cbRkIEKkq zeGGI#ERoOTZNpSB>j#Jb_}kf)M~ss27!~~PHsOHp8@k%?ZN$LG7yy(&YrpRRpJ?`{ zJ$=Q)IjzLurX<19wSIyH3#v{gAMp+g()cl%=o%P?D076T+QxqcbDJ4$i$nAvWnv3I$ z<5V$xLxi7vlwh+7-Oxk~ZILj@TCgm#i!I5m&&*PD9`+%8Wi{O|t;Xf$*m8?}oa<+ecx5bcEm(E?0>f`fQd;dx5A z3t<3+uA7I=#Jc6Pup*2~e+3$$$eHmRYLN-es1?X+ESVQKMTbC_nQP>3w#7c}1q(Oj zRXs+u++4igx3@ws{8XtPM;fb6yEC`2Eg=THwS=7=G8J<`PENW&d>3&ugRP_4B~Pb9)x#K(1Te(onGZ44Jl z=0PLi9WkdzBqG)>fvX$U%pUemzD-`OnIEycN zbOvQtaz^-CuW4Jl4m4!(jgK+tq_fVKCy@|ovVce^LYDWC<>4c~NqtiJL>e!y-$!AT zBeT|>OtC1_U)%a5z~*>_XDE$0n!L((uM#Fy?;I>#l*cJ37mYDp+c}xWJDOO7gED^_ zmMi+foJaT%+-aljb%Q>ld|1~Bef&kAv=0-!PG;gbk#+}r@L42yEKRpu!yFCUnW|x8 zos8qq_4wx2ZJQ?KIRtY7v8Q6by;S)p2n82Kk+_#cUZn!Asm32=CX42fi^lC>n_bJ^@XH z$mUk=EIv=X@sJjQu8(1&_A`+nr5oeB6gefHgUwI(k5#O#I(WX#>mYssFQ&fqT9SG^pl?= zeGt+N*)fs5Nkk7bF)-*32ghDh@@cOn*XinY1#QtzAJZ=_rG+&ns4;J9QHICt8Zb^s zbqZpq=8cAy84YEc@GoQn>0~nSU4NCIN2XDtC^AhG5RG0f))YbTl^*#ZQ*yqh`JO_c z($g+%slU0^@ou2iJ;O#-L49tUmu*UDb8KE`ZTF2e)4(IRwrV0$Pe{B?04*Pmt@^q9-zyWUK z{B-Wj*c-iu5#Jy$KNlnGQbr!pagQT#IeQI*Qx4Pg+d4C4yoWt~CliCgwcj@%7hh z4ZPwSc=5b!argPW;*7p#?=xNl-}}AYY1Vizs+1(Q@7J|mt!_Bp?=;JzL_9O{MT!mD zMs?xAl{O>!=BPq3(8l$))~dtPwK~h!Yi%qEr}nCGi^Z7PCrLKzHJrG{=QZL$W=Vn=0SSqWL(MuAqOG$iX&^^_UzqZ2sgK(_Qe)Y!wF z1%t&#XSOt(3hwifbTBk(uBjb{u;ah>loz9Ci6tJWAB9sNNGI0UF`+8%q;NYSW`%(I z_KF0DxK}ZrB`q~bT|_%3tNkp#I?AR5mt5$~cN(F8cLt>6HQdCZL&l&e2`JXxBy!~} zPslqrPpvGvB|f8_kmuR>;OPvOX)BWb_9CjKQ5B;;;j3@9FrCxzwbjI~K5ele{gG^DWvk|LSmukybA3RL(mv_(K zdv6F9c;~wU$>0R3guf=NnvZp#T5hwVg6Te?oM|Gu7Png34P>24(@jfLg$nZSpVAC%`Yf!P7s-a3< zhs#zW;)zgpK51irkiiX-uqZ;tfNo%VA5Dsye8ocRd9u$%=hv`bVP^n zbQi=YHSWd;g6p;oTdY$1CW3XV)f%NHd~Bw_@pdLkS2p2^;5<1wg=qYAj^EngsCGnl zKz~ppM5%4HP74MTx(P0QcJp4a(S2dN znp$^K`8rK#cZ}v)Ev|fxfJZgio9ISvg984cGx(-6&oAsk_IRGB%^B9E+@s?Woookbg*a~C*ZJYs`7`F#7nbO1_~YA=Tm5zGW7IHLp1$%T zg&VZFe~2SYoT0~tvBDk{VRX_i!Yz} z@z1&Y?|$sxzWH;%|F_)Sd@4-;?+-kuck&o>O{iTiqct$|+v8h9?Li_d!4>+Q7$UTqD0&C^e8h!sFyESd-sqN+UP z8V6(yBAg(>vqdvuw_us48>K}oBexNJ0-GU1Y-h{?&S+0;dVfVIWc^H)sDM^KN;7ZahdeeE}z;{{0Ep~pE`0yA-+D;z85{`m; zS!9YT!mWYHpPWi96eN*TjQ9LXo8aVxDtUtU{fs2d66&s!O1Y1$@~yvFkZC>LQuPY* zQ;6~QDtvi%F(OnbWoVjk<7c)O28T8K@<%9h99&~jIr7s!VmDGRHGy|plH>JCQ8}qE zj*6;pB-aL%hjDUXj`Y4=xLPoZ``hajzPuJUM=4+cFu>~?NHQhV5>VmE0IB984!>_P z&wO0xmCw(C@7!Eca!T|6IG-p6Z~CTsnlAR~IJz;bpZ#5|fpCTG>)O;8#EQ`enEo}> zWtS%|+B9NWpE}h&==Y{~)~Jb#SDMV<+;)u1;3(@ZdvH6JdO|C%y>rAly7M2lO3}iV zCUt`i6Gl;i#$^HEsc;eKSA8LW3AB7Odh~Pcgf;4Aji138{(+b*rfl%=J`AZ(7IwfqR#Y;>8&GKt-Q`N_Nr*wf40 za1h2)na&75wGfVhp3pQd9&2D@(TOI+(lKAThyHYnq-f6Mcv39S>)$a&P}wBc^{C+; z)>R$Jny731&Z|4t#Fq80JB(!x-yDFC|7hn@B&RL%ZUzb(xEje)cWf8PNU0W-VPGPu zE?667p63qNFc)rOwX?3T_pZ`O8kpzpOtj3bhp9?*EW3|CsbQGoYlHyU2}vm@_9Zet3v#IJbU27_rObirY>mwdp@2$@Iiav2mjXRmeY;Y=Ay>h z#%7aCJNZkI&*0m1Vid2+d@I>0dofv?dd+iZ=D}$Uzvg(t4E6S-O>&!Ss!l|_L^|=> z47?;@Rq@AW0+&c89wqY8p2>~M^|DSbg67L*CKLLF@OVPls(&|5liFkhum!sm9bx8r z*-1I>j-yU0#yECaknKcLV6ho<_-4Dwyor=^9A?l>#gvoO@0n>cZLsFp~* zxW>;kknKZN$TjhDKJb&a$jrzsTYPcy&LuSkH7ry|#1CoENNX7`B5=6i8@M#fWF>yF zZ?RK3F_1&j&Chj$gF-7yIqBCl8vcYubh$7EgK$xii<&MTVy!HC1-=+6_5n_M9{{#E z>DG&O%2j1_)vAs=#$?G(r$F}TR8_^N|AEV$=U9EGN5%v{q}$``z6_Dv4NGn@ zz6%IfrpA|LE!L8F#89#}(Ie*;*<_7zx}b4L`J9jPgdr00&X%MT(XvF^g@zKAqabae zQrb9E0{vyN4me)FGeXHu7P0wci;3!Ow1#cft6OvQkp*!-s|-2X0vete zxY@79<&&B%a6t-j^jD==jU+c&v>%jRS2_wW+q4ru5T`*UU_(51HV>pQXS>FaAIc^Gw%U4oHl z4BDsCWEtO#$h5+9PQHG|Y5PLmkT6nv6_!O`CtWCr=M5&Li``DLp}Ku$uNSaiEw;zi=Q>WT4o6cfQk-6kL>i$0HFgC_F*w5i!eg)H&IQGd!aZw|$&?oL)kmB_f#K zH8{BoG)>ZHKKnWG+G@TVLLc3}>BYOdm+xOae$Nkn+vDed>&^S0`8R*t?brWvfAV(s z{h!^v_>nL55W%MfcRLBj?dH36>g%(8?405g8&EJI(~h$2-G+$@7^}48mO($4z1;vIcbj zW@=N3dCdvZ2uwO(Px+gnOtOvH<|K1>XH5{D8r!Ak2~JOwmb>PgEVti&$`4ceB5XSB zfn76;RZs^!t=-b!`MLFAN^1h$#9kF#k#uxR)U4ZCU!)!lVP+CjYIv%sHom?Ux~#7y zwofi3*AQg59cms;B410GwjN@3!l%tU2_0;@*tq6h8LTXc94W=%?fK9Vdzmx1DDsX3+Mqb@ z_2mBKa%c91Cxq1z%RzpsiYJU)gq7^XUV<;^Skpmp+|hJ4?}$IGvrtG5AMJL7#tloU zjQY-S(7Zp~T^9X0Ups?)gbI7tLPN99-72+g?k<~*;_lwvks`jpVVQjIY_M2G4BRjk&u&ehp-yyL@CodiA^9;R*TT1h zoczx-i>QFBIR0CpRKPPcU>c%iX1lo^!?W={>8Us6ACcR|A9w|aeQtYAI4t7@W)YPv zn=3?%-i+$Jr7+Xx%5cw9#XW!ShJExNOVYcu?^s-23~K@Sl7o#cq_aht_FXjPw>Vs; z$*^8Io?d)oP~>50bCtEV#H7qe_(zCXFBu=-7O7Qq z4Bw*!%8%nwk;m_Yt^TMZbq-IgIEkaiTG8Y&(zoAz^Sht8`}sfc zPu;%!Pk;C0=YQgty?^_Yec|7W&-zQ>L;o?V9Wh5ukPtQ%6`Vg%Owv3lsBPKUg&K~D zh6goGWMSDmdi!`9gcpR$^YQF~ZvYRx`}}pC^yvw|Zvep05Pxxd;5<8Rk=BqapYT+> zv3V1T;kY?t(@q1sy?|xB=Dl5(fHl)K6ZJuCgSye&fZd4RJprr!x$I`1W`GaE%J5^mEPuQ80 zDzI*%4@y6-O*Q@WD-X{PC1UW+A~-X{{U%J42bEON!Ztp6W5$(lL_7#*F|pH4UzQ8e z!JD()xtXV|)}hE`HUzJz5C4kv3`8}YNf8@z@qKq zXwC*CiYlgq{_IT7Y5PuxE?F6SV3ugqG!goOy>^ol&xIShn-U>^zZ({RG}cU zS%vKB^_(%mzpSJg{;W&@y7{WIVe0kiP)E|b1=Q2oB?FYVsNe?GP@FN%y1IcL$a;CoKgElmimQh|7jQ501c_IVbm0@jRTU*$fxXz zm24Ve_)WDGdTc(V$ClP{GXapzNCyuC$>(!#ac=-k|BO9S!9Ea*BSpz9R6BQ?-g);z zkS(@YlX^UY_tZ~A^FIQ9fPegh%f}V;8LCQawD3=7t`!CYyOy;B%_ z{QdPl*$N2KNf)$T!=juNa5sKW)a01ORYej})@YjO^2X5IDFv=}&&LyO(VVHy6H&NE*=JCgaUnC)KXZfGz>pSl)&8e_W>X zE!eB|Buo=<)3;P^V}6P^-E9(hg5c&aVZNYGLhZrcom@GCS*7p%Y3``}(zq#YR#Yxk zha(KbBdKd%F%_CRn@M_K5*~j{{Zw@@ka~X2HRjAsvvHS>RArtz$y0tfddgukNv?GN z(2eKJ?mOG~)Lyzfw=&IERgrhUteLjlusOa-&0-8ma;ZOylvx|1S(gn-HXlZ2ZiLdz zZE@koji%^&SlR6G)EyYU&WUNqrYu;>aBP;@^^ga~5&%+5id~vqiRrq>0BbHl7_CW2 zm!3$XDpC^D$XVQpR@bC{dHrJQE`0u4%UT{Rm!qXmyU=c52|>BYqHHz~x3(jwCMYcYZ)IMBu7&ullyxq_hBrz3YOI)O zlR+-AVoI08S&jCgXoBQDhqUWzu5vn|5`Nw1pIv)lVAmY(rk^7Kb%pHj(aQHdhWS}n zFTdRBr=Q;LKTp1I4iwgd9RCrK|hEp9+GEg4gufGc<2mjdR;Y{>=JsAw_4u&;e zKAwbpvwoRZsp0S1-TERkPMd1-qlnb-@Iw3`anl9K|I>n zEF^QEQ~uO3fr%6vcV@+Rg{~{9H9Hrda~smc5m~wMK_$qEP@d(}x@7*ebLB*yMoY9Y zmHzOFUHZ==w)gB1OZ-RD`d{+UZ{$!vS~>Yw{G6{ZFI-{=y{N32P?M_(oa%)ArtU${y_=*1ex0|fJqu?|U47kzXObC=`4NbOSt5Gv5)Qvk z?W98CZ~cpm%{gFaHaCU388&Qdr7Fg|e-LRL;ltUsely=DjaZu1Zi;u60Mq3ZSF?jK z%96i367{3alc#ehKlDw)+BgNZWAu&FQl&8GUhNk4;#}&$ZW{@UiKyuCfTMX#<*^XXSryZ?E1y+&;;eANbKnc#zX!5%sH;66&mQcc zYPQ%at0BL+`#dClLcj6+nu-$FxNZ)R7B&}kVPZBvnXCvtlhzhjo7I)NXTSHrbh249 zBoPfmbX)i*XX&O#{+dV38m89f7MZ{lO1_ruW;0RdE4j)hW$Y%O4k`1Jm}t^bIo#$m z1)Y}}Z1F5%s3Zi{Z_3@L9MT)nnSwV%%Z@s{#b+Imwo^C=k_W9EBPwA1 z*_2tEpHZ#yWCIaxwP;7Y@;yh*HZ<^YWYxrWO40EW475}t4V)N)(zVA)bHMhJB>7QJ z3-%cHG4R?Tcg{aC2Q=oji-zO&nm^^U6$%Mg4DJR>FZtNoILC@DOR1S0BQ4n$ChgA| zIqA3pzN~?Bh|pvsddYZoxCK;AJ8LI(S)#M(S6r1g(^g_MVw(7ZJjyzM|Mt^Cek`vl zVayu#dH%Cs;+aMDazbZvL;cnvqy&N=br^=Iv*b+Sim&pgTI}k*AFwM?Iyp>GLgta% zM@Bn;IzV%1oFnr|>v!Wl&1JMp;mimy6II1qQ7DUD> zeKSQ~x9H9X+_Lejip4u92rj@3=_60+si?oA1tIG378#@Wug1<}A%>t(Y3lkeQm(l^ zRRp?CzN29{PXD9eg4ZdT>7y`nYvp4@CE2wqGA9?=T#}=?DbWaq)Ajmrk`jKjp14~! zJ!}GsGQk}nDm5PyXQt?Mvi_gDlHcVB6S<9q^uz3dFnvU<((=^wXsOT?mo6wLm(P8K zd8;mBD~%S|@$>JCKHakG4HVfFIS1{{OOznF=e!5O<{LJ3Q0zC*I}Mcod0wAdM?Jo3 zQ)fgVnRVJcqk;eW(f{X~roUO19u4S^`q}9kKD%_l0kjzDDmf10^Z7m*-?Pb;NlUgC zo;?Jn18dZ~gS*rDs!FIDUyOC)mUiQ&j$<{<8epR{R| zHDJP7+J;wHueo8VK>mVe`9SZ-c7EVNX2mHYYw2`}cZ2^Zi5wpjq)Y$+KmbWZK~yx# z6m1`Twz`zl)B}>3~(d5wstD8h;X(EF`C* z%!#Ot{;lB?M%wm0%dx;(U#n3s?I;-Bp_k!5kQ4-qWpqw!1VN;v%H!?2EB&X1IQ{X# zqhC+bH#6a%p7U53M4F;eHQ>IR`IQ)6Lok%txc87}^?{VuRL#)^iaufpx}sw5yN29`KvgxgV=_;h zRV3OI?x~u#8Mi|^i=SVfJ@8?B;Nwr~E9Hl zn+7;6EqlhdjH8QvI-&O}Mw8DNGZ*hOlody}#lW1*r^(G|^Z8;$V?#03m1%{&DY#U` z5UwH5E~a}+#z|5FTUp-7(A~-<&&Ccv^gCO6TRX*w>a?0AN>|!t&!3Qze(2(aa{-!M z*@=ZeDH5Qi+HKm3i;>#~v#=Di@PnxiOK(}3o~szDApnje(?eoqB~=qx`Ema&i#`gv zFJba>ajN|2KyvTadry+>8vvw~FCX%W-k9VWQGN8%$xHsOCxY5jUEP!5-YFUg5yg38 z>m1JKlU>&Js-LW`@tpMwhceusS0CZ^iuX{HcWR&Bg^)U>Pc%_=8jAG9jj8MDQC>k0 zmm5h~+NVu1H;nY82)E78nu{}9f_G9B=AA@k;-ISoN*Cp}r>>r$?tjky9&i^j_1_qy z7kiNwJ}pjzphkP1n+GMtj2gfO0i-8}*Y+G>iGl*=y&p?$wc!0nAvq zCKrrRCqu(@S!fIddbRQU4A$=YRWK_Wld4+CG%g}zBSR;nj;Jl)nMA5d(mbW>qS80H zWwNR>XIKtYIJ=BGa~Um}i76e?F(`hB$*O)x2-f0NPf>Q})d|#+mRx*&lpK^wlA9aA z?n*r7Tw{Vjj2hIv;-`>$bH?#lJc)1-QGmRYi6443Xy7$kK+w38|5BJ|m_iH36_Mf& zO)p~4iAXq$TFMRBke=qC92wuI;y`8NRjw}T{D#h7Ppv65VTzq-O(WCBStF`669SnB zBc5pnu00!2*i?BM8~b#%fgfT0WIkHNXx0jk@Z{p3=CM#@Lx#0cg)lF$oHHXc-7&~D z6`iVC8?Bwx&9j@|<}p&v!hURf`fIWmNqP4&#eEQe$=&#$5nV6Mplw>2-LGr@(&pW|!&m!_&~)Ea7IzzF*%iQBV`&7hW|hrW}hlRSU#6C@9>U%$Bjg~yL>fA;_VHIHBZ z+dua3>v5WW)NP$X)${S}fe+dPFF*a%@Ub}lpn*Pv z`^D>lGZNo!-}A#i+gRJEY??I@6}jgz>c;Ut;JIN}He!Zqz&C&FSCZApZtgMZm8o}a zZgw4sxyGppv2t_8ndCP6CHD;Yy*7C@V(HwMrR;8p_&QJv| z?8ki;GEoJFoh5VR#bIY@73wEyCRouNJNrUN;@QA2?o=d!J2Ef%1%rBqG1?M^O@$B^ zxZRiiiefH+f<9px0Hiy(vQkP~Ssf^F%k?ROo zXFCAwAIP`2CYjn^!0q)c=HUY19P&qXuJQ07U7AzyIK%B4oddtU8MX4xr{hqct0%7G zg;O?N5oyC*zM$bz#?hH4_nnDW4?=ppR$ozf_k9|X%R?`hSCIz=dkiPa&Uw9ly-~xn zDPj4^_FL_{Ruf7Kr_;EZQh}e{d=j(ALP5In0JJWTzZH`HRe;sC2%jy>dBu%A1jiyQyKPXEykE2?@BSe=}-tE10KKAx$LkGLclK}cU4^f`ug&%{3k zerm{%>mlWOGo6O{gMr}e!Os?vH-x8Q+{8V=BH%VE$;Q^7!Eb=#hJUQYX_O5huoYkF zqp=otGM9>ll3+s3XQy! zRmX+%e>{8gFp4~TKCq`@yPp0NZtL>;0I6!%;lelfTyR+esrwKoqOtz}=Q`De-_e)yxiyDxp-(^#o2u8p}h zRZY@C3H*$wcDP`pxPwN0MPRIvJ68s#J1PNQtgzXmoP*106 z{QB&H58MN$CEeorc=o^t>47s6-)_IOe*^f%dQ?uaoS})uYVpjBjoD2eCYsIdGp94F z+82Fxd*gX^C*VypyS>tz@x1J~QNJXw-cW(5sImT}RGXc1^GOG_CZ~7O&oj;R?2qq27MUX&KE{KGlnw|GcK*PNG42LOqyfdv!`m8-Hmtg_OQ zURER#ay6`8&EYPrGcFnwC)qvqp<6Cz{v_z>E{>$YQ2S>tmyhAlB8s|rrG>XN<}R8YL-vvX*H#w1VXOJC*(5pY#O;Z zNt57FE=`BYP9sdsVWn7G|Cuh3NPA0QJK#n(C*2PGtM4iei|ih01u-c?jgBy&E^WDa-$Y+>~ zo#fO(+GbO$zWEK$;(J=@rXlgY^U6-Nqz4d0AI)eMtX%=pUU{*cmv|-}X^w&mopTmn z1Ht^WscX{#ph%fd43Pt^KMaq+0Xsj6z9}Q3ewT)k^eUF33ZKIq5c`hcj%}gm)S^8= zL1W4oH1NuST>s+U&W0&uHJiPOnI3QFQ+Dd>)k$F+%hSFq*P0#AJf&QivxG^CLCt>v z=7C;k&sA(BTN}i|)I5SpDRqfqLxy?;smmft@b!MGSJkckRK?S4+Dven8e+&g7WrBZ zyujgi_U`_jY4MkQ>CNrOfBKi-e&GAR?eX9FP5Hc2! zr2rdvw7~jljq4ErhAvXWcQ9R!sr;C*@_`J*T*!aZ-!+5Xo5;uZ6Q01XU32tEH)gV; z{U-YX&&RU|zA-$Y%RC>?9{Aur@bRxK3$dNw_tiByH2n770KShr8qp2zo0`u!Z+_~` zOx2j(4AtFO3#w6-ah|}B$((4Sk=K|zO!7^DO@gKlH!!<0D>TV8clBj@RhOnjOUFK2 z+j}X*;grV7Fj6EBdiylz2FGa`Mkl+~)3gpw5Yhvu*`+0GS;P{o=qYbGY=%w;lY33H zb^fqVPla*>MVr?+{W|1$=Z3I zv04p^8x>v3uu{uVrd^54&YMejC&{vu(bYGtYEq`hHlMQNj7iDiaHd}}gELGSK-0{Y ztJ2E|N#EoGu(FdZk!a;|yhnK{3Z?14o=9CCn$&kNbi%TkW25y9YeC~wx=2QMzlF@X zqg&azxg7drCn^`X`{WvNcx78%60JEGW|ihe#%t*cmCgq>Nfz7q;RPmEEq=N$(s@(} zt0tHGcL^CWT-1@)CA~|x)|OS$fBkhHE6-K7_5x5GhFnf3WNW`A+H(co;MQ)F^7t9U zVt;kMA0aF~3&0NQiS2OR+c8=}!r{NlIKcTu-8pBv_IDbZ-HEwMmE%WoN}uWu&wM^= zu5pk*8JyhV6~C^lPQmc3T!B0?o^8WaSLOJZ`K9$JJPXPa*VsMvp~tjk)e@t17hbEk;ILGs3q%*MT}c#H8|PY7sib=fa+7DUK9uK7=t#-_-e5u zH~^F%huH}A|LZ5N;yu+2#1770;3Q^|TYXA8H`WzNy?2!#w%~;k!8#~*^Mg-K z?3{Hh+bBd^7d6K5=!xoi7V{bwd1Xm~?}!WE zaR4b>7sm&r>f`?H^aO!jW+*pp)67JhSfHw}S!rn`(Tjya#XZWe zu$RdbtD{P?oaxCDv|U7+d+%-^zj*iZo4)e5Z-43k`*pXU{IOqg`!E05KXCiQ|NbAm zyM0=0eAk!X-+n%NyU(Q&gEX18@CupnwNrG(I%ciW9L_w*^(CD71 ziY$fBtx#eu))R2_ZE9JjLvdoHM<#{{Q{HK}N|PoHt2vvupp^Mso67_?Gs-HECnRN6 zZ_Q8E+~P zrKDu1O2p*5I~2{PJ-G~vx-xmEpRB{P@+i9#P!lc3iYk%=(46bA3y>L!o2KUCtt6J3=4NPNlHp4O5M3L0JTk*5|F5};Po)dj< zgA{Kuvb@vB62=*%ISUXvX(IB< zGD29$a4d0gWeNEsJ6LkR=FYY!Y>@>W+RCO&O@(E1UZ-HUK2=)2Y@6voK{^wu%(}TP z&&Ak>5Q>gey?N2ozLzX>gk7#8sSY#AA6T5i@XfLG0x4K-1E) zYZ_OUxHB@vYo())6QeH|-e^W>Rg|dXs)Qup4ZUO?k0fyMETRl6k#Dsy`(pluLzmWyDDdaIhN748P%L{n{q> zbM(2k&dH$GM*|jw6O$<3pZ-Jw97J;n4!`N$TjM%iyC!#=l*zPEPePxe=eO)mmZ>Ev zsSQ67UIfUdTuWeuoKBLS&BrmOfW-4B1# zTzQ*Cq^=l@eDL1q7gxz-BKiwBaLUs>=K8SOeau|de3PAGegTws`Jfq@2qZCDSa_6{UFjm0^Ct^>dM+?xk!d5i!Uql)BD*Pj94tJ{pZ{9hm--cHnG@?h$I4FlsJq0 zqmSxtr0|BVQnZ-G&pO{_;Kw=P^(XIc-}htR@&4UUe0uxe|JC1n`;UI_AHKW&=)3pt zzU!m+_h0+!9{ss}oNm}X{?R{&>fb}LXxP1pM~2PR6AqQG0rv5hXt?<*OHlgp`|YoR zVWDygludZA8%C46XNG*6;pTG6pO0q`eDEH4_vV$<)8F&)?12y81I;65j7>6SR_5yF zmoi(S=gqjS`?A+{W=8k!TzhVC@Y!Y0Z3`j;* z{nDmBm0KcFs#c)~5az-pj3mmcxc!N&(FkG%IqITAb5s?%vnbH6l_5tv#d z5i_alqcfLQl=aka!$3IXy+DyyS^5_b@L&E=)oC?r9tasso!E&dPi@5(P2Qs<(&d0< zg5$KQKxu?(MbcCf25*c~tRHqa^E7Sv(X*j%5AowYqlf zVP3NL0d5T4&!M6=IZsI2Fwnj2 zIvrmO@pzR>txn0ZYWZHgWE~#TXKrN<87q2an&2!{nR@Bp*%J+!+yl>5&%gjGhwsUb z8t3Ue--he-osVtVEHHiZMfCd;zf4*Duh}Pt`;9Iq3KU)KNfA3??U;irusbJWYgg%c zHKMTc$c0&wv)r(7aFD0|Y)fe8NHj)2YgiW=j}}ROua;HEXss`Lg&!$+dwtRBg;Zlf z*9Wotlkt-iN!9(S%?M!yD;^%0?oLwLW8;;cM#;88P#($nlW=g7ozEvR6KA?B|Gvn0 zji(7vIMENuZ5X^sJgRg-YA4i1z5{SC$g(7XH39kS~4-nOPhom>2+-ajlk%lhWc#K`qP~!7%xR0MJsr|md=L2X^M?RM& z-$I+hj;`g-y?z`7YZ7yJN)IE;x78c1F zurZPaa3Y3$!Y1}Cgq2gnu4of!T!!VCNq^ER=TZJf*BF2Nqe-q#Ll8Y#w33>;x?0I- zF=4eK_?>sX`Eikh%~%`n_Gd?%x^xyfh(SmYTh{8e`&0C@C)&+sA&CUOV=E7;=iu25 zP5Mnv^obeK@UW3_2FH4wL&V!{KD0k`_m%rseVqR*x9|SpU;X&;Pkny>{r}1Dc=&Dq z!XJNp_)GoszaQ!!?tSg^G{1U(>%Z^`&;hi`;IX@97U`d|Ak4uUmM`E53gsK;*Wem| z)kylwjEFQh8jXMnscXCS4(?I0oS`c$D|&u;_P{rW2b%lZihDkuJ@A2h;9J}FZA9NV z+;@#NcuOA2AYQ(b&6ul<`2HcZk=lGo*^v-yZ4qKMR7Z>A1umhKMjDCF-*2mrLV>;lCE{I+z)?ZF6^NJnd- zm&!rtZP*~?pE3vK5(}Ut8!ocZ3A>&M2{?HQ8DX=)jzXG|9WAzLS@MOnON$gqF$Md7 z&nOs{{3R#kvt#99A*nxrqq`EEXvsTZwD9DW3N-ig!I2NU37ozIrJ!l@4o53DEYapF z@9yrM2R+kSi|h&2RIDv?ie04LSkNw7%11+bi+X>|W|_;x%{DdX2oJ|rdDf^v!3^5+ zh{cxNZT3!5*n*#0bko9q2kU$^rNZG``bVCuoRF*>-srhzy2M+IRB#F($DQM|L+pSu zPUOYohzpluj9QBxo_%=yG)b=>Uwn3Z{P@KukFP&{`~Jn3O6|8Pl~)V8qc33#Rqy?= z-*cx3139;*s@ls`~%X>9>sZ>G7h0zx5%J#n15uu3zon+ZB5P zGO2?wgZ9~hnCs;BK-Z1)>s(iH5<5-S`f2Y438Q;3y?5~~HpOsM^e0{I7y86^`ry-( zjw?we6Mf=6&A?Wa#ZhEJy)+Jk6KcQP&YmxQQ&>WyFEdmLjl+!l+r%jA)9%a@_UCpQOMJ&@h z(20xQ!pql(Z}TI>El%I}kKJb#%TiTEHxOY6SM59Cq+g1Y(c8On_L`%cMbIJ z_M(65(RaR8NrSg=w)(w)>|5srD?zXr0Ep;NtWTac3LC)bVi6}8bNR-5B~yiUwmXS- z^&&O@(Ec=po+UGT=?cs;62XbCMQSF0Ed@G3O(wK%y z`z+p5G8(1t`oh-*N1EC&j_vWGmAD6=Hh&%e!I0~(ztc9F!++v<*OueEK;8AdeGi|# zOW>Q^!>6C$z5D#_?ZxXix1a3#Pk+z1-G0m8{u6h9?Z5tK-v7F9|DpTacW2s<8$h!A zb*g;=4^F|uF?-rhV$is25A2;uYR=HtD(hSuc;_TOzJ^gJ6$mDWpslDEjVTTks8i;@ zbgn`8q>C>GyVnu!dwEHIKAt`BL3`kn*F6F7_2KG+#`+BI7r6(1@F)JRMs)YX&5Dh_ zO|+Y+o2{<9nGNh_T*LJy*Qgr*_p(n|H?x@rO>YM9mR?N~<__~$8N{aNlS?JUU?1@& zd%kbpIP-vMvPsZ9BT-NsmHC1U1)DP}3r9Md@#0lZUT^6!XSf``B}*Z19+L^!RBu*_ zoV39|tHflSgk@{1p-pQ<6BT3B*OI64{P;gZ{Vw#2Hv_l~W#ktL z^h3row6Cr+a1Cvz?mXotM)UJ>_a!B__lIxu+$P!+6H21V4j@>wSEaLUvJg-C9%-PTfN99S>Fs6j@lll-%9%s&fK?-= zb4h;@^*~3`zfM9!hUhB?xn45q4ROWE8t6>>6-SxR>#T;2__Wrfd7H)<)&w=7h5xIU zZ(iPi`ttGq)$50^eDnL;kAL--zyHxc^IIPO?Z5YbdH;X@&%gii<8S_vyW9WKmp}d0 zPmlVo#7DHaw$sPK&W zw6$y^8!pGi>x+Cgf=ndJ=i}J}AF>B@rsvPBe8?^IjBOtH4ZrqhWG)6}AN7(IY}{=c z>hA5v+iEt&ZNAH3{9Ni6`kWpEf6kshX0O1V{`vk-N)5z!&hEq)e#I z_e(<3lU7H$)R|YL*3N8rgD;t2>ON)4KVj%K5)=Ehu&bLK1NQzcot_q8UoQ;weFR-D zpWqww7|s`cVHmE&mz`~0KHb#?s7!7?7%3t~@p05Ss>Dl9lR>k2|EVC^kyOTLM)cPK z@UA;wqKJ->d3z)zqv1u3-Y+3^VN6YL>|R}L>Fsardjz*Z;(lBURy@mQmIjvuYAIge z*PL{ON^x=u?J1YZo?|+IQzXBLaWuQ@Rf*W68OxQ6FBMwqBz0-3en` z^2COu&E*yqXP2pZcZ!mBe|w!=Qa|;tntMXvMc=RZ?$!-6dq=IlI6H;!_%G74UOdKQ zBt?gr>nHLAh$^?vuus51c~k{VC4OdfGz+In87`#NsCRu1-Nd|G;7SfaUiX&2=K#UrH{RN37Q@w*f@(3;O*-8z>^;66^aFpC;O6^yBC90Q@)hT()qNogGi~5Wgc``vePd6(r7RGg8-r zWJ60$HWpZ`NLA+8SRG@Aoq1_QsVRzL4QoFt0}`19 z@rW0Y#2g&aT6T6p7eD(;&&E17ec@fRXmmyC+RFD~;t?~AP{X#1%8Knu7(O3t6Ld;B z<2W|?k<2dy8U@byKdz+{J-?U!W0Ux1W38YOvsXVNKHUJ5oE-w7KVNt}l+@+2UTtWb z>&CWsva|U@u40StB*h%H0XLtk=UNK-dwB8w;p3NgU;gChZ$I!?zV-Gu z|H7}o{jdMTAGrMw|M|amcl)tlxO?}fKDvAO+N#Pk%n1J@Ad<0oh{F z|9m`q;KTR8#_9&+vzOnLQX(TcH2-cTyt%OH(=^*ST(K$U*2l>jnahcy-W?TnrnWcD zunqk3@@mX`%gD#u^w$hhx%u_X%*_chZ!^4=aP_H*roHP8{wEfE0 zdJNI334VFb=0j6Pf6XT(_qI<5JCAKM_KYScbhJz)!DkMZ2X0^EKuHpDOK%2Ht1h7+ zdij&31@@AgJS2_mZ6;+rOVUDnZ&f(uNAtE!`&`0i&g7X0U9`Z#%VE$@DAB<6F88RW zGc@_YVl3VXI7%%9&FmGQWHE|4g&`8YS^>nVOgX(LZ}PefXDTX>HsEv8WlQt^lT%cL zD*)iHF~V1?X1#)ntAb)M&hS_|jdaR;3+4vKlhZe!|07Kh4bdx0kPbFt$yE_qz=i@j<)z7;efP zR5|sRi-z!&p4afDOwFO`ZD-24>XaXQpZK0Wqua26IJyjZQg}SGaz&bf3^*Lz?e)Ar zm7lPk!_;mSxYih6Vzvu3AwsB?mu*@mNB+7{kv*Bt9>G&#J!)AgIOH;nwy;1FQ z!PY$LKuy%H>Q2laP;d32TI`_jb>5bZ|ISTd1i-}FH z4UeO|rKG+Ro@l<96wcyy8#@-^*;y^O3~ju|M*TWv+JUU2x6eW+`*<-LCLC~#h!0ET zRj{2Kz@2a_G&W%7!RT`a=MX-QD8|Ev^IXyh%>ypIK&M{9l=~-d6 zM`?V)(c1|4|J*%a^Qc%`RJ^vSe$v(}V?U+tIMgjHCt~yOQ*Y!b>i>1mM zY1B@mto8Ol5+1E9geR*{M8uGxC}3i7=E$e*=&t&E=8xI@Yc&ZZ~xK! zSoyyDgt@B2`E$OIXZFm>${9iLlueiE34Pex_OD z=MK-#%({6SuxiuSkDD-Qa>GXdBLCst%e(v6FYiA3@Bj@oz z7wR)1;R)_{^~tBkDB-=Woa$6Y+hu=i#F^zPSr6wqCDhNyvj;w84}ALOt%O&Pc|M*! z@L_x4FZ_+~X!hCQyG7I)%o~awpj)got4KU{ZICyln+mEbvq5OcHheeCH(0-FQgQ9I zqv>qVwqP($H>$cVZH7WJZo{VpR8Qb~dwBTnTn2b4J!QKXVVbo=lEl zDRd-H6EwN!S&HZ`^)XwuBq{MQMI89e-$%vQzn0kipN!#*{F2!AVS-cf zIgYLF?w*XoZTHot$R5ugCp3SvPkTMFExozmWooXPz3soGH=L~oP}eY2vq zY-2Ks*6ozyi}6Xt%s9@-byt%UwLE{MxpMDSI+e6fXH*Wt?fcM@DtU%a%X*B(QIZ*V zW^I9!JprWNomZIqom-dB08bE^1tq#%PRJBp0>_6@Yst%!Ftd6od?E(XZ*8^Mnn?L` zRY{9s`Or=kq~qjXVx)E$E;>WrcW_8_KmYX2SxEY%Y5vcu0-^?3z>Ssz;_S%n;%dj` zt`)94zPDkRjib;kew3WjLw5l9(70n)b^1%}xvu;eh11W;jq_SH0*bsV)+$%`Pw|2DsQ!AVZ%H~>S&0bfKdH0KIcse00 z1vzrbr;?||AT{w5dfGqU&TCc!9TS3~SNDd$SBqpFC+KI4ign5W)+H>Ak4%F^=P@DZ zMQt%dmy7iu&kSCMRIbyl+x4=}8sDHLJJ&)RjD?+B^*F~tLO`mcGcoj4-*2WHjZecr z*-8f5lWo(eDbh_e{N&{b3+XyK+DxGB+Pkd(uEB#-ZCLjt12sk^YPo50>905(@f(tN z+}-sU3zs%#(7CQV=t7A;Kb=jt_7QMQOZXN0;if97LW#P4JN}`mA>ek|fK(sJuc;dJLoZ zB4(MQQ-gYK&-{ZkBdbeRl*X?Eb!D}mz4(|IZ5Uf@#A;L&*q$u?SeBXn!2!x%js z7oK>>VJ$q-XD3CUg5asK9(&0&^9tqEcz=CZ6&OBe|K)oUqA2LSoW0^`6gyLldO?UM4(=*O``x+}h);8_f|WjcKn6?KGUpBhf)Q zvLS;lQJZW65Owr2OHK&g+!B9PE?K+eup}YULm!|jrSc|TnXXJg-n{&yE6AfYUq@42 z=e7h=9O;FucZ=#dX!`fPJ8UL+o^T>N8lCALfX&ZG3MA41YL53UiTT_WUxo^tm;g12 zA1{`FVBH}TP?JoJ7>`Hj!%B((z)ya4`y4CFQ|S)ndFYAE9?v}PVgH(ZSce&w9K-@? z75}6kN+2apXcO7)QYYMbA`N%7DTHsW#}r-(ZN-I;)MJH(ViyKslGC$NxfV5*kWV~o zuKXvCS`COw#o~DLQp`~u+Rl$RouHrsOtIM+woc0XFny5}g@Q(T(PEz;FC09Pjw2{4 zzeMiu{o9Au?QMM(m)-ooR&PAvkD~srHjtCzzK%8jEF_u>LU6r48KJ~EWmZ+X3mRqT zGZRmB6(wUW;dBRyTs$}T?)uPEd*WxYQ{CY_^_a!K2H0Or_$&fZOitNucwQsXT1)L8 z@87=b%c|Z+-fcN;F0R|TjFIV9kdu^43`W|8foaRWGZw|cbz4-lQ8{l5rrJ3Q{3s?FyJIF&Gul}Ry+HPvNWvJ+^ zdjG$J{dA@*$BEfgGq9J+Na@U@+15J8>SI5O`Kfnbs@hH2TzahS`4$a?V9++gMW#pR z0_6)$4(`|zzcbuv24bR}5alvTJuR9X1~JDA4*x7AC#EUHUGpc*@TbIg;C5ci36llQ z>|&i`w0umze^nO=^T#uAWW}5|yValo!-R^eN_Fo6Z<{3D2(-UaV%#m(uwsY%C)|l$ zgUO4E{Iy6An?CVbvR7*Zj1UdeC~SZdl0g ztS=ROeD(74$8YMX{iu85{nQ`)$KU_jfAFt7e*V3$@818|Cl9xe zKco0El7s+JJ7MrCc@n-tzC-6p5JAP7D#qKn!W~f^`?|_PWSuOiM(d(KGj-SFs!=v* zHC!k_7T>nVwnrrR8UVMmbCf*4K6~Ip_keEyp<{f;_w0e&yPx>^Gb=4$dzuu^Y=3>w zv;p`G_Du?=wH9V)zSu;ewlREjKvBQ(pO`b=o0*(88*hL&qo%LID*P1<@ci;Y_3X$uzag_V?Q*H`i5= zt|*gcX7^zP*~-$~X{&}Ck^(bW;d2&OHRZH)Usq0M%9^pEs7Dz=eZ;g6>Tc9k9$yec@6G~^{p9#>OB@TZuJ1T^? zmoLAR+WW^hf8(p&7;d%jD%JYM95LiUf=B>B)3E?m)?dPAApfb>Ox_(ne6Ez3Q+sIL zp@i9i_S79ZWlub_ixaOhu+aNEWm~j%{lszPx;k=Utbx0Lfy?l@T6f)fW!>m+rTA&V zx8+pKxqO32IS-apI zR3d6}tooS>K;RbD1-8!M1}p2ioE!*Zs{|SBZJQZfj7i*(3@y@GFEw!2d%9vMt6msW z-{ycp6E3vl9k#CR4o5)|gY3*DZ5wgoe>)1b(dLh?50s&UFy%blyYV7H3<195gk`*E z&00x>vm!aUrQR{CSV^n;XgdRx?V}MW2)8QQ=~PM zhVQr(WtOS-)PNu02W_jzW7^o(e8~?QT?tGP=buq#g$d#xmD6cOsVscPH-(JRYy+sh zg4rdO!=?>3U@I{rPUIOS2aNbh4=Lu_FP-z@Bv)wHy~@R)oi~<~>&V&@Or7VtHdi)2 zhQD$cDz449$n6@q)77=+8Ia7ITZ@y6ZyN_V#W}#&u45`Se!nO1p3Fpg`C`Cb)4g%T zBz|V772Did{CAbphH!GiLVZ7ViM3g6KRj8Vzt@h7Bu><}xWz}&G7qlLEvZNlm&U*F zk8-{Xe_RHr>0v8grQVg-RE(_jUYt|uGgnl#3j2ci(8YfJv*>`ir_ym)ihNB-}= z>nFec_7{HSJMO;oOaJHlzy6>8_Qy|t)lc7l{(WD$fALpeXGz~FqY(f$WP#8dWeIiG z!33w#t;F_GCpJAIE6_NrqnA-Mfoje%=7o*v*vz?TE2yF|yg8t+3*6mkDN7Z$to%ZC=H!H4#@0Xw>l5_N zw#~^2NOo5WzE}!#ji)ybu5Q%M*0P)Esu`}FHJNnUJ%~!0u$u)b24oGCUh-Yr1D~6yaSKd2oQdE@Iw7R%9?T1dcEMYI&uaOKO?ceoYVnLAZzJF1Jdwy9W^7}4fWjnQ|9O*>zg zBB{Q+d-DtTpFBLgfA!6MrnBdhkF+^>L;=X*IgrxnsV-atc*xvIS4U%Yy&ObofQtUr zJ7@GIC(4tKYU)#VsT~bUIVt>p&T+{+f-6$PU0%3#o}uli{E@1Q;Z z&waPs$K54+Rm*(=4l-8P<{Ib;u+AmS6coCN^?B2wdYxi?u`nTDhp3?EI0wdA66Qm{~9mQ9g*Hq5BOZHSF=xF!wbem7QDx3w6*uHjNTGY6Uu zW?#0z1-}Ah$-xiCO4!*Spzr%K41!{aYg-(Vt2nGl)=U^uJ&n^tnoy=h3ySh}!y`7$ z?j%;GkZTqUTFwq98$#0;Eg(wFGrE_OWXvzg?UF;)T<)C#1PC#&P$$0d;^p@v@MBfR7Hu35ozq zzk5Dw@{~jvI^$UW*~p?{A?ddu_J})eIX(QD-i9%cR}c@7Ji1Znc zyRMaa|h{ zTaIJw@RL(z4Z}|@lG?)18&_8%p)&4S2|0at@_ZOgmtrV;4RTR$Tqwex+J*!y6Z`nl z%g^pUxqWo^fB(=we0%rTf5q+h{r!LB_TB&4@4UbJb)VjD-}B|iS3lnu1b*ZnSiH&& z8DU@*^4xRA%u~?ocE^ECco!+AzZaLslHoalc-mv-`kx;(PS+pTKwaZD;S$*}c{(;1 zgdt1CX+cW;BpgQ##l@-S`FQrghwp)xuV3`Vp3m$555J|J5$+=ZKhdcCPMZ7b@xK29 zd-wKTk1uw!N#`!%@oPAHrM3B3 zy40jxp_bjQMwZvNwc2$7vv@mJRnM>f=q9{_zh{CwRUs6qL~Fd!Lf;=Jp2x?N#zj~1 z!F89YLvJ0!975>gUk>U#(&TjRtx^f1Qso%t7BGp&hc*lON>pTDb>#KY;dXCcKlqSH zG++H={YR+Z`u>-`_}cx;ua&cZQpd=t5AW-!$H#mZ<3NQ40GIqaAP3OSt2@vFan&gS zQ@OIITk$)831S z$%fP>Z~y3FPKW1V4@F!<%7IrKa3jHMwq8!OYx-%)2gxfy*Qmt+_h>U_?N0J(?ds8s ze({sI;%`bj8PQjlBo2fMU!1@8%|K^S+ed1kTrnA*i zW^ZYk#dK63JwI3$3*q&Z$rP#dR2W1b@2MNCG3>>N;m5a^y(oK%gd3wKgF1Y3!g<)s zWyV&X%;9YLk^bcHSuW|3Wr(LYaAA%;(P^$Me2vgpJE=s#wZvj)Hbv1vosZhL)WVyt zHP#k4X9QkIap2|~lIrPQ_q7B0IBhXd>PtlX*3R5M-}VjaRwMMsIt=mfLD!m)NJs4l z$j&n<>7Ae7eAo&bX}XY~r^*s0kd$NxL}J%kAI%n&X(%w7b8z=O!Gl~9zDb!+stF8( zkEkv5H)2%gh&0JZUa~Dmd5gZBm;b|UvJ_&(FwCU}&^`ofzj=NhaGFg`Z~4~}Tf0M; zsIz8F-WdWbgl+xX8_D^A`(iPlz0%qCH6!{SrFbHsWaH|ISKw-lN~=FnQ>!iC5YPLk zK}^zy;@fn)sH#xVPj_{lMda@cTu=VFsN%<&k1!Y)gji4@T141^fPtRQRIXoNxGRb` z8*3xprta!_d>g5cDjFdUiU3g^4{1q8AOCv+?po};f1Q@%;IX{y^w{~A=L!xlh3Mn$ zE3a#iV&wn&;a`2b{ru0}zWZPNH*WvXzw{?>w;zq*TAd6-hy!9Ifn-NQ1Ez+^9XKfznqmh@G2#qrNKCj_c< z)B6klYz}Id`9P8oF4>?k@H2VJ`|Vx~Z*|_^a96mCBa!9I;%6;B?^lqMI%jljVv8Ls zw;ZDIxM&*j+l5I2FI$)|o^p(2O$rpX>eDI6q>905&bcOUM=b~~F54UFb ziOe)GF1=UdCgXe>xT3T^nJY0YhkE?3^Xl5L=uI}<^khML7X zhoWBi-8s0PN$#!pneB9Sz3tqqGOvec7>A z#JXtlU-K+!uPt7b(KA!rXhRLHOAC%OWcL#!?AI-xT74m?zFf_);ucW zLvp`Hi-Hn9tEJXIy4*2oYq0a=FJ~c--${oN|FnDZD%6KkxcXF`agMPOdX#&rFv4aM zu+Z%7w6n2Dc_Pheh*M)+X*1wd%mExyJ-L+dD5sF;ymD(P{s4F}6)ryzMA7)ckhXYI zNVj8h&Pn4=s{7MWYR^1#;$ZiYr$5Qf4Rh_#!LmOCf6iQ6WXGjpt;s67DXPynr-ylb zzxJU!#qw1@oi1Qw^`1WAOAvFNnVyq&-2rd&)`;&3zAf;EDeL*pL8a61<)pa7*Xi^q zQ8m(iKC1GxsqbYz2OcJ4Yv|@rV`DVf8YAZPFl3I+t%|B&glPl7tu3QH^Y+)}kMUGQ zRI6Vx-$gF+l9VQ@+{-iq_9mi1JSKA5Iktxoh0PUcro6ZQ9c-uk#Zy|X!{p4O>a-bq z7eA|nM%7#4UPxLL6Dm;X#oOJOhu61{U%xD4dwKibANtnEFMa;ChoAa)f5*db{+)mP z_VW9G?soT=o3vhL{O(Q^pvlf(y_V1Pw)r(l2UOR-@Zo7tNKepeNCQO?Is21!Vqi=N zW}^Uh>`fVBMaLMi>DXR_d{J?}Sfs+#`u@Z}Oxc#tj{OZx_j}|arv?9c@jpL4zkJvp z_~gC~fVzNQ@O(Ub;Dhvlksh$m{>IBvyJnR0kBXXbTJ1Dr8+uK+?%FpvcQ2%qGyQI$ zzb38nvu0Zh(Fru|HA8q1HIeZf`uj73NZOhe4EW7F7;HG3D$NxGgL+J7g0k-~GilZ_ zTlC)1#L;|M4UT}DqNr|9ix7ehVe6~e89451A++-M_35kDGWAZCn z4#9%(r=?`h;vkcQc3$NsXIecZDel0EsJ9E~PSW7fc?%XeR=>%3Od|#=4BV1E%QNQ_ za%@6Wlu@Mw{m6b|%3bqs^$rTRYTXF5^s9{!-a5Eg;|mM9+rL%Y*BxX++K?orcmB*1 zOXOgMr~Cl!^5at<2Vb70!<;Dj2~}L;pk_Qq6G&sCk9p}ywNh)_z#_qjq%l7{D zTw)9``)4A#h0Jg{WG(ak7LfX>iP)DttqKH;a35r?~CH*HTM3vgAK}BgKq&D;)+N3m6 zlqQOzNJI(|i7F(LiZ+!63c&;v0tBBJ8`~IclTIMee}ZCkIDJpn`WRV#M;K<#ufv^s8%~?Ax_t6c*z%Z zv?`zykD#dLRFFhQNZbN!+s|mSWBv;{QfD!oVb?zG>+4l9z61CH4tCuQkMP%pqw&}U z2V`NP>iI_~Bqra6f=O{?O#tGlpSUe6$|1F_9=ks1u7G;PFheT`~=R#*(m}+#hhB!evlZyp>O&gC$oy;NOuDhc*2*$!;w!RF~Qk0cu z{|+J;FZP-3D)Pep3HK=Fdh(6|IgQrQz)+G*U5x-OzU?zd$k?wUT9*OC9Q98(XpMqzxQ1rmIdrcB(2LT8SE` zA9RAH`vxF)9n|{pvN$Wg+1^;-BX5Bh_uWoi2YKVLANi*GwJYFbczW{bj}=pjItKiP zSpm;TEN3z33ygwd7f%-iHl$04sMi6f%ZY{^679MwPw3u#)}jY0e@^r51Bn10Yc(9;c8etn`Fh@CA4a-7H!fW zyqSTPVKfCt11Q(#{oarBq(6>3Bayx=MORiMi<7Sn`jFY4jvm^Eyt!&9e7hz_4=iP74 zTCZN*UA;*7Z~O24Hm-F4^8GK=6xAfdZSw-R@=Nby+`rzCKw76GrZ}G6Iioi(YRRxg zLu*9|DUuIyUAWNk2!s#Wq!^qEAK0JFr**{ zkm&JV;}I-Krr|#TwpC1uXf+yDGP}B^+?9XiU92M$m9kk^v0MI}E3kx5SnSy|0!JPc$VRz&8Oo zINa5WRMJI`>sn3T@J&gA1U_N4&CYUTZ2Go<)7SuHnoXh{bU0K&Z=Wg09zk4QfcPd1rg8Apa%QzS`CU)gpHDuIeFvvJOorX&EqCnr(_rG-v&$ld* zpj=f4j70-J3j^d223-6B*`j@X)iWU(IoTM1q%%b)WOeVPE}MtH+1^;-o52Ea-@I%9 zJEQqGgS>BK{55U?(;f4l26Y4V;+Uv)!qlR{f|%-!@XZAsmAExr7rySg53ppQNlPe= z{U@*$0zetZPuN&hr!o$CgSwNjUTilTy1ySDNE=p<=XjrBML#1X?6*KXP2FcLc<>8X+Qs@)sQEwCDPA6YM zh2`e>jFT@%t|?&=6MD3rD^N)_q1pJv=jefR9(1INpjKrfqESe-We;NXImW3TeW}yd zFF^b6%$f{_eIj_weIZjpNt9f_b`_{$2Nw1)s;F%XyPd1AVl|hfVQM54>#t}*AD^HC zUpj+mG@D~JXETK0_()n;nyMKnTSdEUF=D}nEJryQk;NR_DPoAFZ?eVtMhFH$uzjMkxrF~{>qQ)D^q{uSL68Nt|~6j6ajPCCDH{S1Qx4*KC)!-}^uQtwBG%xqJ1s>xZY$GD848oWAt< zOmvQB^K>@MSReWazOl!@G7SODOaDD)q2=bgT%-Sc&wf`-JYvD)2hg$du@a-z8};TR zBgoH#LL->?)8FTKbuB((7sHJO&*~udm5yXzY%sEM97;kqFr8JO+d_u<87`_Kl1YN(D^sF#*mQ5b=pTMDrd^cNV`Qwc`_U) z@1!$*y>`kL!8TSzi3p72C^699Inbz*i4o8oo9Zb*HjbDZ_D2{4*O4LUhAN~8I4&5a z!N&e@*XWgSxv`sHSUS!MD^jo_%y zc_kWx@9n)djvE*QnZIm6xNr-4xa%LuOzlLN$U6*<&3C<;mL^Y^wZ#v);!Chv@_<@r%tlu??w6wO}i`<1b;F<|FOw>HZjnv$&z(gd7mp~9Y>ll>6G z;V?K=1c@s5W*y_#>)ujy>EMsgsXTew!2bN*>!1CZzwhBIfAgKIfB$#a)hTlGmy9VdNl3~0N+Yo;ORmBCf z>Iy2|&ndj^O@50FBX)v9h?^^gr%M?75wCdBM+gvl5vX_<226_Zw{-{-)BgJ^s23;O zZo9{(Y@@m@9>?8XX(y=+w#34$WAP8vNj=spT{blP+GKx0=n{!#HAr?vt*CU7pjbt) z-SRfk6LOhQxk(`8UgWFO7jz|j>@`LmOuxmrN&;uhk{0H2wncWCMx_drZ)5C;xm7rl zrxCIsxuVl^g{iBmL?O*j^sIP#qITq8`q>$>bVCuBK2~qyWV%fJSxQMnz;p|ul?ny% zXy?B}aXZUJIF#UNJL^T`h@P#I7k>#DBLzZqH2SO2s&CQ1tZc0#*9aOu-B>J2iDcqU z%`E~TL4x4OYf3qOzE&21<4R+ao?7EVe=x8UX*?1|hU&J6AwkX3lC9X2^QCuSdG*n%Js63fT z7~hDz?zwM`J2$OE;O;ug%lE<&wt)ZwK|u)zUlb66{3*&sH=p_91vwDx!ys}rjm7*WeANOV}l za})rjKw7_vbp}v`HJnyH5rbRZs4H5_4JbAY6Av_v4QLr&ra-0o=!Rd+!d(Iq{Pa6! zsZfJw0s8gk<=b9H9B`ywT)+MNYY(sQpFRBC&pdm0^7Eg+{>gv(cU}GNf9j7uy#JSP zAMStR`SsJAm&FdAN+6ave9kz0$i$R@L_D${J09Vscj{}@{N+;Zc0RB zyhgGw=4cx^O**6I z>xRjXys&Qs{IzX?7oYleS&mrMIDD2{bKXYfZk3hbnu%-(UO*Ag6f}@`QO69r5q<%? z>Jp<97GzKj=t}^CEv{^2kBk7bD<|IlCUyw71&R{{+ARev2A;I9(*Xr4EDpTz+)}-C zVOL?JEXEW}+|gT%*Y7F_!#ulN{e)yZimkF1XI=+~WW?zwd2W|*fwYv4;*t@BlO$b( zm;od_{XW2mu7^uxr15bMGA@hgp`bZ4_3cWw2p3S`(j+3{=}(!{h2Kea&VvGSE|5$J zOAh@3jJX-CM_N%-y+2n9A^Fg!11S#1Stw5Us(}&%cw1#~b`ZsANsydZll4nlnMnP# z0)?O{=(I(Yf!02VFDt~SwmC#FAy23f`}K!(xJ;iEO{PHi_}`!!LT6th93@D7VNx1( z&d{v3@3o;hNhjnB)YXSnuAY0PfNUaqN5A0mGY(UslL^tmq3wVf`fleK5E94!3Y>GS zv&RccfYBWa!mLsmW%d&qAkM_-+=i-kNPGN6%8q<6W|K^;X^z28qqB5op$g!?^rH{gzs?-tS7aV1R$(EBL^8U|@0V)sH zR4O?Q?5t9)akirAm)tQ5+0@2*1mL{No)k%0{L-d8Eq;EZU|=MWRTyI!1W!|Bywucc}7%I2vwP|$U`tO>A-k%3mK^3k^}+tZ8U154#qs2 zk~WS^fCxJLr#X|uRigTV2L-t z7COqIsI!K(7V1=MAPyKFf*qsBV4WQyvWIbXJnBb-=dyZGz%wP-H`^NveDp2Qt5Mzm z@@9KufseKYUjMDH76+a+47ViSsN0E%S0ZBK-mtrPln#dW4a$c01(Zw7Do%7RFEp4N z&ktnZB23Y8N!S9=3KnBrd24S^ENgsWix0w+q_ss|`^ z6-g~v6h})D%m~R-44FI;ktmq?mywE2@(j8sR*R+(gO0L9=+O5H`WP2(k7 zi_3)Qs%#-Vks?@po6HHcj0e#P&#WEbivqy@qZhaYLP`^H>P{QQl@mNWF|1^32?w0; zXY9bnJjuGt!cBZ7niK=%EllAeBWPPShyi&pmQGF*ATh@ZYBmZyq!q?R2u>qC~&&bi!R=h#tW*&NM z8{2faBw#_z(EVm1CF$191<~OQ7qMN>pi;CZ-*FYPr_Yvcc*{f}M6@48Aw!DAYw~4* zSo3=@S}TA`q$rNrTz;z!-FX74RKLG53x4XYzI<-=ggGk4CTea-uuOuaRk^1RuexS< zfBiRp^h-D2`9Jw}S0DSfS65&9;_IeVPv-&j!l6IxC?0d-C?`A&&it1n%D^5*+~-ygd9x&Pxo)cVoqu5O=yhHt0$S^3#Zr`)PT={iEqq;~X? zG(KUae>*avbq~rU2=3vw<(S=3v8h8akfhn0 z9}EMT3A$;cMsqX3Dn_zZP-<=hiD15VL_Yr7`K4QRg)c(uaM1yi_eR;=+Wdho$zA^n zGLHchJ%em|GlY~H|Kro)t9xSc_VRQ_?Gf6hAAVsZA^Tju(eLm4IA~=bwrM|4rt@TT zkLx_?t|9X*g!JPiV3#;)_WrUT$&5U{)mPJdRnr%9`n$iLJE&<7hOHcqG-5UJnS912 z61(~x#nZ(rs%+hozs`R-YVv5pNDbbd63t;N7y@|{KA>b%_^h8`)AHGOlh^8-W7QvX zFZEBJb)>W5M8={S_lSlC)2n_mk)&B5vF45Sr%KokT<@AehRHXcRF!O`_21p!YmAX- zZJ|c>to5T`M8udETE3zH6pYrBo)!*QSV zY%?3OTB5h&9EpmZ6TR>D&T6`h(D=97sA@F8^Y{p9s;Nwd1Er&KOVS5WEeQe(=EcsF5v#b zgt*3VJUVlGZ8!jr2*rL^JHX>Xp0zS-A4t$|wl@~|C|lrlj}{gSr3Y`eHx~G4Ti`c- z;`#lYU>*F~hWjO{bF&qa-4{$UHUVV@sc+BoX(r zq&V+NOeF7RZ^HVntVc<$EaPqyRWv0IM_yW>5tWrqlBp99GvM^t)^I{xaO6FBGp7)# za92!cEwq>7btXX54$^`@9W0?#CHRsn5b-?YRmVzFR>^yELp=;h7^@08IxUWJr2*YY zoGef}^{k zw_g3@|H^;u>i%oL@NoB)AI*uM-`qZXyZy7;?Cl|6-#iVytEq04sHL9p%loH|Qc3ji z^s<6?clGM29_iNS-Ymb{kz(GjE^N_Z57@kDvhcJya{Z%8{oXqs&3@sh-`+Np{(8ey z^@-j^x7GxWcx^>BU4D5CzRm5uu*?WaCG4Ii!DmfZ2f{rfEr zJh^$|Sb`m-YYI0e`Z?F#^#_eRJf|jDH;Z=Och~P|*Y?fCUxf5Db&{W>_b&3D^NpIR zT2qTLa=-1a8g0`pwH8;zQ3QA&##}n^XaSWw+bA!djJhN_t!78*YjrZ_gfx}2Hp+g zkwRh7=79n1e))^q=U_4HeZkyp-9MIgxV*T6V4p~PJ!N!-xRAXwi z=Y+#>(*T>=qFuA?XNAL8#!PtmkZHuz>*XC=RCe2DCYU>iz`W29&E_`0~M94{z5;@85oT^*4X+YY$)i z+24Hq-~P;>x%vzL>_2~X`-?3N{1-Bm8uQ~6$!lCZmm_5AEdte(vPR8*l$)L6q3vcR zIBe4jq!4g4>%cJ+B%xk@s`^f8DrAfSoWKomTBY=0E(Vqz=*e@iK+=iPZN8~!P#CFb zoPqIFE*3YUCLb>duzc$2&CQc;pLw&rvA{Qh1zx{ZH*Tn{XTI6qSm2{=fggMSlh^`0JLytM4#JBDvGvZ#ih(BFHnv2 zf@x-1`nAB~ON?18=(}X3q#-Vg>$70f+p<*2GvR^_ta{(%0Vs!~K^LAo7<0l(cuJ32 zDHoK28~;Vb=ue_jp^4f_x%o+e&f-MBgD8;450}V>jb{WU53(Zkml61Fu_gx@!Mgup zBafIN%e#_KN{|krWk9y+EF#lCQFf7Mix-=l$wPuz!jk||Ux@&!eQbm{N`Z`NnPCv2!&(#woQyO2yNI$J&akqQ}VHK7pGvD?EZ9Y zZo51f);=Cow!e4JUslF|MKqYzx6%uT>sIZ z{i|319Z0 zt@zaiWcw3=qpru&*zqKec5umjfXJA9ZY{hR?Tmj-F}sPJ9)3iFyl0#UwkfqyIyG)gh%^Pf1^lZ_)0zxs@%JIx3wC}Dd(r^v~L!v zVa9Lzblx5Zc4gIFP|q%R?BD))t?6dJd>P!+I??m1hmU_>ZvB={+)WzMD;BgCk!DG} zUWKI5C8BA1?AxhPSjR3NsHprBP;&ny;RH54;)zQ(ukUUjzW5UnYjGu~N`(6luKSVl zjJD=Q0tGLS>hn!=ng(sXBqC$FrFYLi$M z-1t|kOJbZ*d-vl0?(08!egAdF`NY-zTi=>FzNN`~R-}LO^O|)~wuqLluS@b)eE2p? z;oFE}Tz6MZ3qZkFdS2QGV&KK4?AFQ(n+TDk6VWKQ1(meMNj&{ZsfpSd05H-6qSF@l z6m8kG*8csS9hi=T9;bwD zAHM|Oy3a%pwdOg}Ck_2H4p$-lRxB#(JT?gu@>n~G;La;0F>eZaf(T7iXA36(?A^@s4&tI9^5MO;!J+0(eTt%$N& zgX1T+2Dm6jIh(R}Pc&KrTb0{_5wJB!hFjlTK|nQ3e{0ju+Ndz$Mu{H1eWT-+EQ2-d zU_Gxm2yedMSl}aUf!@<}eyRK;EaMx|-dJE3_$}Y@Ve(DMi0T!I_K4gln6)*A$SF)Gli>p+-jo@IwB5!I*g1aCz zWu}uOiHoOFX>kE1V_SJmv3wWvQ?e`6-M3d7C&%t6<_*^n5=3MEl!2vkdNkS5h!%9_Ux^t2! zn|f%A*#M)6q@Got3$w@ud}%8g@{C3>W`mMav6XZt#-T5ZJOXX6C1!}}-?i81NA*G^ zJJPhBu4A?5$?doF3c&L9U;Ja;oAA$F{ig4~y7@~#eE;x4nQTGatVK_Kcn~kpU=O8ZieU#}X6A)v7juv1vH0Ob7xElBjQhB8ltWeN zWBUM|{F6EXI*0vOKXH2}$NSe0pZ{yu*Z=O5mskJH?|k?AkNo*RboKB4=>O&V%Rl$| zhoAq^FI~O&`Oi1&e|i7nOIJ5fn({W2yMO1&S8ncKz2;UoJ>k?XIbEdBeXed^Wq@v- z{oK>*S2rKrcs##(=>1!sRlj|f23;3#pm^5h;~oR5XV=g*$L&+rdRn(5<9%0fyMkW( zbytreq|0%2nVV<2WkW~w_QtP=8`JriST_pmsWq~a_hxv#%kaKgu6u=E#j~QiLHxUf zetXx0;@!@B=kaiEVM&8an)2!TF>4y#{MK)sYN*?rw_Nrs?J$*anYhJ;o7YxPtU5G= z=+XCX5q6$^-?qroh|sQi&;5PByp~J3^(ys(y}NtA!L5sLTEk$KjUMjZMG(a8-QCj< z-nH1!W4rDqczyG%OCI;#F_93dkSX79l6?JsHq05DWmJ}Ay&NeGqI&nvv)hVpdY>bd zM$6m#*Bw9UE2?Sz>gnz4AOC+o-2Kv@xw-n%k9Sw|w>~`m*k^igfua2IQLSVRJ;&iY z$G>W(Mszvq8` z^LKsflh^M&e{%gTPcva}qm1tD^Tw)fICSdw|V;QqI^PSQ)>y)DWQH{8F>ZFBRCQ-eOgy8Gi_k_ z*MIeY`3E2FzwqZCuHO4vZT6X~n`dw3q`d^PkPXH z!zjt*+XYKZq;sf5z9@h|CtT4hdq%Axam!M9(6mL9%yqSkR+Yql=kQHfEH#X2Rtf+&%-U5fNbV6k@RRpKt2FnVoER=|*wdH7Wx zBQ7$c+s9BcCnadea{^@DhcYMOT9F@c1WBUZ5j`MIJL>d&8#FsTXG?#WL)*9G9+h3} zSlOHHjRij17Px!bt2<98_-G6IM!Gi^V1W(&Qqvw=F*Gj@btG&qE{JqFbit}Y+nC?L zESjp^X#N=Ls$C^24elGiqjx3@RfSxf$54XM3`QY46b)u0_ zZH_PI9pXsFyX0;J3qNBM%p%s(&Vpl!iM4QufyhOppuF@ValHGASV6d`NGx<0u!$v^ zT8XSU0@4vH{xgQ(U|S>;K_yrfC%Bst0vp0Y;I!JS3M|-<{?Ql%)@0l&4ocrkK1!n7 zqt*YC7D?R&Ft$hSWWqR&u!{RyA%fFoVfiF-HQZ>$6U@M5EFncB>!b?ZI<7u}cBM~M zFo`KUW2~LmJ^&9q8KWn!k&ToDh28|fURokwh#pd*UF6s#XHtf^8QE-5!czNah6&Ox zwAxA|)s)LA@;tO6%X0m7;sxQm5*NvA)%Oy)la^GEro@66WQfvQ2$#os@f6+t*QoEW zoCMm;#JB-77^N~hp^hX~@PO*%NKsD24%Q4KChg+X?!%>{iAewRLkXE~2GP~jT~&GU zqxYZt^8L+k{oaR%|L`py_jLBd8r?pOVeisvTas|DI6ITH1#+xDV;<1eIoIXW=8)@% zX>4*Z+nOZWmg3ecf<2@U9E^({qLl)DTv{5hYStMqG$RuzM}Tqc2t%1|lvaNRqUMQL z&w8hRGJN*Gy1n{W{`S|de((pLKm0E~eRKW&zvbgsZ~bHMmd}5G?nPl&s-JZ1y8Ug} zalJ;XYJDgJal>ascyOMXRgh5AOxjtW$R!{pPth&nkP~h}2rf z{TI5-cK&^1gXR&e9_iX=?Gp#q8`>LbIFDd+;PbPu8!7<3{$&P5b1E z_kEYKJ->06F30|^^!Mjg2l0n{+t7XAt@Za;@5cvhs}e@k_ID4@rymD;`|A1Yf9~er z`Rd)>ANoiBrJMV|{C~Q>`{LdG&D-DG?6lXTZ)jzl9J>Vs1?yA#L2PEYc+}N0K^z-n z^xVMmf{h9prYp>iWFOq+=!TEwXr*P~mq>Gu7aJb!T)!0x$!nv~h8FmvDHQx^?RLSgU=pJ0Cvrw8t=h*AHG@|IdEM!$0#&zjFO2zvtKAU%mL*o7c}i z-EynPgTUrA?Z>&6BM3=+Rr}!~owxc_o4V^@4$VHf65kwX&>OQMMih~%GaHv{4+-fD zalUErIzs}6^L4v`$Y$KF;~L)t6~$Q93$&K4FqYm3GoW~pHJ@4>4KxPg2tkan80q1& zHLp!Bepl5IGcE&7R)g_I_7H@IC}f&y`%<1i#yF`KzNzWYILUuP;NDH>qh7O^^dh}U zDXF6~Y1=17C<&qC%;r=Hzn&iN>^-C6UGSwoW@$&;WXYXbCDH4V6>Z>t_?4gk&WDGu z-CqCkANW_V{;~hjzj1r@%iR_8-Rn@BPGWMQ^q@7Wjx;V0QrIz+C&y_QnDqZ3}$$ySo1Lx>#_A@e^1Y zaQEPQBeEb-)y8RKc9B4CFH{M{-2-XRmSt5_6kE_2h%fOV3Q|m=!gUIlNKh$k76lv` z@{30upEIFB@9DD$0vN4mh9Gh+HuvS&e{1s-W(Z_DL@_Bq7JZmpGE2j*&S*S4I<6@0~+O!qE$%g}<%`1%i2`q;;bgjboFj zP3EI@P=F^3aa@u*n_HEm)^}#ZBE%9NupN`UCa8Xjl)N8{o`fg{Yoa9uj0tGj))bhK zK>AxFlBmu1WN7CdCfX{Y&vSyaze`*-f@H#<_AtddS9~VQ$Y{d|p_Qd$zc!h1I%lRJ z7&gs5Oa_7{fF~Q70p6~4(-`eF(Nl1-zv)x*e- zS@1I90YFwAX*u&vPa)rb?dCuI3-4e5Q@{P${ku=^ZeI3ycT>|mFl4>y5%sK!&F190 za78UTn#%mM)6+Ibm(%p@IE?WolB{blV;FWag$DyQdcdRnDu#XP)Kk;PF;&$(md&;+ zOa56JEdDs2BgZz>o5P#jYpdJVJ$~iO?_B*yKm7XYPk!b8_1o`0xqauIXLrxTe(PiP zoab4*j_|x`)Tg?j`=lweS!Z^)$1}ZcBaL1nxF6d3Cp{?iu<(dLzTtvy$e!e>y5jjkm-ky>lAbrV#KmOPu=6T6a|#Te);AtsQsERvh2eXdvo^~Ag3E>W7lJlwf0zS&0-HVZJtYEGAY z-9FM!Su)Jk7e08>E@tuEjo!6G*VeC}^wNg6x)I{neWKqAcyBm}x|jHPi;1Icfz7of)c{m!3XY_vnC9>DHSz zdw0Z_ex#L=?|S%u|H{u^KY9NC^}qe{3s-;f`b$@LFYoSOaE^^&tk=yO*^4WE(0e#) zrtaH_cv0>8`sw`#UU-=IWF^nFTYWM4>V}Vg+n}2#PAuq|g3MFQH62U@4U(Lhf~A_< zJ-pS5$XD;)z5lK!AHVvZURLpczI%4_?|t&y?*G}({8vOkWp&eNaUhx3 zyzRM{=-U8Fkcx@iO)z#lt~lDUV4v9nEdd+^Us2jO7*)qaM-Ea|Wm$_Y3|g^Erzph2 zqZDpZhYmZa>9M)LG)q6km@aKO2pS}LgzwO_? zdjCg~>c`&c2F>0}^6W|7CMo)n3WRgL`khvuZr??7002M$NklotQQ~rw+T4Lw z8d^E96wxj8mo<~7k|z{`7pyu`NgUad(7rGe_JT#YpgKbfW+{~gc~Ug15ZlU? zVuNk^6`Tl-P$^saS-C)=)9d}Cw1mWheuPjF^m_M|r!%KT^Ddb?c_zP^67{1fnHKK{ zNgDcHZe?8ZO+Hys7oD1As4o*!mOPQGdh#CyR-aO$c*o{@Gn`aDIlEvmg(PZA;xAX9 zP$EdsTcr0sv@I)i8pn8K!+n9Zhz&=P0){nJoLK=-5S9KN@=^4-o@?fe9Da5%rpDcOSy734yl#BS9QZ&u1Y;W z+?y^#KRm6}%lW#4Bo`yl#asNS#>;NL=LmHUzy9{*otpM_FHcSOyzb^zj_g8iSl6#^ zU-dw^YxaD`pwgw^=o?{78X9qK_PcW3&5&oXq_Qwc3y z9B;XQ@!HHwmuNyj5Gy3zHWB%3degOkLj*03=-Iu63r)pCu3o-O|1|8yh1d7p=-zt7 z{mc5|)823ALiy{v4{mR~PCprY{$W=?#fc`^SN-JW^*f(>@8)TPgzw--36a%jpO~SW<=m9bCyPFJ>|UME}lKqpQV;sWeb+X-Eu5y+#hHgM`Ulf~c7W$g`ErXdw2N7hM8izHuHKaEixdPP^BT}$LQsM%G3eB=Adb)K&YcZ}l1CiVP zEQ;BuB@lL>f>GMT8||tkCzxzmHDv&S%uZ}z#VNSs)m5F2f_-=hM7MFb@<0MhP_Kj?*gs=z1iMa;G4k$ z_phE5@(e0(wl@~|h+E*T?GKJANwM&^q$QT%y~oMh zmuWS<$Zh(bT~sgW6D7q)0u)h`cWNrBEMd*4k3M~ey0|(vdlgs^vmCLLZ#FCVW)l&< z5L)?E3u1k`z)`LNiHE_v12L0xQCWZ!|Lfh5hICeL`F64 zo86<|aWt~;WA)@frWaCennP4gFo1-XL-1%x&JZuMB_XtAr`Px;H`)Zr$rPtbyQ5oG zfzPxQ)S?kZBh{oy9c5&G9M56plcbHDR>Ao69_KY_5~*bsmHVe3qeT_PE)hR{tJ&1m z_2>SxKi7NVfAiIE_`B{O{@PD=3&3Fdq8844^3tS7uo`(#0MQ>5R?)A_{*-Ynt0Y9Y z2)bimE1}JN;$V#YAe_g!?}Lv5dB(E+sR40xy0BcMz~u`}rx$a$N4)LV-#)zb);uf( z@7O%zKYUBdrU&O~E`HPV$kse7kG0$Q)bqlyVg`&?2`ck{D{2Noc2H(VfP11GRywPk7jNm2gOqs_UAc8%rcEgzE%c~b}=hr$) zqVn8Efwiakk41nknS0g1)t~>#+a7m*di(o-@csLDe)}J}e(@)M;Oc|-zi@T)*1Jj8 zNUr;|%+U#tj`IlB1b%`vMfh$Yr{g9CUlmu_>FMp&`|o|B8#>;-{oUX5-S12x__P5?#At2S7=;iI4hMIJ&80> zOE9rcd2a=1!J|c{UVOklmYW2#r)Mc8`n^J-7Y$e*^5V;9S6{k)asAc@H&;LN@#~x4 z_vD?cKXUcX-T(HF{mZw1_%r|3{j0Bk;p*APzboOoH+mx}|2=A@c0P2v`q|MxTgQ>R z^R*nOhCM#F#^*ZW>y6Wx*KTX@&7@(Sh8hVy#m3RJK`v22Bxm3G!Et#^JLR$<0w+MF z8mfO%>>M?>y@;Rly3CAl1Y0nwUyG=5-IxqD!Xb}>C$%mw>x_Kn=(}Oyv^m10tvxWc zC!aom29W?LqkkpJ*H=h_O`F0_0FOMiR>GB5Ig6esgTG0+r|!=qD5Ax|fMl39bEfMV zfJT7>vvd;PUjN3MuixEutJlLXbq5$3TH(yqN0N)@hIzZ&%(Ol|5Jdu6RAwIzrgd-v z7tqBBVML-a;OxTBDW<*=qvJWzg>F>AGf$KlUfax&9YrJJHIRuuM`H`NL=%pNV)z7~ zoNPzvASz6Vz8j$q?P)9Khd01#murD~v%RsvN8SS0Z@;<|!_FAifxq`;{Etj5k`2~t)d_b9vj`E*AtxXz=&Us zJP9K~U6c)_09=$2c$QKK^uZp&01;jWw@B!UzOs_5z9%UCV-tQ9@EXsR$5G;)-B!!2uo_(vgm}zkXV9dhxec={L|KldQqnfz@?<=$K3NFo> z5KOob2JfrvxN8d0YvRx(PnLpm|CjcnUkWj9&CUqbA*M)TGqp4kDR@ud(dx|K2I2Uv zSptcS)>Ji>4+}cd=A6#Lj%kL+fu7NyK|8V-|_IaUm4QF_(u7Gng3OH@H=Qj?@&K{ z)IH*8>|>#wrQ0t~t*0!d(N$-dpMu%P`S> z>m=ij@zC*{=Z1ApKMydAF5!2p(6Q8h(4=D%Mb8feNx+!6ogi_W`<~v9TZEbcC`gFN zO(2xgqpIDP?jZzs6(hs-NZ{L0Qc>U71?<*UyFZ}25Mm7; zUW*LkL$_(%zkYf3wGUoj|JfgZ_3(#(@cGR@^auXls~`Fk3G(V!?tA7Vey!SQy3`=bV>Va8|>wd2klFi$$=g{?{vgCD5tFK=$M<4a^)jk{yg zB(Mz3h6cL%I5(`_h|b*mf>kSW0?t%%8#_7a%eXd&>|oPT2>5Jp#UbGY3yA!3a^M|8 z$J2s$BRT$^WL;WZ!b*0anf~2}Vi@L+#{w~&3>U3~?DWixkAhepuQ{TDrg7E@6u?z z??#LKkRR6ymLuyqwRN5*RA6@Og5@zE&VjFCj2O{1n&A-eIFUC5c04&i=WIxUkb~=( zUjV!aez7KiEvGdeYgy-O2;yUEfkUT`0{u1WXskZjlHV4;suPIq^TED=#<_l;_*uq> zPmKT~Z?-oU_$XW8;lR4Nky1T=vhny7fD7oB0iV33f-(k(-sX%7O?Mbc`$6F|o_4O|p%Pn{N9eD;VaCb-(jW_$WPhc%v&Eon3JNi`P zH`NduNM5s~#R!SB1g;l}4}{)PMyCHH#G|6vr5C1SdEsZhV2zLPg~xdT+;XazZ>AH} zo)`!}k2!5Zw{WOMi~O~N(hSPAb*8;*>RqPY94U+=Bkx+mfBa$1R6i(1N6P>L%*Sp1j=~)BekwKlQ~YSO3KKy?^!Zy!*Ug znLqdcE~z`KGZbm-YyQKKn9iWbHrjzugz#ZA1&8@*t49H=sa&%jM}73k@le_JNQ>B- z9t^@;y{eY(HZ;(M>)8I$jXT`2J?KY|H_=5`8F)@&3y&I2An}_tkvwn2s*-O7dpSof z#*hA|6Pxs1@TzTvuXNj_`x`=BbgtwW(7xILA8FCS+v+u#Hl56gvgL*imP>|fmIl*E zm#OPV>A})%5$mVj&Q^gp1xCI)HnX1{j5X%C!1-9UpuSW8{N;O6El!Q|QQ zxo5hAJ(11Q%u5p-S*?>Coo{CZ^qz*?qg(u6e(ar|wSLF*3J=eo-#mQ1RiUrE^Yu*r zQ|s5f%K;icMiI^P@>$SL&KNK&m1pw@mvHP2TqBlARK&vwgy8aPEWy=_| zS2ZC=1ogT`-3Y_gP$OF0tt54JG|y5*nap8lr;Zsf-Y7&FJ%TY1kjYdjBcJnVbv_=) zcOn3p!yWe;A^s5BVJ60{^AoN^*s+)(mx9Tl?|Uq$?9!`B8`u)8(WmzS=&x_KHx~G2 zu)y834;uECNq;kl`$ooJ%NF>tU;Zsc&6b!&Au-7PvI2sFJvn&l8iV51{;G`OL_i zbMr}@_m>!Qbbm)j^2yjOR0Kt)3i{yG*(`@+D8)6U<~X|oM5!$&bP7%81v%Ko%8o)8 zi4T7q<5EQLI@1iac+Nm9zInqyTv%@@!}ETSu^R{xS1qojLF)3sTDwRh&g+!_U# zsRGA5V`rf{g+HT`h_Dq+$Agwg(`|IwV*Eonke$b9?SuHR`$}H7eV?R>BjG`0EF?-Z zMttBiaOWj2Cs{8k0-YCvV(}ch4`ns;B_(3N!?I!tMkkZYZRKg$C;QHCX+%&7(>$7v zQM2Acs-DoUz|4|-`b7BZah#KAR1C4vYb3Fe>^pb@_80$-*f4rHMe?nh^6id)<^DhZ z<6r6_^tZ0R{W~g}Inq*w?J_4tXoG=8GzO4tE@Q;hhPd*8;DcqG)cmlX=gvH_Xl}Jkp$F^=IHpzDFgM1%waV;W9_q zd3EahR6Kg);Y7XiHZC#yRtCsGiLu?9v4hLN@L|QyFT@AeF^A8TvHzkB=+9AHor!ZSB)B{prImRbAMfcIn*hOyuD|#h@)R z#JgDZ@anGHf!pw=Ro2uh9GA3DmEvKiqfWrYG}_H_biJLAf&hB;|BfSL;`9g1VqItphWFMl@XbC=brM12TLW zs5Nr-U*Dv2ckITedXbU=Ezr{_@3zWj);VhP$YfOMzX_> z43`S<$Loy2Sm9%QtiERV`wRk33Q?>|L?9ZEj_p>cDq6-vHJWP^f0$FCdNOo2{_QrV zhocQ5+UPid4y6;M!zvWxR7d-sz^9AqR_wb;Yj98^o6LqU`Z0|nv^3B{-y{-0*GLN0 z_KDJrL;hjFn-g>neJA-qf-HYwl<@MOLGtlq*QCa+&xZe@hmoLabfPu*q$43MCvnZ2 z{f2vNpq8H!uib|26Q3;ZH>wNs%Dc+Z zPL8##rAS(@38i4O;2^NYSEfR$zWqxgT;)IfOOagi0e~ts9JEAm@^0KGv@nGDG_`w- zON5Tgv^kNjXjG*r8?y3o4kUXh^y=j7fMc^axA1mq4vN>rcMJATCwf%V6-I$90i_91 zTVxmLa9Bz4pD|)rX{0HV08w}6K>R6++Ayl?pucE5GAGwX4oPt2s*VQb`$JhvwUv!X z5LT85wDEukg;@}XO`;LsX+|I7j1l^7VVIqyEhRo3#}ih#K@qOAu7cLGM}H-5f9(^k z)XE!Duj3%(Sp8v=W*88rxd51+q*YK6+wd9HK46A~Vn44=i72}ZR0aydf>c~(&hBri zmVo@+1LN9agfIS2i2jy5g&yA)YlzrM@-6|4wl-y>36XT#OVY^ZZGoY`Bo7o=lq!Rw zHP;!)l1ijaC?xp~M(sU&QSI&1=ifedKlgwAk9uj?)x-Dxrry~RiWUqnCLIzw(lKaH zKUERL)}hI~!b!6R3T)V?9tD74(S(1FS5qzM$FhmtnKElGr_Kai1KOrh`y}c6P&)8n z4li(4OD=6cDk!;C9zHbtU8$>99ZHTi+3(n%1_$rd*HXVqeiCHT5SOikuNBs_D}zU7 zAB|(v~b=%kQI^B3wc)G=b9gBcWhQV7x#QWIEUNQMF z>O4B?)_xb7Pz^%E2c}W|gGZnb-V-gjQ?77e2LO@k_SARAaX0m$!Mbh7u(K84pWmQa zrG%Z!l8w`w_zx#EZL5Af(WxFqe`K1kRDbk3G}L+YeH;48Wr)y6&ja2q2CgIs-O$uL zk&I0!Txb~-8en30e(PWzqjM9#0Fgj$zdCyTOXt9v-aR?-?2fYG3C>r*ldv>(;4XeS^p*9KDA#w6MhoFM{8qBrK7ol zvEz!fDF;sm@2c^4^D+h+G&v?qxSGt@0j56e0=&LO7x&|-MB8MI!Bg&A{U_q$msuUZeb{E*$a_mMS<&P?T(~lp26zA|6vGsar2-4>aEs8mia z0RD3T3+eoTD~>-$$y}CgQX93agAUHBBPlDp`#EEvW3%>Y7&{SG-ByfB4M|ND?O18r+h6wr3%m;-a zh$ui;IqjUYfv2BOKC}Ety&4MRTRN~e9%lAUikH=DZQPcG z$yFE|R$^cSmp=?>(`~F4rITh`z{A(>ky_BU7;+L8g0_*7HI8O0YBj0TQAcU@+CBan zWX&#fo^6pBoy~p-_(&>~Xx|D<6tn{Shjd|;A8DrQ@@0cDW56(%r{3-?$Z&|VygbIb z5&|GyeIfrQ5l5X(&;iUYbc?>$AOQJiH+?5XxWU?j_8@jXK&@6;_o*McIQrMj73X`* zpFG`(9GN7zd8eXQ1pfN};rdVi%GK3x`ER^+_tsl+A@F8FG0lc)Lf`0Q*z7}hiD7$y zmD!P2{pFZ!VXs_WB-&1!CBn+>$qdrfe)VVLU|`M^nK{*<;REuK=HYPY9a@iYkJD?k z4%7ysVS;&V)PswjlC%H+q5ml6qyX`#$*HK}!-MPmaRYZs9_^q**v2E!qg4g$wq5qA zoUKt(=73H4&ZB*t+ceFi9~@Zog|nSnNH?I&a3mh|6`XlIwE`89R{0_|Fq?5!g*wE zmd1E}?=!!pSLZf9TRn2F;<~xz_4DR5$@IfgNGqfux2c>gC1>2iany zXb?h-tJY=QN{z^!JGI=A(BZS&+nW!f+S3$%U!iVig0HSmWcIBD^EL9N4vfLW0?Kq& zj{4*H)R~sqU>9dgkXtGbgiX+M&6&>`N;XjzaVic6YKL16x$Q{+L)yc)y_*;f`e_zK zUSX+VaC7c8#E)-(Mv zF~728fcQ+nci0IU4QQVpc_2CwfNtN>mKrWTu?UU~JQfdRp7Q7u6;0gZJ?CW5%u^?Y zLPy8rUxEB03mq3naE(z5W``pf9w7*D!85_yl+8LX3f4uFpg(336%Uy>vpMXToj7Ea zt|Us;)ryfr&}kiLy0bwbNYhiZkE}S1HzzRcN2o__df{fr42Ka^(m;UAsvWeA%NgH} z?UY9m8GIaA$k>_KWG)E&ZO-t=p$iGP-^CMHt9MySRuG8Gqn(!Dm9!- zSjy?uX?M7jBy1STR%Ws%6@rnZOzgfdR_n;(1YdABDx74X*d`IdxhX*E55F%BZ2TIXNX- zlc)3L((WOsATztNL=$oV{!nC!s)^=YzALPjDv&*GmDNI-9m1--c8E_#wn%h9Tk^tE z8wBE6Ne=Z2 zbcSQ`gL5=Gu#eInAHV3p_o9C`W7KJXMMwJ6{-d8@E&@;9Ba_E}?_TBmttz@}=sTY` z^?c>omfnnh^?Z*&Uu;wT8m`ueWn?VwC2If2h#M}dHS~xN#H|nBcRP*43}ij<=K8&> zXO()v0ge$P4=MSFHti*)%1-|cWaEwc2AQg>I}Fzc>p>vTSkNF*J^!0oI%)M`^zkrA z5mKZuP9dg#f*W5RMue$2sL%saE&^~n<<+MwJSe%vor;K2VO$-Q`N-#wjQEZ>nx~aJMI{C-x~~B}!mYFAb-n1vh))rGYo1`o z2e&@eiBA`$yulPf8xifM5Jeb+p4yA933~3I;U;vb4bC%lb=xh09Tdv3CC0{x@GK08 z8J9MwzV3hrjy~e&_&HauyAUZnRyxksxdt9aDqK1Oey8*h#CMG_^wUs-U=Z@ec#TKS zU^U=)l_@!Tv%RsvH-QEA2*6!$8U7}a^^J(X#x3Ay0E_h{U<+hf!^peXTl^_37}&k| zsF7LjWM0}9#AX0Fd2U{WFgub<6)G1a${QBj7cJzVP$U)|pD{j+!WSTm4gygOcKuWM zSRib7Lcd_J$S2N9Xd)&)U`AxpRgQ-&&tH)du5@U#!{9!Q;zi37TTt?n98xA;!Y}AY z{&L4=HErYvwRGj2u7IgJkc*g@lImiQ*cQ;3V=E*lgPQI!+QLGpWS}}LFQ6|7&Wa1$ zq!XB#!`lYvv_E)dT&J#M_8+kFmmL!B8TaV6EW zomQDq=okn-@!y1izLcuy!oCHaB@Br<0dNQe96W8;X9nBpVVJG7$X&AABr%(l5gu=n zFwrqFW(qq?rqXC~u4Gh@jCr2IS31kaa`g)L`a{A_gBei@g(o$Tq`w4;YFqfI_@p`& zMQ<680+58#X7Ch|r($ATJ+S!IS+wojS{2e1A;4afRXy8)qeOC6-<851xoHWalL{YR z-rqj|_8?x}{>;Drr=xv;|LecIHG$m*jvSmmj63d~o=I(!u1it+l-5mX@<~r?kfjhjze-yBCatyb!xf>^KLsR9Pu7mMdGJf==({u=r zM6mdTEBCti1wCH+LuYulxd)m68>CKA-;XpM2@Hq!qunm3c|+-u~=;hlBPR@U*+R+_=g}It!##yQKB+R?LUl}=Kl>ptkGbdq!+syL>JmTJ!Kfje}<~O zv2PYKAMdm?bdMR`Zf|VlPPj+&mEu7PJ?LBux|5&uhxtKGK2kQix0FVqv8%HY03MhTCgAsjI zy2eGsfH(9dK)(l}cp*bf#ytb~HCU!vHIQjsgH}0)>_MX`5X!;Ia#OLz3rH9DUVfH+ z#&^CqYH%=&e-P1|SsC5^8#87CARN(!9%u!@$|fWI1E?Mus$+86d}0!RkO2YnCDsIa zaMhL223w-n~P;hp2X3<$p zYf!)UOEZE?VY$&-Kr`bU%tfK%zvx_%@p+@Ype(kSITf1><^{TijLqr{@rwrw$FV8| zckb7c?|b8IU!^@!R4l#(FCgtEJX5D3i(ZuxCmAJuvO-JzaH>%;+YV$+YWD$>6 z6nnAlEU90jY}Eu6W#f?0;^(f*H{TjIxx~9*mc~r@Gfj&dGljkf85h6O{ya5m(4}1!WWb1?MOvm}z9i zYZnYVnk^YPmZ8f3{Os3nZvF?qIc=WRX{~N? zD6q^&?^!Y0G*hj_NV;d!isFWBfs-Yqm1H(>ZP{I)emQM$qe2e?Bm|qBOk{WD4ZYq zzZ$N>OSw89>OMyGNBBqmYPo7{-;np52N5uMZSkvZqh7l_ZVn&mPTo#VnbITYBmE=u zDIZXP`OM&yo~gk_mI@wqR5z@?xSHe1a1mtn15jbIeom;jx<~x>{Ooj8biQF%HXT-9 zeiCuV5u=lB{@Pv_=Kd>3zvFeZj`geh+0(bL?w>!ux_LKW|1W;%CqwXN(6_*j)QBTf z52+g)OQ$MrkiWBVihk?74RmRosCslAc<+|n+fjSBX7FEa+<4hQ@+!TEl5$zC3InK( z4eGe7Q>3%%f3poCjyVYcyWb1cw+_!0RF*8pj@f*8)f+3ZvKa(m`=^Z`El8fb*LT<7 zdh^cxFV)5`zxX+!Xf!fYP`1?Te(~@Oc?Um1M-{+;i1IjG#5OQvn~{-a(i39sm}1k_ zu8qCGb{~~FE(ys5+r3vCy_K1P10>w|ofP4C7Se)ESngK44E{uP{Qk~&B^i!iEe&=J z-#)u9kLU{q83q(<^m!??6NlALz+jK(&?c~{*Mf}JpT{485UJj(4=RYC?+(=+CnhAy zN0&*UzOtDK65+z^Ut67Ud)hWB`_K5vCH|}r1gj}C^PUz(L2_p?#}%SC+Zzjf6IkH& z%ctG&e16mEn?T$*BK}&o!0pR-8~T@$6jRLL8*h!tCwz2v*1Qek(g8_>q-VKS;n-D9 zn;bz>1x)-h|1`owC`>G%*`F}f1icu1*NdOzX6OQnzc>7cRavx)7ZYQVf?&eb$V&1G zWp3PzzNoseV`>&oNTq$kQk25GErgB+anMF;F;={pz!ELxst%A2O7VU|OhM4{&~4=p1Xha@TfD)ysz+gNc)btI#8 z`B}4s@1IP4jTxx)FMDE$sCb5+1QK@5+gS?%HJ8&sO0-wjH(_R_s^j76_e2Vcapf|D zIS9eZ=gOvzgs~kbqQt!7E&sq!Ft+GBrlG8-WAZalhW0G5`I7b{1Q;hjXZFBDtNmJL%fkE~5cYIIa?JKmCZN_Bl?BX$G#G<X}Kz+RC@FD zVca2Bbq+8 z8Lz$iBm&RpSAJwX5}dMwMe0*}Y~lD3r^9XNJ*!M&OjO**Tt+3ai6|J)9o6 zL+@gHEPoZ=(e1Pu^i=~1_Zu|6aeVA?>My*90=9z-`ZwT%xfDOjb0L0YtBxa5L}dKeoE^6}Pu7>5uu!FZ3L16>usH1|IiyuKUe)S}mfkdiSb~@A>5GE(AWm z{_KD9XSHa*lJ?1dtcEf=)T0pLcMB1L&d9QuQ2nkSuXmq5e@N?E4Tse?b?X7+er@gM ztHJ8F%}8t2;Lbd%z0OZSp9I8&{$w{hTif`H*p-)=sBdbI{2M! z-|_gjqIXBW8sW*q!zXW_U4NYqy?Vd*=OYy z({xMDVf8~PGm({n{%2j%qURQg5sKI05QSi718VFrON@I$;z_TIJlq^}d@ZP)Rq#oQ zuu>Al-!Oz4W{%A?4DEDK zGX8(r`}bID*Y&>dnsdIVwb$NjZ+m;&-j3rqaRQA@!A+V|gVH7>O`0YuX)S|-8d{Vn zJxcsh2?Ydd5QINak*XFDX|SjSfl!C0SfmtE;gmz#h(v^@xT3Y1Cbn@xe75&qYrX4z z=j8kO-DAA(awPtLEzf7pF`oNy9e&q!AFlg;o@b1K#%JG>PvN>HdFl>YafDEfFiFZs zere&_vie@Ity?E{{;Z&yXuBRELW40;+WK^`^TQ`rOD?Eed@5%jX`?5C zErI~784_8z9kS!Qx>6|D{y8Sqz2(7UoQ8^w9PDx|UukKF);?qNxTF{5)|$hUa0=vB}-ix4ssutf|(7m-~+ zI6IRHY-}T&M2Wt^P~2HO3d^x&Fvu^uRFX`q5iw@b$1(_R*e>tl*6x=Sj`|Yn{;CnK z(WvWn%JSJ30R>(W`lE-37kC7`Mn{0M=?I6kO zE!S8R6CYpml&Ltq@I2@Lqzxdq&e+nYF{#B9p2OYRg?nf?)lHmMzHWRTzj! zw5m)(^T&89Ng61AExZ;bc_izjIcarUkVMq#t4gbUEmbmd9R7|68xN)DiRx8xf3&D) z?_@-+gdB@o^quHJ-qHJv(RU7pT_CvXs_m~g-#l(I{dF0wP~Tqv={LaI_mWsxZd$~QQ_KY(7s_$!zH~gK}@h#Hm6G{&|dSN{U*b4EnDUay7LvAv*IiL#!Y$l;=S^p zbkyhn_^$e^oRX8Xynb;hkl(8kzA*`&$}*S4_62XV>3k~{2`u`KGa1qjRB&CrcPTy{ z`AXoNqV?Ph!3EtWCo&WpFpjI;(pTHndtFf}mmx>_wZB~HVEi=w2FXV^pL_MPFaCRZ z^L5|&^ya6k|LVW>Kdm2jMatE;6gQ2msn2}apuG5NBf6~7ycx8Mk^Mpl79fV>+1-oc z%~!j|mopzed;d>^0WwKs9yMO;@pWl3N^TF+V&rY6PJF&WhH!>oWf)j(Ryrw zyxs;`2QWPxyUDEBRp087o8rY8kMZNYK;QrLE1o@#Df+ow z@PDrCnzT*mdbeOw04fCfKDvUO`FfLom5V1^r-(xfCUF)Q+~O3O#ErBTj9hA847dc2 z^jb?S9to3I)gs#h@KIenT#OMmT^lFiCWvpW@G7&31c z^WgImnTlt^QfXlv*PFVDYf+FY$k>m}1qlm-tvIWTJ15{rXfa{wYKAy>t1oINBsv&| z@Ysptv`*17xyQBwfAo29;wLz^ST)!E{jCnu{9s!c5N0aEJ9OC1I{PXsF_ysH2)QU9_B@^yKE{@GwW@u zumid^N$2ttSqWt_mo<(4WbtiJr<~Sxo0=bs*I~KT6V}lM1Q_%UCRey^1dDrFngPg* z_|tzqx3ao;vigJ2g4*68*3&RD{&XU-g0FxH49H&v~JYYm^ZJVe9_ag zcR&8`{1Z3-pI5IQ{+?g`^#1*)eLeCs7XsEYDa;5PEFDc_!sIH&@=IUpSkQ|p{xk}B z=WC1|4MgZT#i zyqRq)KXo&%esFES$R>lDD`q9v`m3MNIp{Nc?3}{Uk$-(%XfC!rGpwHXeRnUP-F(Nl z-Q4`S9}W12|3QPX9q?=4(|_iB>djhh^pD2m8UJ!rc~EFY`Q6XJZ5&r)Blh%37inLA zoKIdqJnsuaOp1M->4OX5_xl8A(#=VY`=H0lSN(;Nj$u!S!X8IEc}hogTzs+bs8!Rh zZ4ca`<2QaCRC<)1?7B%4L%+;-|FqlxV0>ef?cd~|*I&sQJ+e@9tm(xXUt)kgoZ(H3 zT**$bo3pmQZFqA-Qc^7;0Uq-3t&r5k1E-oy&V17L|P(?%!aP^+nu*^T< z0-TtNZNLFaI-!aL&Ru3$#pw98x=zRyZ9<;2j!%^hUR7L3?r6QyUg_pn-E9>K)b`Vv zjEv|TAZPBN$CFdV(I}D;|Gvwn(q+jZQ2@Whn?WRRSFqzEVsf04FNox&?8BlqkZ=3Q zE>=uz)V1b((_7jZaodNz9&XwMo7;OLW$b!0|3}oRG8ds@W>;<0TVtXk2aZ~OYm6&& zn3&+0ChSKY{HFsk#N07s&$(_lx1gO_)2$(+oub|i!Xqo93!RS^I4+>!xOih0jMs9+ zLTHrqm56@vdyZ)mvOerlyW8*CtTB;~Z;u{$S3R&V0c<|3H$NVa9(cDs@bbl%i;;yW zpD*tLeABuphn6v4vqQ%>mx2~?IVmomk1Q>pxGidU_ zc36FYDGoM9@1J%r8A;N^YCSUSyD^mba46_)QHr>;3IL)StFTNw!86`1d@7#&@V8~> z45mfif`~X5D+rn(R^mv8g&*@`9uoq&yw8j#Y|Q}l%Qe|e0t z<&FByO4o%zS7pMoD>w1fh`k@Jld*4q-iI42ygqSnn-9`mx41(?Z;;MCB!S##IQINV z%D|-);jUXqBwEqMVO5aQ2VJ!ah7K^b-*Tz5$m;IfZ04Vix~l#q#iktQDgH^XAmK@y zxRqJ>Sj5v?xqB|hkL-kK40(-dL9REFw#Ke^)Qq6yn{mom!6qf5w|Ytg1ihsf*{bO* zJ^QM~601Ido%wrC#D`W&ds}ZBS_Rs*e$-DPKk+qXcVC~(pSb_S|HH4`{(ay2`r%7G z1>o}t{bFEiq&iy|9&SHrFlf!-c%Khj)p{%eMx7LJRskzS;E@)yWJKTx1__U!`1S!FAGy|LT*?KN;ut z<7rnoNIBB9-jSMrR<6^Z`fIyh>rEB@PCEj3(5QFJMpmcq{@KlY&tBd9)(<|q`E!5j zZzTAm^gS0h^z`_Et#fTYEef3rbw=G8?a9&+gHN8DpUo9i$JZlQ8*6mF_}aVoJGCWj z%r|3yY?`!LOMqdqYXjkthwID~a9_MRqvWc_H__F>SaE$si;OEgYZd13 zT6Dl*u6C)xk?&O;KZ0w(eX<7B$mZJ4EEt`)b&iBi;2TIf&E!XcC|nWGF%8SeWUUw0 zfub~Lh!wq_bghG-vDFsik$F5GJ@C$Z;OU3mr)d-N*kRxKrg{YU?cd%u-}C3iW%s(< z`0i=yZDF36fxAGxm?CTz0X8X%1pU{=ZM>Soo1qmfFhtYjjhcHeaVG8xIx1>=w>jUo z`UIKCr6;@k0)X&yRtF^Ev6U7Z3hCvAjsC;t{%Ls&uL{qS#WyEW6p(0RO92`I3TSov zS9s@%Qr{;K@mW2^w18WIEA`nQl~B5ETU5+T3~Q_!BC~|rqKDpz>MV%5#&LW$>(3_N?r=fyemL4*hx>`oaXI+vxAM7slL{HG-dOMuZ z$C&{3&nxrBHSZS#T3x0$`}*EpVi4d34W`~Qu9`2L6Q-+S`wzWsGy4Or*xOU~*&p;EhV7>_Gi z14I~i<;)F?p*vuPA-1z5k9}rx?b}1M%wfbIhNSTfWHrc0qw&WI{_CA{cRmE8I`l{W z8XIjwA)KAKOCBlu&)b8BgKG^k*+-9e9CSi)WO^P)<^Ei3@R{>gp=f zedRmKu4xT*W$Rm`eOo`;Xz=qhvxar; zd-z;%(MQ;&FU?=2dF0~1*luq<0+5Wa`{qw>zSy15KmR}aSGKjjdH#X^9X)p z?Kj>$ef|IZfxkKHZ=ah7=$#8hx(Q-5N4UPYIVV4;s?tSPx|osHE^(C9w6hC)v}>~F zG*i4hS_TUsSh@2u$(sl34)alo5HsI0>QvLQ~NA~NmWtBILCKPpyluX;2 znLc{(uY!M@e?_XT8Jt#xrUZF`(a;*?*dBR(E*WBA9D#-V8YE^WH74#JxzB%0HZ%ZB zOewoivUkkAu0m8ciAxfF>KU}I^QIs=S74(9VqIe^Cy($&oRkH<14aJ~aU1VC;@o0z z>_)ss0}PY@t4$?gHoebRqlj6E0|QjrRvBX6Q#923NC z&NlNG4_ZQ*yipLE;2Uf!c)tOPvhqLPhi^i0-LVSOwR4hwii&SSekDRNiug z6zEAVWqqvyW`ZV*6z`L4jHb0!y26@R4zF$kEd^|D*m79|jmR|w38%GTve3ka`Fu%U z7|VxEa9-a?d_~?vPe(5fZZT|eS)!Ppt*I(keXN}{TJpnn{9q|osRr0bMw3`Jw_E4= z4!X(IH&WD%@L`N%Ya@66g0mhGQ#{as6h-p0sQkKJiQ|iE`nxQLPe%r5Yu-RGCU%B6 zk)Kl*1ry7^6i*XM7){QgJ%LZAk-s5W%+b4$3HsO_3$lVX!+y##6XJjB48dWFH7_3lvi%${iRJk z`%N6@GT#BQ`l%!2>d}Kku`j83i(dgb+OoGD2MBBig0m|4NYQ_;wAc0@|9IIHO$_z{ z!*?m~G_roVKQQqACaNp;o(sujVU-BDz%>L%)Ctl7$x3|w5NQJQQ4;;bFZ%5wS zE-9q5D|754-dqug#xUp|+-;zC7{oUG>gSIPw{cqg6Ym97FaGXCO8cEYk6)s^x5Gb#I@o5hh~f_cI`VS+9;j%>+tq6G|C>SAB%PZ_ znH-a5;n3^CpHxcWIA*Nl63l~S8>T|Vtl|#B0+s$q7SocN4*1w#e9$Hd6PTN#;*?~dO7z1REd^fD>#&Je zQ%qgVz;(W47FG>+ImoTu$Fj!IDGpMxP(|-v=l^mMX7LWhtCNv2q7!%ri~20CW9;a$ z{OBv=fRv8E%=-AEf8|~M@u&y~%{=L460ylHz6<`uhY#Ioj@-(oGdB#+G(WyQdf=V* zz>DtU-JJ`a?=;RwaE~6C2MR4)7i=;v)K~_HT+Pnjn|e3t3rPZ&fU8^_8vphU;)8fv z)D&jT?5B1CDQc0(L&^&X0(AUsShqN&iLjP0sWRuEb#_Y;8^u(eMF6_UMD5oeRS=|l zrPtlk6SdI79MTi+D^7xFE*5r5ioe-+q)*B=tre;p7^SiHM~&1HEdiwxNXeFz6lMUm z$LhhI)KW}!gEyR!WS|ufAGu>SnJO%!=DgDkQ+xEMdvLjK#XzQMgyYX!cBpJrMd#d6 zHtSFOB&+JZMQY)(`^2UCi*3uV7|1xgzi7JT5=P!wu#cZWX18p!M}cSj%KML}HS&5) z3H`>U{LVIU{4-G$uB*5r5=XD(XTD@)89HaI^58$$J17XljFvbzGpw&2k*n5aj;%Th zP~IxjWYyPQN_2IIsqU3Y^dFYA%4!ElW?waA$6`IsKEjzr`f*|e)$<{68N$DS_O`m& z1n)4bnvvurtUPR`lFIF4wPC<4++N5rpwn2UBIRn3FQIFudv%>H?6~6$%*yAe`SnURHY{Ff z9nzY|+56;|njcr9{A~UyJN2-fM!oVmlSm`kYnn^h&#k-aO2!~uC7g22q~w$ynMc#l zE#n{T=;p%C=#EuToHt=35>*VOM@+A3wMETn9eohwIa} zkf;kD+v15t8d7*RH&y0*?7Pqs<#jpmxbkM=IO)y_koIyI&kp#MUc1tvsqRe3tNoPd zamj1H$^LX@<*C_J^W@d-v(Mk({JvlI`r%Lh<)3X}|LDW(yJwp>*%f;u<}!3=OX3=n zHhiUA?@e$D45%e-n!3zb)-+=;YM!M1Gr#+H-+u1lIjiZbw63$6s7u==^G1sJ)rgos7Oa8F92Z{arY-SRQ9$FYb~cDRmNj-?{7TCEBp-sKuk4O)=GnHW{RKi(v3hpQvs! z@_e)&n4xX$wkBP(ab91+KjW*7CHs0CkAgCTQHtBjuWr!A%G!G|Y8U>?>T0L;rZon! zRe#N*_Su!DojaR;#U@q`XWn1iL7}u$My+g_@R9*u^(D1Oh0*%m%QPQsCWsE$6niVV zesjuKEjfe8$pP%#v2$q`gg3{GIns3+B)N!_$X_$)x<@DcOVN3ir{u^ufVNmLS{{!_ z54`gpxPATnb@PdbaUPFH5Bz-afM0#vdd9QtChnr;Zg8|05mpO`&D>JL$3m>gU$9v$ z5D)6@2)0&g@;+;b;*OczNKhmQ7oNm{5$q-kW^z=O6343+<~mnd0H?R#&YQO2^|3KBXuDZ^G;7?U;6tSJWVV!FUD&Pd8DFbbNDFyk)Fk^dZ2JHWlJY9o z4Jgm6=t@K{zJsy$*UXL-o>mJ%x7f&E&34)(XfwPyVVez~KFN)o!pH97z){-syHpVC z4pA(cBCm%hdoO~IKe1DUI;9$)HG8Lzb=Eus$E#4B#qI8P1*W3e%8|NlK=xA*oN_8T zSy}@|9GI+x&{~HD)vI$>3>3{^&oJ#;ft6%re1;bi;1Ofx!>(vd=bRVlpniP+nVXxx z`p-Q4sUNtz|E>SoC%0et+{X{MAN4r^4+*dfXcE@w0Zemh*N!_=wT2CsP&NR+Q&ZtL_x3=N9>so@2_(UATt_Jvx-vw3Md0mLDg7 zx?b&7-=RCT`o+&ZbY91iaq!_h*gj2rI8lFE##_3#mhmHb$Div#l^jbC)z#{w?`kt` z2I*BkT9ik}!GxB7ul8+FSLV24IsQj;4l>y9d@vpT(x>i7qwnxUTW0wRjFIEbi@;9w z`Yr(UY(3J;^*{Wb@$a=!bR-;%sM&FCYvX?!wz8Sh@#Z?kQ+}m+h7H9Pv zc_pGVT#dKRrdbzCUFB=i)alwg z_B)y^%F(?b83;mm`AlP|i~b<*3~cqK25NQD%&nzKwkR;#5LT zZbn3?hlHcjGhNjheaF+}`+1DV|LTdq2?>ZEo$up-t;O zO4)R8;(rraN{S>V@*-Bbrsz(Q7nl!K1&beko}ImgtRijHLe{a3zD>=x6yo&?`ec_O*MHi6=8%t-*Ys)EeQyrb+M|LT>4~{7eRNMRGIQs=ay@4e<4ZWg@5*|NTRarV5);}wx~Rh45W+J`HT6{)f({c4R-bN zE(f^$(E&Shzmf5`=5?p^vGM}bY0WttJoOgWk$Tmk zX?$k>a2!llrs}U{pPB-rbR3LFhV;oFn@{?zZjT?(sk>sh%4RW?m!bQnp1vy&BuDPS zME7ZfV;?kEJW_b+CSlGfedM2u7Q=e27(JfTK4qtO*rPd^k0fPR{OFbs_B`|LjulUlz#s^$HXP3H*XwX+<*3Z>CC%r-elk9~F32dfEA^k^@3_w?EQ!?*P# zfIs;%q96Z{3s7CGZ*IMOsQ<_@nXocdW{o5ggkxCtTu zfM&*}o{r1F*S9x9jYuvkSXH#fh`o+f8CB7v<&f0}o`DQ#An|eOXyuy({DxxpQfX}e zuki7B^uRmsfmfet1U`Mz4>Ug>j~;l(J@9M(;y?C@I|hrzb{e~B@JTlvyS4qEouawB zkk=LCP0-EGf{JU{aTRZxpLc~_c&tEp*ha5hMQBA|E2}M-yf2_4_acAcB}rrz5p4L6 z9N}OwMHD>IMIe?D2!%T_#Gi#gp{BTIV=ds&zNI0ku z6rVMfBb&%%z2#Vh7IMiG%#Tayait>;twV50zPRe<*YyboStsZuj7bRHE;6VhaHdo? zRl}Xg)mji*FR84p0H6RrzdN&DyPuVgrkuR>38Wg##-E(E?PU?Af$2}kO7OuviRa6f z5{*BRm8b74<%TI9KvbV^RP_B5gE>q+SrMjP5a=9P!pUZp+-cWiL-LOgI@2?4(Qb4d zNZC-;zI*Z9nVjz1r}yvOe$<0IFaO_v@D^2n)Y#LSD9r&ENZdrXeqOpSA z7xI;MDBJP3$}2zfCh5>!X)mtSF5|zfJ(rGcWW?b=4atggn8xA*Z)H08m3E2o9J|Pu zI_D!n`dY5`O`ecXf}Q zG3$@VIlklL`SY8HXSXkJe&?5NZvW)>_Raq{eQ@VRfa~c-r{6*r9%ou6pT{J(FI}*sK~26w(9fT%EZAJbvPeYuj*e@#?N0 z@_5?h@FZJ5_xzVU{K}gbxBvQI{m}x$hYvST^Fg*?Uxxu>b56P8sKQ}BkvA8tEo>I+ zL^C6MWFj4la<(|HI6ys6q|)>~oSk!|X^OA+T7RJ5(Y$!|19dvV3FUbt(XB3!bkKDs zgxww3y)vEV;ENE*+|tB>ezpyYOd*HxTG8AXY2ttmk0=E0)xQHe{1@}b$JLt>lg}6I zrGu19a6H}ovcaVS3<&v>i(KSA(Lq^7`Dx9Yp_9)9{^Y+IFz+Y-R^k0 zuip}@1Vr<^8GW%!@M+6_Cgx`P%dOzc2Lu)~HoF%tW`k zNS{&{G%kVfRC;uQBAbICi&xS18F)5d)-7|F zYlPz$WkT`pHjg_!JXKn}-2RJ$jyr+|?y=Kij97shWL&&dJC)*$(32HA9RqM&T)Gl| z^;&ICoV!>&O9s{A#)9UqJuU#jKt8{Fw_Lnf!5SXoDt9}$oU|kJocBaTrSSJ3VJLb( z%J~oGv85HaSBXo;cMNTD#?dW=UfXP6j5-UiHPpnB4aQjcIbQeCq2enGnB7;0V>)X8UEWxC&P6aK0HZGZ~k&VuIevy~xxOC_AAD+t?oPMiaI@gU4`QGVs92-J9%!mJc^!eByNuM-A zT!$TgT)Ncp#lTe0FEgwY{TJ~&Ljc1;e(aFD;xAeKsXabFG{-#SN$uN1?)n0JvU*2< zEx&S*XM>35*!6R1_~F=h%F&%JeDw@wrOs)Pyd1LE_B=8!Sd=5b(9JjHJYUUk4t3I} z9S$ElxLI-;Mw4$sn@3}cCx0B8tN!)1N`0Qn2Bm@bO~35<&9D9J>zjY|4}EXc6~F%t z-D@xDNtnhocnIxFOvnK^l{)Tx2SkHXajzP%c5nsE!00`;eVpJ+-}dmW@7>(~)sMgO z@RhbuuU>zWXir}y1il^H*c{9hPdNaNXbgU3im(}~GJQ`?r<9kzVA|QHXYt{v!G8qh zH2blA)XeMhzg(Yw(vuLcu8(9T_Y$bPf6THo;Iy#ZU1kMpmb42?^4pqCVArZ|eseAV=8O)%gQ610oRM8^l8x!F{+ zSt_^i)r_cX=uhG(;WNAxqoF#7I?I!btyQ{+4A1dnRPz1R-^Luf9-cd3C-?MULQ9GX zkw{EtiAYc1Q56!8;WaS_-Z8Th_&T@X$I1nJ=MA}aC5Y9mcaFA;)w_O8$yqn?N>pXW z$Q496_7{-3iyznw)&cjXc8kErN+>m2n zV==$D$8`IL|Lk@4xoUnqKYHMu^S~3o0#Khflpc>q54`gp*ks-G>m!XmYPDbKGpSqF z?5?3HSR83S7VS;k!bTIh_*Z1v#eK0t2ov;+0M(V)4`LuCVs~;E#m=N2v(UJWS#o^- zAhFAmQB8}Q$v;jkbQA>r2EC2iVXHn!TN5s5U+y`??|OP0H@eHYetx8MJ}ZRbGfca9 z;Zd_F1ww)EJRsj!Ju``woMdH}j`T7U!YX&0ptu?gDV6KFB2eaajRd9UtN8|1oS zNK8t2bL%7v2)Np4PksjM!x(c;n%v!CUve{T-U({R2hR%U7^|uk9^LMqU>{@MK)V4` zYnO_3&Qewzxn9d&Qrudc7Ba}~SR3*Qo%&U>mD_y4K_4EDn^}`D&A#fR!q7H`5B&5b zZZ-H?&#z$D6N2P&ngt{&8j~-=L~j}bpdUW_`Uqa!-TlDtd-!AD`{Bdi@vCm{zwpJL zfqC^J5ZioQKxQo`adjw{oRd5?z3o^KQ92 zx(Z!vQ6+z&o`1$r#aj!H{;N;b>$BwZ83D(-i|@*BG^n3!GeXu^4huhMyDMVN(nrY; zEt&zwOCz>Y+6N{Dgl=X$yRP^rRn?_3+X#y4_09Xw8(UBA@4xGJKe_o|{gwLgfAmk( zCmRiSU)$@+y$h1f@OBszKwDij4{wt)I8~WzgWqdS>SH}}=Dw7giC+C|_nq$_zV7$` z)3=}N$*sTqlRvrY=TVl;+ki&QcieHAk&h2~rFwmzd9&P;U!~8to@f8?LAM0^N*|xt zYvEJofXd#}B-YVnR&VXs&&+itzwP-KZhrXH)BC^nFa7sW-`>9eUK|=Fwu#5SUpQph zI+6J-bi`6dQ6w2tb1~GF#|CZ`XR>ioapsmsB5o7FC`X~zUr>ZygBzL zng%)V#7z&(!%)FYJ=mj(LuIL;hOP6#F*z_$9s0e`w)!ZpwO7^JNf)G1`QEfih>o#G z1OY85YSV`&?4NTIGQ|n5USR+a*kdQTb7Fqgi+O;LaW$7-pwk_*8rR{}`5sR_OW#n* zN7>#Cf0%2(oIFu+(}IdnFO0{S)@|=C8V-G{i01w_Og7hoz%|m6aQTg7UtrdDZ*c~Jpj<58eTIdhem-5GsiFrR z@ChkJqs){Lj!Ir1v^(~+0wk%IKW}dJXD*fip5;v_vS-n$;FNUYqC3{MXes{U7*hyh zv@0$wx96&I(#EmCzgS6#l{{G%K(mX8Hr&Ncp+Ak#E}T{od21+GlDAVfgcOzv*i6>W zC{NQZ6&%jkDr5JPY|nYh>X(GWl+c8++Y${y?yl*>f&W+-ps;mhG7%lUTWeWxgxgQm zS5snANl8vc$0Ur(MwGe}@3(-WERSqc?m__njEsbfA{sA9l22s-*Ox{VIdB$GHF*5B zgZ2;qd~`g{;N!!H&mK7WKEIeEULb}y*{RES$_CdoN>^)N6hT<2cb+8y4*cxSkLim1 zj4XoZf=&R(yYIS8vh)-?^&}`Hb%4s#)q2=xn0<-k-Dkd>MX&Dv;_v(YH}5@rxc{~P zK({mNXSeThK6_HUJ%LQe!Pp}NrI}BpADB+fwAhS!tV0GQbm`PlHYez3j8msv!b$c# znh~Gecq;5bd)&3+N;vND)V$T5%@dsTD?>^5HizTj?n>jcgq*%n9gK96GMiQ|cd*4# z$vODaRHuw@$#LY$QC;-2u+l64kzmj&J)XU$56#j4*1lnlau_(R!~3}dej4LSbMe^x zv|E$2e=PA|c@NTK$)|kAg2{&?o3n2IkDdRn{*@x>_~-C%-xa?ywfOWG2j<&`S|bD;!pDaFEY+JpIjYGJ^vDSF8-w7CSfI5lX`#itdFah(7*hPZtuS3gWH?` z?H~NV*HAy*WBPp-K&LihGu5ZJuH8qW;Z?s_*_zB;8w8VNVbfXUI(RCok|MigQUb!@#IrFUE;~)|6|CtS@&7MHGkU(vjfSvnuPqK7qE{aLksvTdpgvYPO>viMemr zUH~KRI;LdfHXb4iw6mE78YzX-wX>^0G0gAe%lSTxH8BUQVQW5vfxtCHF*zu?ZPHfP zIO|%8LG}9HZ(=?kj~;mUJ#hcz+S>@7O#zG9VRN~uJqewXP>|UMWC2_}Ncuul zBE_-p_ngtQirq4$`9zeE37mG&!tfRshn>P=2o(j>;hx}dv7?oBQpLA(45JlBT&X=H z))F89_8x5Y9}i=MxfH1H9#ICwxi9UZmIqjSmM*Ghz$?@i3}wa4%Y8E#U81nC*g-cS zHACm5L`Z_oK>zTuDCy-}xLMIzXeOf&Uj6(U%4j;g2eDf6I@NgY?$6QocIaZY_OU3) z6Hf8*g*)W%t$6E`RcOFe1|kTd?D@lB!#P}On*__J5+tQz5PNfFr_#)%!O{}|sCAW+ z{i~TVgLgoMe>$t%Eh?1Sa-JF&n1``)%jn4=FL%(Nv$xzBz}#rTeZ)j^e6glweC75M zM7EHN8=h#6XGthWC<~9`)yL{$$b1p|1$1ZrM_$YLZjITW)C`Z@_8nZXc~SkL$6}E* zPbDGS^LYhbme2=wf2}7(pxp{ZSboFlf3Wy)@3iGALxp^oCR)`43pdP~8R*JbegVC=>B@~cOK5j%%o1kembm^@u*%c0Q*b0+yE3UoS@!Q2s@62IXex z@^A4<(R~o{56W_DR9KEQGU(@gX~(gxHbGwAz@~g0CYNzvB#g-(V)d6cRCqMwIhwWc zk#Vf0rN42YAy3I{UCxX`iecGz@;$qM`taht+uPsu8=EnH`t{9^{G0!v4Nmj%*C(cL zz$uz29(3Pziugq$?~*7Sbd+rW zSX$Q*Y8nc|D}Geo1cT`Kv!uf$)5-i_49flkH$Gt*&PKN;^9OtWkHtOu{S%6;N=oUWCpOvJ)P0+_Az)2Na%*vc#^8ORkAJ@)?wxz z@P>DuDHGoU)usO+Wa`ieGy_=HmY1Fi5}wXUx}vYBvTQ>EV~Ys0bpGv=`p0X>6QR6- zc{2ns#@5@6BtPRS74)0oH)1Q(CHAkLx73zS=W7>RN+zUN|MfR9xi5~$XY&NI%B>8D zt+~$ovcs0dq4RkHiz9v3)uZ)~h|PSv-dDMWMJQD<9hVt@#ukf+XH{`XTHf~ zZ=Nl_ZKGCMkT$AQYrq9d!u)CU2XUCC;Vc zafsal0M%P4rM{$q+ZI3G)Qa+3*&q`-k351KUPain7(xy$B&?mme?Z(4mNr7<% zg+c?fNV?@xp-b+(l(+(M?(k<7BU*+`vmjS^6hXlPh9)Yu+RsMeaZ>Y9u{@5mauUH@ zw8#ewdett>FT5q8?EE)pd4R74>Xrry7^07H>oyXNDf!Tc3NE8I&*AdWv0Ujjr8L+|jm zAhDiN(+v;nlu|kes_44fFz99RuP3B@Yw#dDKdFVopTxw*r~NbGq9>eFDGS7UK^_3% zD(%9r9#Q6#JP^lJ61KOlHWzbw6$5sJ;!0-XD@^;rKdvpdS2vwJ+i`K~u2N}M9PG|* zBpU+#pGMIG^t2W~y1vyIYuBqN;!MnOX}Pq=pxx^TPro+Ko0}i|qyLGUzw}ejZ+^$` ze6M?iukN0|xOA*2=QNlMjpMADGiOhJZ$5XRAMnpv24a*BNM|i~ngA3|P&0yPs_X9! zeOlN7m;rRg5BhUx9LM3m@|}7#uGFBwe@gRRsbN1#E;Uy<7u75EhEg9Ns+B_XMt}8J z>?m{vb?iSn_I|ojWoV&9^S1uj2f->X5~Gdp;H?VZTI_h3_i9VeiQeVAH{jWUTWSs zwyob(cBGJTQ0O;k=~Zi2erQ5-B^D$4`m-$l(l34e@OS^B+uMKkpLX~EhwFu3hA*Cm zqv7FA*RzSRnNJZhotw90PNNG#h7>27)7yt+d-Sn2`&QcT4F1~RcK>(2fAjFKfAmwX zDa1}_Bh1Fe#a$-2jUM&V>$Bz!LRBW%M0^0S`5dzue+RpjJ1?Cq^&pt2#-E1CsL?CZ z_4_WkC!fE2{_qbx`TE=Me|C5GH~tU*Tr6Epzw?-cpF-R=KB1uP6-7-GMxIJk~D!k{RE>MfuCSZMCp!f+1LCo zLy>?p)SNz1&F?Tbu7;??IrcRLiYiyXWXOpWnvL(VzVb=pBs%)J7l z9`<-Vdf?}Q2QFU%`11hiBg9|u9{4*y_z^RGN3*p@$t)C_o1geDbGhAW-V&o&&@{X` z*RJ4iw1^)Q#)Ra+q!E&gxjY)CcOb0=^}%>79_Wc>jftoK0ZGLKsb5J=p6g=M z&-tB|y6U=xR-+fM*F{5Ul2`d2|0NqJXiq0sVg%KpF_CeUoDB&OhsPZt&Pq;6s!m5` zP`yO|v0x=QqRO5HwL6&)FK!>6eW|ASnfpKS?f=}(ul~Zzo4^0JKEL_oMa}aRR!1`2 z)muZCmgMT|U3v|<9fUp@RVQXF!Eus-Tb)5aC(RKT`l>TC9#$TQBqAfSw!bS3RAle$ zR33%ysQ75uSY8cjc-5gZC>_`IDPPe_1N?@fy8R!7WX_7o!!fs4dX=vht&RFgIbY?i zY%P1GzanvV%Wb{qLQf7`pg$I#A$D|-bq*NFQ|^q1FI>`;Kh_^KByFBc;Xy_76-CL? z+Ukn$Xg>8vJ|rhUS(ARKuafK(l)Py(4c&2-sgJ{_PrCEHpS(EaNj_-bwk0Wt|Ek|M zt6x4b0ya6FXFJ-@_O+JJUf%qJzy8JTUw`%b;RpZt?@CRZ!e?Jor)q}P=XE~~fHerZ zo4J)wDi(vLs~KQG?2Dp1JIO{*-L7e&`_*6lS*P1y`ak>Q{gmd%_kZn2f4nc%d(w}N zebNx=F_SYlUV6J8oD`Cs+2%+`Y2vHtq+QlFozbxyjbZG}jk)X45Rh%dFYFmMi#sCE zHi;hdfBNDJx4*p~MEoCr_y4VZ-ADINo`3P-;iIqCat*S?Y3ju8Q3vx?LG5U}JcZ+; z1|L&RcO;uzfD1_iN+k=d^S*}5OEse=VgmC-zsn?=gw8-r; zTu_z3yJ?$%0kCcu8i=%D?WkQ4s7d0;?NRq>MD&5w)@s`Ey!yvPBfhSCpQH0pybw-1diZ90V;8>0rMCP_l;lWtF@ zH)iuVU#42)d9OSI9sBrq4xnzAQnhfhExPbv#>e?PR+w9Km&BXP6ipmO4h06v!8p&& zvWl4DFI+AK4cqGal*qw136F1&9(b2Mu%7{JLe$eAk4F!@+aCD(Z!gp|%l7iY!=k5| zxa~px*PPv4ZXPdRwor4TjcWn!ad$M;$15&v^8_S586-8ygW{|jv%MQ# z<%VP)R~K*b_(RJw~A*@2O`c;fl@^>edT2TmIiFcS0DLmlBZnv(DseMHx~A z-@0%@*%Xg`pnY3%c)wFEq)GBVkAP&+kO)W;v*kvNd4wAEO0`mDV4hWXRTXRYixJ#W z@|eL;p*_1o#xJl(I|F&zip9#K^A9fIlf%B5{}|Em~fsPadFHDH>d;BLASDUDKCAgDO*}z4@}B-GDpMEP-3KA z(2zV_NTyZIprv+ltTrn!e%3;r>r%Pz;rZvfnfSSN`@(a`6` z>1QLQY1AJCt1Exbr;blZQ+5t`PY&;)J^D@_y<>E`bu=`dh3M5*1#5(6jjIi(?$jKe zFdTUkkW7Cpr}w0KW**cZifW@F<-)6JOx% zdKr+oV}HaZ#6xpH901Mbn~on|{%GHA_nDi2^q0SX^ZoZf`{ZZtzuNTm$);4}YM<;% zK;-;;zAuB?je|i>ESnk*HaEtT3pQ)pY}-_+yn0~>FF@)$odbVH3kC;l#U4L@+NXu? z9zOr<+0A!+_FMY~gy&EG?LYW?-1EP?dGUp=wfA_KC&V@(#GG69?K~2BB#BBA8Hi2Y zXipPlS!_M#vk52F+z;DvPv;8spkVW9_T{n~-$ZN*SzqodSOj-?`+z^!wYT!4$Zb}fW)M)A8jsJ}GV{fFF zD?h_}y2b){;`2!?c+M?zrj?8(2h&rox?VFJo~pTKc6XLOQ8LcDAAdE;2eOP=fa7uy zH_n>*md?kGaz*wz66w}V*a$J}vEndQ-0hTCjKqMS(<>Zve zyZJZayROBy>RnWc^Geq_6=xw(qfA~L{umU%_3gAnXE08|gq76!Xlo!0(CHtG!OT&I zk<$_#D|1iTPHZDPLgjk`k_~S!pe3xVFW(an5sLA*`sNg2&q1;7tE6V&T~aRie2v9@}ZTZ(Isqa&m=DD zTPl^FqeNPHiw8=b!R*~ywM8=#qy=j}DP`dcbyU`oRaUoU$TaTrsoG*n#?Q9nmVC(P zjBG97ix$9)a(LG;`KM-(uPWrJMPtt{$qMw4!n~{nMI||}Gx8Z8ep%!_2zC+;Yywv zQwDiR*8gUlJXbJ8iW54#Rj|!XJbefV`?Yw@w%&gs4Lh#toliQ=Qhd}rq1JJ&Q+tuO z@>2)imEy{B&8r%$a~gB`{Iu>J-#Nb#SLHQY-D(u zD!DkFGA4wa^ltns{?X)!#hx7b^zSsKSrIJiG?2)Vz#*f~=b9I39-4uPZht(N z=wYJY@Ee~z{2M=ebMrU<*zX8cMtnhj%yM{H^!@xtBM$|MJTZd-mY@!$&WkKfG-B*`EE3 zbw*6^1v9&R8bHvy_TjLfqchrVUeO0?F(}%yJ4|6Re&5wE&S*?DzT0edPntHL3B390 z;pSUD`Qpvrd-MLy|M3TZxN-iY4fZegG|y+l-Ujui-zwPG$ag9F(h;dN7rns|)tUbR zsNAt4G^Q2Z`M@Z15+M(I5{-qmz~pC-{)-qB!rJE8EmlA$!*@ z(xl99g4Ly?W16n!bM>=44{RY5j{3O^63W#ZqDhU zIFHsNg!N9>JTiMS5>q1kHA@CR@ie9Rx(fzRUN>{o8(W_rfX@+C3Y+^i0T4bi&x+Ni zZx+C|MbeC>7`>mN~Bj0z}#BVYn^WdMQd(aZLsFDFp#Gv%9-}f>uqF8r<%CN{+ z>ks3re*TR_qwzCY>jeDM3plcU3@zd(uV2>H`)cJQ@9Et~9Qk-Wdf?snz{{JDo?6=( z6_3ZG2i|QD{O13*?OIDhA!adaleY<7OeoZwvfB=s^w+7Ix@{plPK3`)Uv zm5Zq=EOo_Mn#bcY&O+aUkl-r3oeSKmEux6+zu*&HYe#}Bz%JG-dWlROb3+_36ud!H z?P><)IjFD?Te)5ikNE8|zaGgGLsPly98p!!KjNV{JfP3f!ee4zE%K#_K;S~n6+2SC zr|KqwN21d}d2WGuU^ovBKuh?LBU#`Q(!v5mlDEcD(MEIPQJjTGpwhcm>?)A<*+Rn| zxy;~GvUcli_F0&e7vq;0gq)L#s_3_0khwHpR7vFKnrblBS2m#D-5|m{`xB5-Pasai zMV9WTD<1!3N4Soas4oAj3|VscWxpap${NXP3Wo~9QfXrxGrW`&$S(bU-Q()2s^*6% zwlB!GQP-E0>rq+HxtVBX)RD|xM3`(6IY?<7yP}a^iYwE{+~ITR1p|Pz1L!sWY5S93J|B?6Zzwq$v;qFC0E&0@kb^06tIb6eId&vd%0+0R6>koUx zJ4MP)>nWo>-cJ0A*O_KmrRSd#&N{1(IFj;gW0HY6+6g}@v;ji2IQ;qYKf})9Qb-z(@~<;i-VVt7#QWZ7jnS!*G*{(V=lSRV;UD$_VDi%Cc}Wu0CYn zC9P@)UCJ(4=dxj#V!HBIGzJ-)*>HGF4F|h6A?~`lmp)$RSGg68W*xDtJ=o}KC|^v< z)^)%Vb+qBrhU*Ivp1yam666c*KHYwRV?ux0A=^u@J?ZaIN00HV+yr1i92_^#+V?(r z`t;$U#NErMx8M3(UfuudFW%jJ_iy{3C;6wFgMV?*Tu3d8k1b6=CW#G##GP(e)9Idj z3cZWC*|XuYku;$9H&1))zcF%q_ukFJ$3Fwq&DZ^rKX!Nj{O0E0`>7v%c-3aw1v__J zJ#9eGaO$L&w%WMdrat!hJ~{wK7@dxM#)Wbfr(PRwbd!0ZX99olk*g8`_Pw~dy=HoS z^X&fF%{P7N7v22rH=nurpZveBl?d&1Zmu5`>61h&Aj{7hPhfxEjUbcb#CG zYd;+G8MkZz9#*7tG2AU(jh6fSI^YTkjMF!8LXrS-`Dquo%Tb>>{%ePe!zx3`Vw+x} zRc^J^&lh~-!(XU$u`~KMkB+2*s+#xgCl3p`{J>KFo6HK^gbJ{bx?P>M&2sG3HkFz` zcmROx@ToMu9=X`T=<%N}eHludx^cwG<~=P3Evbg&kymCzBxzBYjyLA}q;F%gw&IO&6^)mD& zCC0Vo<_ryb85c1c=ydnS6qg5s*$s(aF;jLtN6_Q(=z(|H1JC;=+osMZm(lPpLw&^d z3*G~N_MhDnr8&0v&|PPbsx>3~;=W?O;9gw1z3Z-PGy10TYNIiK3l!TxR(aWlSp;CZ zY?j0&^kQ*GNpgga=6nk-A!VkG4b*l(?-Jyi|QPu zJRFyr0~F<2;VLr!B_Dfhh7;@O&E%&rlikm_z_QTD%r*|zFmvIW1cCrncUlhMB-V%n zacM101vSot$|A&K2fsE5n%4E$z|n|daw3$zv|dG2Y|-MIr>WKu_VnfK=x6nOfek_D zXj16U1tO)KA$@2vm4WIikA<4NVgs)+@bFc+RYczMadpv`$*lF<ypp-{3V%lHz0 z{;iVNx7K}=H^`OHyiqjg1)@%d6&pW?um51-iL2It6<2TdHg{ZfmM5RdxGx9s`)>Z> z@A$r(Z~yXF?|$`f{P^Z`@AdWSFP`rgJa+Z$ba1P_3Cyo+&iMp#?UwRhDX5Ncj#yK! zZZ?f?oD7bh(N@o-awX+( zxDGrW<^Pt>uo%3W#bmYg=FqOj2 zn>t!FhiRTJQ=JLrY`R=&x_B#JAeGx|c$0IfZW^sz=ISkSj;_N=m+7|mgc>HpaN*ZP z=s8V$>5AI#0&cRVIr>BA24&LF*5KOQVe>nlc+D!Crc?B$9g>e`y{WPI;?!B=fBsAN z5C71&zkmC`{_`K-zW(!naDr~1ceh*DY@kd;vrO)}xUTu5Ht5!Uio+X})In4>#~2D+ z&?z!=b32NFwlTc@_^;hOJpG!R@A*A{*UkU_+&pvqgL*3>7 z=l@Ut{>|-I|D$~m!b4vO_)6?yhKn2*@w-v4DKhP7fyK}HYMSxJHvKdItnVTu_H*xFMiM$jf zrq(=P9EiIqExO5`>me_9SOagrsC|b=MTre700L@wRG5f-l|{3){38A!kd7v~Vc?O1 z+#9|wOX1daf)hc4^eeGHxq4&V)kStebwu$je~ganXo<*E9}ad%?rZ)L(M$4HTq&H~ zn@?&(Ph-->*;+Gc(NoE3RyyUHOS@Q;AN0a%41}rD;z`agsp!eX@*M^W{?mw{Mka_< z7ZS#;|L8;HGaN`SRl3s3Kw{)+ox!=R!h*_1;Lz{w$_SFqhOZiy>o$4S)ueTrV`dje z?ruCp?e0KW;==TJJbK`r_rU$Dk9~^FD0n;`J@Afu;JdzO`&R*}%l}U;Fg9to;OUpf z#RD_Au+_0yc;9s<3@JF&1@3NP2$zc|oAW)mT*9Z>P!=A2zV6(N56pGRUlmOoQ!taSz+Y2Q8=(h7)5KY2%4g;2SqNpCmL@e$p=t90oSY@1hNf*$H zHx^FCo-KhxR=RN~=i_&XeuOE5{gq6kW~q@}yc79%XQ_?VzQ8t-|F=(U#C#bt#y zxV>98XekXzyzbvL8mmP~@CR*}I#|L66?Ych_t6j(T{Omcq#RFOE2Y&96-58N(D%X@oRcEhW*=d^bU7AZe!+Rz8@=s^JIG&MQJxS56L27V=SV_c< zd90VCP2e@zS-F#TZJ;FP zjbApLdpk2^73}DBWq{AK_*$KR)k&5FgZ6D_&G01eMv(~fXVg36xtZ_I9kv}mw~X-pZ(+vk(derB}q@50y9+t**|R|P`! z9e?6qd-(GEcMpI1!ymc%uzlBEk2`Ho-CK&M^e`r5UG+>o7wm~(qdKoBcY)TK^w?iF zbF&xn0l=A{u)FlxL?n2)`(J&NaNpeKS680pwkI#|Zh!lifBD0I*ad(;^V@$@0=pFa z-ZynIz8FhpTJ|*nB{o1dq4`!^x>Exxory=kC#q%YbK6H&*BsA@Yog1H$a4!vNZWlM z+3x1odo)7_8CCbH&oDO9O1<^IiRlb_xAZrym*t*y=9S5h9KjMie&@Rq7KzSK2)GSl z%|{VE(3U>_o{N`EgxI@iy)hH2{2b7sTZ0^!VDxte0+*dh^9+rY2P=~5ecEckC@7gu z5V#iLb;h{Y7Cbe_LdR9)_SY~gNR!9eW0VMMFM2fA`o?TJ7qc5+G`CEkH55;ap;1Tc za?<>kSp7w3P53N^lDq2T`<5QhJ+ftP0DKaQva=aHGOCA5J;FJv8f`M?w4@SyFGG$w z0f;l+UB!u+LDMtX=EB7FPo{{QtPPTpGCDubwh7sMqCUcn(`Mw1(4YVw?#eG96E(CmB$RdcrvJeGk50&=EV{M{uPQYa)I2Te7tX;WU;@CR4bI0KiUQ#Vf>dU*L!oT2Em|Xpn z>n-1=J=HMuE0lF=ymY%6GD}G=%d-|oS=Q&60e9qnqOF<8uikf1G%$2l^+0ea7xg~% zF-vHSK)!0>yW|={G4lGz5-+~7$|v{#@pu24+wc7ouO9xu@BQ%ROJ9C@|NPa9yXVh6 z2Yzs@Np!T+Rj4MrSJHGgpVi=$)el;E`d5E!-lIa%%(i&p^BIziyl}*y9CBwR{t^~^ z{i<1X9CaX--GP^Cj+@-m#RogfD)P}`^*C&(c?!=D8u6;;yFI4uBd5W zc*A+&yYPcG9O~u@=LyDhA-;e=vXfu2{&U5D^+NvKm>!#Zwvk4oOI~<3jA!rpU^Gjv zc13*wrCr|m2Lz6sgv|lf`O`+%;=2tl$_KsT)BkYk-t->(SCz#1CqI#Vj^n5CSIBo4 zIGt0}9`fKiNLC-e_FF>_Om$7B_4&Hb{l{PFqxiq__WnD+?PqVk`ycrWH+P@>pPL8X z?;3Pl;d=jmH_g;=1<++VKM1~kL;$aDQ(f8x=nTxEz4NR2AOjkZrF;nc)sIE+!QFr7 zJO7^BKll2hhadj%!-v~X0=i9&i6yMsY<;QB?5XTL8IUl8_2TMuRG$y{c!PMVL!FHk zYX3ts*Bssoj(j@mmC0(r8X8Y?N=^Rot)KhahyTL;2Y3I%KmUCN-5+aweDUG#X&3Ig z%geE__5eZYwt;oK3pj_T-lN(Kp#iOXMA)gu+7 znhjN@Vk2oozVeEypUOcO&t5DBQ|#1rk7k3FO-z5CPat0iu6TQmp+7SDPl**;x_X44}E1z1A(`4 zK1*Dl2vG-fPg|4g;H_**yVnrqxU8yGh`4eUX2{UatK?seN{xk(Uo>)vdlS$b zUfKO1a+(qylA}_E(fTPv`4!ePmX^x&cxEU|%6Oh9Lrt7>DNZ z?a>48ya$@+E@Szfhx!Qc7qkby>)VSX&DzcNK91M?+l*@(K67rf@uJ7>+?xJP(NSz_ zmN?;WA*Y#IsA)bgT(lfJbN1qtrBgAb67fNh5Xx}3@DYDRw5`5XZRiMSNnAWjk#g~n z?(OCm?VuCjiVsN6E^0yAGNVJih;sB8gxHm~hG9n|4Nc|E=$BQ_gF2&v1P>;gBRwu`=#i$>`Vhy)J(QkN%3qcWfzfFuEV}N zL9jp)Pq?z~;+%N|Pwdt4fi9Q>k29m3A+OH|FUHFF4lW|^oIzu6>Pk*ETO^{#fp*I! zCU+Poe4>n9oyzss?YP8r-AO&l>XNdtQ_c;rh$dN|B|lz^d|dseR1XWc!9T(fYbPl6 z>+I`_(*5(V^Mv)opZdN3=_mieUw{ARpZu=ZH(&U=k8Ykm>(<`I5PB)ei1C58ypE}G zMy+o4Z1o7pm1>~Mb3ACwb8zr%!0S~Nw_WAu$5rgE_B5hDbVpMLUGhg?8F@{+RQHV1 zwawY7h@W(a)ZypfqY9d-Tw9Z}#zM~c3sv(VFJ2F>UyXuOegvM5_qGz9^)i~yBi-8p zd*MA5%Jq*hN{;O4u~_aH&%tl-DLb-CS7#Dj!(Ga+{+Y1qPn)q1-+8KmNJC=sOPYzb z5fXelwC^vouQ^6srg~C zWC(T_Td?u8{gWqmuRi?giTcL>=AXX#j++nf|HTjgzjrTR_G7mnhl`lq5YObZ4GnQX z;RV6SGZ~qW<@e$q%4c86`LddwRaMbHT5{Hg$EC*)4^KnPjK*|tvAtIh{inY6Tkb#k z>6_bs@4xn)gLL!RZ*GFO1@2$$N?dcFX4!S%wVkpwUABiMgXK#FT(%G2K{70xz772l zRgt{SF=0qSSkl3S+@ZOW&wtE?($oWf2y zOJZij0M$6n#R#~vx?c2F0{Y5LtWVl3XH6v(gik73SAE7{dOTu)9SvHYT6wiG=Hx_A zbAd;b%#Fzk__|X>rM72goa5+>c=PwgSOH|}vNFr63I>bEQ;IiJJetMv$x)gH!q6TB zbhvmE?Zqj5Dy8JE24m|=_>A<#V4bi@f11WX)X|cEXm^Re8b*uoiB-B9Sua{ZL_*YD z%02v*3W8lx3M=DE^|!vuUGdJ3)osF?E@B3QNhOm%VZ?1oC1DV-S$*#<7IQ+G{WR3d z;vr{KR*uS&RF6hDdORLI@bka}Pd!zpuRk7-9{Bm-foRzSQbn+hZm=Ii?U#$zW+8{#6@xjhc%;kpnCi{%MMXo zDAjX~9-4EY@GjV%0+FM*N|PvtPSA~piWUrF)F>#7aWan(Dj`v%gxMz&RaBvcMzVya zbM2hP0$L`t$Pz>6rgIPXENqBM#<4$fVT*nn&J*XyA$(qJD4<$G|K>a_O91V=H_ESo;F$9P`#F7+nKO$UK8iqLLN> z9=rfp!5U`9RM$EqZ|VR^*`gte{I9#<(Z=NGl$MYap?qrz{^o58wPCN+P1?o8*~Kw@ zJ9}9*IqW8%CsT5mtAx5kpS4QvvlrTvi_*?Oc|0&*)FxtD}3WuCA z$EGAC;4Fx8cY?!_7Jaaz1VIUp1j{bqlt^^NUb^3CgN={h_~xv&VlJnB=9zhrM9&MX zVi!VAxXI_21zs>-z04)gZeBnAp#9cgdHC=D(%+(bLdbn~x zG{UT2FGyK<4Acu~hP2qrK|ojmb~4tn&)DIQ=oGCXvg(qLsKdJf$}uygJRKTNgDLZc zgBM8UwYMzJtAfe$z`^L<6gB+*f9#$4(`{E-=hr^t``#(J2{$(r5JE@_0tx~mC=ds* zML^4z4l@m{Xf?_ps8DpvP$g>70__Cq4^oPvI8=+2rM8WNB_b`-G&aa220}s-5^{6L z_kGVfyFZ`rv(KgbKe)Fz`+d(jYp?ap&$HIE*4q2*Bd#_KWEqBlV(VnH;1BrTU0WLY3l?iY_J`RDelM!Y{X8Q>W~OpnnZUX+kMfAkH@2B40kM@qyKaT>%FLtPQ7Jphp{sb3xidCfwecv2Ql6dqF z|Je`#T>TWm%|&q)3zZW0oH1CarhzMA&8%U-!|XgX6rLGapaJ2uGvcWh7|};b#CQCM zQGOA|)txTU>DUBt4hkU>OLHzbw_v1IA+)`NOej;UknqHhrkB9;o?6FL68enkQz8iVHNp zhh)8f52s-l;G2#c{D`M{jB>r^n)z2X&`Mw!J3Tc zGLSp>VR4>hk5 zobfrU4ku-G%V@^oN;b~Z`FCK?V4ugck|I2D7_af#Gs&2KTz5e!Nv?9cje`4y$11Y- zYeM9N44)KJxgqVY-<&#_f`aHt9aK;yn_QJ(J`zdTWAIWiCrQX;A}5c|5lnJmkm8G= zN>xUYlsr`4OBi`_GW-!9{;4-jMz|s%16fGkL`yo>0_NnJ6nJeu8C)}PSuK+n^$ykK zN#*mEvrqP=#%xfI#Vj7GMoY^2GRhjAhm9{3Naz8wGmfS zpe-j?3T(neNlF|F&MtCTR&4Z(eYJ}77wS#xO36tqd*W$S5d_h!L6enTM9jVvllgm1*!6 z=p5>wk9yPI@`&=#HtSD;S=o+naWd}2(ec1OLu8F`5tJ7N0p6_wRCHJ!q1?jB^`~=_zNoD zU15s@ur!r7_8|6xk5FnNFOS4ySdc_4q=I=@~CnDj7xV`FM85o__D1#sR1C@xU+c>gX@A%>$hM_ZC zGal{h`qQ@~+@gKmyZ>r@kY_W$|4Vn$Wheb=!A=sO3=`Swjy=`EzU3lbU(#XY3k?u8 zrOHd{{+_Glz-cGdUe~3P|5h^q7~Exj8%y*fT+vfp1xEe z;Um1mIQh%2C>I7UhM#gMx@4gbMbOoi!w#HbO&5iXV){TBySX0Jtw_N49YmO!S9pes zhVdUbVuTS5EI0>;h<8v7A)3pm(Ks9t%&{8{aK0TQ;y_9M8L(8Q%8++Xq6F5-l_W?} zX#UpmU-ijFiZ7oeBQH~MCOn5miOiUXD(d`d2H;}gGJB1&0i6NAj6nrKESKpJ0dp9bsZKlqq2|!S zh;-=i=2KpJT&C!SE7U`tHP)$^+G$D00jCivDGdioY#Q6dl@bER2FDKwaZN=x( z7_f9or1NqVt}fCV1(Anz7uaGorA zsInP>L3g<~?s-_4w}U(RqN?w>5W)J%IdAf{6VOZ`9Q%`X8LNkZ-$xRe8yv13;3Z2V z$s&V413OuYDl@m~S0HE!qfcBHlFc&fOO-zx;plLSBx)QS9 zVM(oIgIMXq#F^yj>i^OvIwj)L`Wo;c1s<)q_Jh8Qo762m@8c zZ4iQRa0md}&=DI)K0ia7c4dPwYGWj4@|k(4N%%lE8Q(`zs7lQc!Pa(8GPGQ^R9uJ$ zzX~s?(B=e(s)QjQ=wOflCWo|3f~h9+a6{8jKoZ*Yx?t341KOfLI5mGM?g*YjJ!c#Y z;1c4}5SM0so$D7;qg~#LU54VPo`8%_)a&0&Mk6$G$rK!kQYx7d(p&qL%|OdN!0IhS z@Ua#`A_w#9(boRH%@4M2exQw}m-2Oi17xIM>YsP`#_o!vWX&pEvMM#D~1YX%T>^J<IY& zbdL|xZcV|uFs#Dp)efla~)OfF8tA(b|rCgy*8I9Dbv9C6~5RvciYOK zhR(tZtVDnk2l=CzZ$p@r&4Ee423Bwc(FrVo)lpH-F5)>?G8di-yS9P-E?3mUPWo8M>m z2lfKS%PsJx>S5gBZAHOf$l;g86&;?`^Gy5&Pd`U-su?1%jbEd8H-c9B`_iuwEVp)u z=et_F^y~UJJmu?`zqh!j{r&Q8lUR3#n`Elbm?ba}>v`Gc&7mo=Hq<3#;2nEdM_EUE zo;uH|uh^zg|3hFEtRbK3uI=YH2oEf`+rf6AZI6z2Hy(LZ_bDFD@vetIxZB$~HXd!? z8xH*3g35V68+3E`kOMZNF+%X>7=>TwGvmPx#(fe%2)L>C95xc@y z7Z+ksDjO-Y*d&I)>0^0GTUm!%r$iZF9P{)mc-hzm7W*12m3a;9r0st;SojSCL~_m+Ul(ssqzi93L)tdO zB0|Y(uw!=20H}>5we$s`?e=BZr+*BIHRm$-8biqY?qrbw(OJfq_A;2HlQU_t!55P# zl;<>2^gC@~=N`v$$(P&sxsBBx1|fKOfG1NIN3?+Yxj8l#I9Dw&8S@Y`b~p|H&9Sk- zIc)*&0ASEJLozOtq0CPC?N&cCR5O0Nw5q|H(SDd^P#`0kA{eesc?{0Lm<|}Ko%51? zIPr}Z#&t3X5M&uLmx+*hK;|K)KyQQ02%j_sHC9e0NS}mTJj_kd$buhwBp8Xwmjo55 z%_m%xv4S>dBDBiM)RNw4x>ZgSof1Gq%_ur;bj|LwqKYVGc0|lyZYm`ELto&`%3;r^ zs9qPKS->zE`zQp6bD)EBR5N&33k4X0`M^7^;RG(ml2&+OKD6N^q7i6La9!UVa z^J%{-)2IN-DRpS5b2wU-E(mCAI06PuUX~42VU24h={$Pk@t3ViFk<1oCUj z%0FlXd%p$XB+b5J*Hv8N7UF|2<5}yb2dLZMEsp&`|DyZ7pj~nCUi%L(+T!73(|#Yp z*zL!Cd{=rrMREoSd$M_Zpt?LAI0H6h?e(<^0!D<=@j{{Nd z@~Vg7^SCjp(_9{?GxIO8$5kSgP^|I9y(U=auTi|(x_{)xn?mvmCyB#&)*lfIcwl9sequ9ajwXr)&)R(pJKR7Hvi$rV!4> zxTC8Jr71%0vBtUX;tSieu+STR{Eu5Z_RsX;gFMZ23K{{G)gLs8HFh*2uXS$LE1!dk z<6JV1X*{w=C}IT}U~l@%SfCop#GCe2+abdtRY@j-p#++(oRX(t>*h74HR8n!ov4(= zL>Op)b|@k-vVkTxhyJroBgh=pZB(Y=oDBg$Y2%}43aJd7MP6tisLa)`exXJ=28u$# zdyS=hk%Uhp#CzZjDz$OkWVQl#_$z{}?Fos#zbAI1fDttT{+D_A1~^0036_7%UN>ZA3JULNE@H zmmwfJYWh9^&U+G-z!UqmgSYhE~aGUI%6m5=;1^pGh8|p+b8HS8aTH@b&hGYg}$Lb^>Mt^7Wq&<_$FkYL# z7{i&LKsgliD@PzX@N<}aOlW+Xz@W^~KjN|G;Vlyiqy>|Q6Ha%$$GiHFH*+HD5dlv3p6Df0lz03D+Smr}U{)wxX+G|!0ahrGfT;z>@}h<4gA2>=>J zrQzY60dC4bee#xoLT3ZPv}pFG8G*8$?Nrc2&N+-LDQ35iTdDr3>T$fa52 zA#vsM&J1U0n3@ta{Y^MkS(YC7K>laPX4cT0|Sa0xgDU@^U1EyY6hAKO` z63Pi8umIW*v{x*n6imSMzZUe~7$+ZnS?jo*m{2>%rD;{*Bd-U2GN zUa{&99U67h)&6ceW9`9i67&arG(@E8gNoumMv3Vn8ATleTRUrHv`WP|G;m4=7V_uA zc4R|-4)FfpPl2JARgCq8hbkiYuTp^U7kk4;I-1@(150s)6}3hZ=jlTw=w(v1q8*tL zHf3+ekXGc?Mb-wh1}d5)o0mAkvF?PYD_69Occm{4S}8aKP$mP_!*UKksj6B(p`$Du znm3nyIkjE-GF7%>&L;sDT2#7fR)g>o-bjL9VvzP4b)=(R^@(KcI4Ot_x(VmOM?%}I z4*J3sXW~g@!4-^S{bTbnp>l#JUF5ILEhtP0J;HZ6pSFt`Xus=uvo_s6wfxg>x*BkV z-giItF5PuychZ%nU53HO3x(zOj7AdH*(zs6e3eZ=q%kbGSj;DUF(bDFuzie-$36>> zop${PKGwf#YrcB9AWgrEO=nfA~L1XIxEr2*PQ2UbH=HKQp?b{nW>g^*6uedx6E&`!5SE`@iFD zG{R zJB;EUF{q`Cs)>)(BV5KR$2T@mP*nc>BSA(vHx8`MjvvSv7y?PnS&F+HkM*HiXsBts z-Ud+-DVb>1T`9ejPLqO}^rSAZEqK*Z#{?3k`UO($1-XzgPiK5rCHq8f^8+77$}hX* z45?rSbq*^0Wul;f5@*x`FNXBBpLX}9CDRApOh|tz>jFlLu*hS`7%6&||Jb`=S9`$a zdcQj_(C1kU0K&;L2>J^GS@csu%=0x1;xmS(o1<58Qy~c1DIC!>ET>w*jRwl@j?}az zyBSnQ6F+E-0;&Svh@m7Zz^7CqD@cBQmCv z2KX}v5RGP)*0myRj*SJ*c?&FO(~Oox{N~tL;M})BC#I^bJ^aNuV1e>l0r>o(Lnw1`7%J2ZORw5EmkE_zp8&4Pr5-7< zrW0Jkf{KaB21#iJK5ANKGKEv7=#;E3X~Bqq2r%S4k>O>^HS1Qjw{TN-&d)T#85;1) z1TpI+hG==usY#iF43p8#kIp=T5gmtY9(4nwF=z&?D8-3oTfh$X&JaP4JcFADFrfUdZ_$nZ`D!N0=}{B{UsUx#LTwhpv{CYfbXpB~&oGY5 zbKat976pKm9m>#|KtMWoPCzf)0uqa5C?KY z%bBFCBN*MwQk{q`1_1icg^z-|xuK0>zlcIC;TGXQraH70pa_AKNiU}!0;J11D`dj& z_K7~seGX1b5ylCyf00RiP!!a!>QbBT2FPT!WTJ)k%WfYV0DP}_e7I|0e)sBihhNez zIlQ;}!IzAd7aiDZ)5U)80zgmTAy$W*dr_Qv!nE#1XIh*t=E6cri?a&aYqJWZPQh{z zG0Nkcby`w1byI&MN(*KYYwUU=96~@}wLQ&c&+4eE=Ln-?#e=6fRU2H-3RZ!%SJAZV z#8iPc9@VrA>6Lzu^iyji9__=w+ad8nJ+z>;#y(>fiWRtUJWBrW6PELZ#|Pcg$4IBk*6CMMzG#%k!hgi5<_ zAO#SyC^}|XaKOoGMVV!78-Kam0W^JH!##_6oTy(=B#)w)l2>NacG39s=wZ(sbysfh zwl`e(a&W)GSHLgL;EnJM%w`*657RNaW-FRLAJHOcZ4VOZ9A;2Yj;3dUTs4onXYDfvF{-v z=io{mA~26MfLXx}kW%%P>l0kq&~V^a=+Wfek;<$xTEdan&INOu>)-Ws z<_P9S7wO3g;^g){$!Egn(DzvDic68wmRwMx!71U4v4a`e%$T~04E6J*{ zKM(_bnp8$wahBN5ahwq%QcasU#i}Cl;S>!mqzRg{2R$WQIY>7;Q4bvq%2jtadJ+k- z01#SFWOM^p6zRu$L8rzEk}APF-#9nz(&RxH(6>EeA}qDI3@Yonn&+Wq7y?x;RVb+|hYavdMUP>Ep7JC>lv!P{ zrtCB%w{MoUb!m9hWUN{?lgP|9u3J2o#Fw|4(JEyMl5>`w6Ya#UKIeeAwg^kc)rgM zl713iI*xy~M+^+(xTPLiIC5xA;G&2lC`c~&Wg2~HpgFV(P&uT_29vB|itIg?G7m0J zv`0K`yM61Gi`CCu{)1eZe;ZE&yquNUwghppOBEhmDMdx(2S=#QUK}-=2kRJkuEH^f zCf1vrxgzxoJZwC`EuD_9U7ZH>u0^}zPo3z0{5=4sX5w&!;x>PdMraf|$@-y2sN=x8N~5 z&$;h|`^WCI_Jjw2J1uv0qxmK5W6YzNA>vhDvykXnerXYt8)CXcQu$i36gdtg24reT zDXD)E#WgsRRm>?SFs!}()R=&wyQz@+;OILoOPEwNqy>CJK~8};&LLn&Go-L+Y(ia# z#XhuT|GmsmwpF)iMF&#c?5|<|x&nZI1%s5tqYL0FLQRc&v?qV_i3*MqwmC*PT&YF| z%+P@o?9x?@8bFOE)bpYRTyT+;)e4J6Ss@ahyq3CI%LQ&4k8lRy`(K5 zigAX0Leu`E#WgZ*(1eV^8k!{(ZHjI@;$8M@((!c_fu1rdLd^p~psf{4E+I6OUk5a1 zoHiK9`bts6UTG%m0^TXfB1+PvN=Ztov!x4QUH&CG}6uh zVoduqz<`KoG}5Y+;(!61yebd!Ee*VYT)qXCCYy^n@H%R9Y%FjdSYYQg&j83cd>#n9 z5%Fu=0w2HQL2MP1W=x4SM4RSneCFIG-;h!rx6J~M+~#MB4DV({Gq!b%)Bc+;0!Ov8 zKw(Tk5UX54N=n8uGZz_75y9ULa8BV2&s+lOgqfKQs?96`)^lJdF=|2Bdm1#*q&nPobcogjnNN=l9-rE znC#38b~ek_lgz8CWkf~}F+Kxby> zWJ-tgka5Eogt{uFJ7Pf;k7>Y+)#Pcc#oFM@L)DqbiI0GMxT&R%nW1T^bQh<2PL^y} z5A?N0-#N*2Q_k=dQL28G+rS{G`3<(wg*^RpYdkvsJKZZU{NBmUdz0>!FPyC|d*JDQ zI^$bGQ@(jUhn&P~{z^%Otbv@yO&c{OR<#8K1KO4qxz(PFJG^wYJzWZddE`TVhO(}4 z_Ps`P)~r4n$T}m)f~uW;Q8jE^*tP4H5J7G+>u!J(fsq6k z8S+Vyqlik+jf<>kRMbI2nq{i+HPD4l5A=g}uQ^-6QV5SiSOLKRX(I`5j!koZuUNgVG4#UVli?+awfs zJIKJyHprFj3^oHsC~9c8L)bHs{R@EsN&(b`UamS6n4c=FR9`{VBGcoy@U@4RVr z_X$?o@Zy@I+USZwew~$nSRin8q}|w)dE!YXc6t#xT-;Ref|{3srRYo2&D*+(PYj$rMUpfvYDO5fZc1 zz=Fe7+aOjxl$nhZ$0oQlT$t@*(~x5DOdw=n$*zlfBd;rHx!EtZG{uFq%xLmuP-Te1 z6nqRTU~??qpcMrF(}ldZNptYhqtuIsxKQMkmJE{)mk<^#KQxFU4uWAdm@mypA%6hv z3yM_2>wmeLCY4H3a7`G+Cdk3L5O!HU4VOqHAYD6#rHpWrRAh(&9Wg+FaK48Z-q@Nd z+?f0W=UCYI%NU{b6vWji3Y$rVYQe)kMfHkM&nCTA3v1;N0m-Rz%!eCD-D*Js1ls*x zSZHh23nFg}|KTD(+KERcQUzJr9Z{7A^J@_ex>8fC9fw2;2Cx#;T5BRg8AcIG1)!}D zJ)~>9nI`bk6y7?@jv*9>rT?u zf+1W@v#6C{gatHEWG(c=dPpX@T0Xg0L(@oVa@3CURa(&lYmJuk(?pnrsG3MBCiA~) zqs$+aP39u;;Y0=?&TGO0q>}p{2tUw?slymJsnS$(xQzge|C%2{kk=!SleZIw&|t+Y zp4?GSI|wQ!N=V<7t<59?B1JJ(Q2Z1ztEN$(pn_8oIz|cB~9emz39qbW{85L?p zHXvL$LCP>q=RvK6Y%z3R7S1+@rnfoZA3dbdaG}f;4I0{l*`DSC6yAf`6c|V=0A@z! z#8UY=jhKXlR@VB^aDIy8T*uX31eGYjVvEfo%%|1SWy;=#*Fg`nnK53CPW*ZMg^M1v z`itAf%jZ4qK>rPo8MW>0Gi+kePk2E4@Jvvz024Un0i}v;hGa~Qpaz~1l%GD37w+;( zzh4{h%)`%xk;S2tyb>QBInPc=tToVrUNF#B6s_cLjd5;|aONLu2>_#L9u)Itv{ujY z9N8on8U3sy_&DEq^0DO+4SWWzkSXb~HsGPfhf(fKyR^ZMYYlYL&?d(EPe|)sV6Ei^ zG>7MU%~Ks}N&O7S5;!Mm_c;YrhE_0Wiv6M|C~L)q!Zv<=!n+QlH>k>EJSe|sCBOcY zFE4}|hM2Sxir@w@;0`d-ARN;kXY!`ql~Tcsd45`Q%2V#A1mWYJH(5OL0gKhoUH{vo z_R-faxNr;fEHg75yWu`3Gjkz4hhLZ_wsIbsRIyN(VRdCKiDo=@LOi_2I^{c z&u6$>db@kUmu_i~oG$w}e(|Q&M^5b!l5`qC7urNSOWJtSWR^?R>bk-J06+jqL_t)3 zJ}IpAtB2B9!9Rf^oXUPRp0S2P@}IcTRt6IA(ZfLzwWwCB8Tb63LaD3vU)J6*I@*5n zH{RFX_O|E4k&TP#A@(lzjHNPpLPAoo(CX>Dgrx41U-}{T(?gm>R_Tk{MtKy?5Xu*? z5>23>E%L@+$mps4)lgG(twHjIrbDeC0SRE*rOYNJEm<3aLYJU_4p1 zH7Z{@#1Yt$iRL&LIKQBz0P^F%l&t_rKuX2&G28OQj3On4KwC*9?%^nyp))WV01@H| zhds-fPFzJS3=v9i~|G;CmVBp#}<1cHfI`h-!S zfV`urMxrRFUDh_6Fqb#)7oD(19I}H^;^T=Ya)Q{(cNODr=LB z%Z%LY|B}xbkdwDEC^IluDsaHgRDfIpl6d4SW+s&=n0T0H=mR$L4jnS6lQu?ibDbH$ z)KMize=kT-X}@tgKoy?=&|pq+_%{u^phz9(l}jW7JrL|~t`OlmL4GLJ#<(6ZF;gK= zAWf0VQprbBobv)CTm4>?QnN@bc?c^tbE#yl)TkU+NN8Z4&ZMVQ%ft+3fWsCRWN?}- zrOb!bv1BDR=_?u40x2w#s<25@hSs~*50l_B=dw}>X;!z)={aLkq>8NEq$I6qDYE$( z{d4bRDz%D6iLUa|1O$Pvj#ftgEZ~!ob01~mP&o|XI}o_u$-5`iteroE25nIVU_&U*g=Qqd zzS)tnUR#H7=AZCVrt*VQXh5Ao;FQG-7;4LA7+L*bV5`tllb|M4eBh!8^^h`LQxFm~ zAjkFv*}5?X7r5I;Bm|ilhv~57^G9&SXG1%<+7ixkl&HqwNOUW!(eLgSc1s+1A&Pou zlJMs#|A}v}UXQn2ZsE2|fZR{lx@q)>_d7cKonQY<_uWs}xB8K%joMX*Ps0!Y&X@=q zPgCjB$@O(6i8@vLQK3>Enu8iW9k_~Mg|0=Cuq8h@z#2ZbMSpP6J?Fk9sO3q!j?gHc zIlgEF?V;hsmw?)uhfy||30`H<4Bqa9y%X7fEr6@* zQ4?QU7ZsF7(OnREOEs5i`GLUJ&Kz%hcmHopGV5OTrQ_|XT)qA2J8$j&@t$3_?RNW# zcft_5?pm{0V)X`{ikHE+oUdcr?oQn6D&W#_HGX8&E{>at<1CZ4VYT@?@JU1J0vx#t z5{Ni%Gy3F$C;mTheo^<1`TbWv@sXSQzx#o2_tCGTe(Q2m3mYd>lxD}q8^H`AZNEWo`F*`1?1B=O<^Zx{LI|NKonmq z;eYkwQ`M}r-Wp@kkLWZ|wN67RC%Y$#6w^|uKAuRn~Z^!cQ(}{X3l9*H^P0bTj1dze&J#^@rQWXV_y0_%tae5c)X22U&j?E zrXtL@nU}b-aRBG&4R8l)26--8R)TPhcBx2a@?k*^+#qM@_U=+gadJnX%riBvr^dvc z!QV`2N(Y^k!G^%_kSX9LRueJidda-50uas2kn9JkRRoSEPK(JLt_mYDoGUL`>y8B3 z2W^xu?Mw0YF@h!MTfYv6%D71)FL!W$;C2gn?gTbOJ+@R+A2i6F9jEZ;k&9 zM@p#8qcKEaLlI!))DF&Fr9W)=003iW0P=)-$tE#gk_JoO@~ON2tS@ut;Yg^>wc!}ITnP53v|ab zF!KAT+)nRT^%>V#bMLZX4z4p%a2aGS6~18ZSv3G z0ejcq*Y<6Gq4C1}2;%P0!eTUae1ZjRDd?_o7oZjEgoSAd=A44qI!^@R@i00XI z*v5`(tZ6%6b}oC475wC1X&emsy2iLeZwGe{$3=pRb4zwb9w1>AM_fahE=5F_W-f=b zP^ZqWtIp!d#E}oEuy$8g78#ZzZ^76ND^o-}Sjnu~+>l8(0EU{33*zH6UVLU^WSi&U zsi-!JbdKBbM;v3>8B*+`%9O;2N;H?<=v`S6CmSL8tUJsdiu{5`zsq=P&WNo+a$bPa zu){`>4I`N4Cp5tnQd7jpI8Ug8!3oI13Ap&&k;a&}pZkemgQk?V9CRG(!r2dC1~sBY z>54Eg^_5UlFKJ>sT9h$uYbxT{u0oZQ3*YXqX3C+F*&M1L74TLu27i9Xvp*w~&9Sk- zxo?5-Y@2<|^z3tA*o}l=;}*E#b^d-BImyA9g9OGf%ph$FFu^cn6U$~2{wleUG%7VX z>(+?Yuw{kCr5IbDqmcCocISgb+f?bHK4_aQVzvTW{h#MP#pNs%{(6*;4~(#%VbN%lrcKV zQ%q&FHjT(-NuIHT(v}GV6Y`)by>bAGel=O7CawWDt)VA*d1`>-pyUELsn1N(b_DmX z6a1F-Zy`xsTukBOLq){npZ-(6(6*tg0idGIat%$@si5CX4lwV$9E5TE^56cu_LCR= z+tn}p%f9}npSj)s%`0ZBLl@8b>3DxPn#|i47jU7?`iNekQY_BG6OW+6%DYJ7JcZDp zvQTMiBN(S;7Mb(!jNU>f!B*92u9)j$`>X$h-_EAiI1L)*3h?Atb4F+wXdd9RI+k{1 zk&bqz(RTai9xxGDXVSjLwM|(JSb@cXJi&xG!coo}olV`+*98Sm-|;b!#s1>!AMwi1 zp<_qJPfmI**i?FeFFmOkDTb0Z5KR7&C(LCO3*{_&X#-##1LWuCfCIAxB%^*=+;TF6 zOUY>)b#{@sCZvItsfYkmZ|_sa#167_Z2s`f2p(9!)n9VeJ^jmGKJEAI&PM;`svqr7 zeENCpC9@o!Ud4!mQ1(@fW6xAHY3eA;3=wv5h7euFOLh*Y@W%uy1e&`l0#CTo$$)|?91-#=5kyG?Rs^GFmV{NcA7F5XMl?EA5oEpJ3*+a;O-Tb+I)zLk)vg zJ!NpBmbUd&{b)xCKci&CrQK-JGmem)V*X^d;OQ%Ep^pqP^Pf*vjO^Lal9fOn`kAsZ z4jcI;t_2nhQM^WOkyLRt(jT)x8@*YemecB!wFJ;i?~^a^c7b z1%Xi9TsD9X^O9K0i|0ckz^!B!_VfHcafH%E7|@AWD=v4AWxe@fyUPL@;;LaG{5A116~@{iNw8*fN)dIAt^b)ZHD zL}iWQ++B=?(bj6@i-b;tT>lA+IEgSPTauRsA2v}Tah%7TDowe8LKt}=vVQW`8*YPz zn(`s_AW%F`IlPh6HAH%>Zf8GQSg=)IA#^YyCtnB};wsT?qxXHW@4>zue^2*k*IeA) z_!A!)-FWq0|DspT+jUQv_xtv^m3e1g-zMM-4Pr~5_XH7rgo3MCRTymS_>w=F4t3gJ zYZ~{17h?xSGFm*8Pb>L2ax@5yB21%zw)s&MkgkcOysvreN% zd5m1=e4;nw+RBkthd(nO%m_(K-C@kGahPd)Nk8H*)6;cpC!mL}fBhhf%jG)N_7>a~#e zi@8FDllUoXMlQu?=$NwUHk)wA*mQqC?dRS7AF{K0{(ro%`^4_f;#aSJZtK7FRxa$# zxy$4#xY23x0)!BuLs|n9iAW+*gi(wAB%0|n$YC#XG@NSg_jK?4^q=>~xMKWMx7@b+)a_qcE_Shp%LeZNaFB{_>VnSljQp{dFbbpPwCyat`(@c4cj@KrzwSHQ z|JdK&+Wz#)OR3v!oI(NLmJ}?Mt6nE{TBkZx2?5Y##Hji2a(q!;rvWf!D zi)i?=uz3ff;DuwKFA%z4+X@{A2o)PhK6jgWsx2}x06JL(NyHaLK=5mq0grHzHUvb0 zt-MNBZHZ~QdBdR#g?u6Z8W2iJ_=a4Tx4H;R4g~UnBvQv1c!Pvz;PVF{RETVdIif;Z zXXy_&g`Eeeh39F~JRW>#KP3Pa1gfks7aErt)C>pk9agli2?2z<^-(mHq(a_0ZCKbCncE$2p8C>l?*hc>iW}2f|;%LO<>Y5?KFF_pxlgJ+&jP< z;*a=IJu|WcGJewai2`}xD9Q37tRz>b*~QgHG13}7DI(#gWklB*4(Z9!&~hlS%^?S7 zVIxz8_b)=qF31QZW6Hi{g=`*L970G>{U9RMP%xcXVt&G@H4L2;Hpj*S=Z6KH5!*dB z$HoHZv<3S1T9-paYZfky#Uuv?@)cj#B=I?CGXo{V*g*ElknED#ah$VCUJT}rSs`6$fvL4@Rb;>C_1ao2v zCuu4l4f2Ar%kEhK2hvGGcb2z|=V(;^J`l16={(s*XG! zGPG!u0H|uEs6-}Qr88$p+-O@z*Q-8 zNkKU@yIR=@FYT#;!LY<1D_+aArzyr(H|c`zdzyW-j?EqSf+T>G`^k~k_8p;otlj#? zZ|a|Z_(|P+-haG%`V*I{XTEN}d+EK#ZMwJOy5?duo%3J>oui+#m|>e_5L`j|R|@%I zI(HfIf^kwEuG-@Px$7Sc`L+3=HFEuJfk?4 zvTsGEfk9bU}(P3k;! z9AgpYP${3kAwg&UftOIA@*xWLbB&}tswEk^hGII7q%I5A*bjy*L!@6lRC4^WhBAy5 zMEQ**LW36SEQgqbkTmHq#smS6G zTO9iWrnsZE1DB7V_^I34dw$}(+h^D4ydiRiI;zGwUQJcgTd=CDh-i>n3O*X(GE5i33eL-Bz?P8QY57xSb(~R}~3$MPu zy=v=dd+slPsD0pZR~l!_`M&#c#{^%$Y5Xz)_O;uET8u)*O-H3U3~UJ_Ba#~5U>e!* zt?^A?5+peka7vsxzUX->kj-%p7(P{!X6Y~(hO}iGL`f#HHDG}Yo`ZW_XnoY?-Sruu z2m!JpST5AK)RZSgG4TYEh`sd&77YQfjj`Y-&LB022{A|R(2o8Ievue-YuJ{PTp%5O zg_-c7hQt10f-5?S~J zrX7XQ{uMx4PW~eB@1bkJfFPPcA#%i4X*N3yn+QI`Lg@Hr~NXaiPBc#H=EqZb2AaNywD5os;a{L z0iFKqC3wX<{_p^;gUsw%A}O1 z=rVvin44>oWlR^)$$o&<5~ar6lZCLv-CQ?|nsEcqC#l3lCw*{CvDpTXFv%lMLLibX zl%$`GEphY9aLY6toi<1!;)k9hkc3qhe0S}fEM)@8irjo?GBjE9A4yE9bMM;#Sl56e2BW9Ylj@k-u&htmy6&A)$66&z5r##0L zI#3;v6DJ~Y5vdW#6}rSiPeqCkuvs5NiZ%%j6s>Ub%;$gzDy!lM6wxSZhP;H4doJ*a zlgSfCi4L^UlDTR`N6C0G@79aWr)XF(W|cx_A24wHsVIV&HHg!%lFjgwAE!)aC;(z3 z=CT@tW-=~=#Fig|2h^eqIQdhl7iFZkSQP1Wilw&gH0mHU_FLLg3P$8B#Nw$i{Hvwj z(d+Vq5$_To*wYRh-A@HV7cxfhaRD%kE zf}@mn262f1V4!Zh@?t-K#?(n7a)()bLKI6&HC^cDC0$7Z$G(~V&w=OyQZ9T;_8zxo zlqE7#C;cn{6-_<-5Qd|AKA)W0EWcR6X8q^rWsw&ua1dzxWv&b@>d~lfsi}5bIe1Gz z@u}{qLoqaPnx6wIoCA*zS=^TzkwIE(yScD6xJ8Q%Y0Dx&qP*57j}8)7&KT7vvBFaa zaj}|^aEqy(oVH6YJ=4DHhvws_eB->m`5Cv5KlqFbQS_6eHocPd{!vI0a~-jST-gw| ztJyQ`8*399(uosgkRPfbrt2H-Ak&&0i*EPi=laz>H(|2lqtR2}-v9DFpKY(b&*A>{ z$4~UX@-LrnH=n+nZ*%O7Pw(;F@w5JoZtUqv8z;EFQ-7j!?ZVn9L8sWoP5I4?%;e8`|nq{;S->-wX1_`tsYUH5)?d-Can{gb}u=U4yy;z!Xu z8BO**aJr$yl%8>a6 zkYOxc=ouDlUuS&Pye`~w68#*Z(q%9>`1$~t3LmQqvc@mX;c%qOYP5iuDaWuMI!;Vg z%zf8?Kqt(wdG45^-WVgnBecO5KDBe5?HvM7JD{X%Zo7tBUu;lujs@ zl{47DL1uB48lV?cksz+J2Dyg(gotKHB~a`zB=q3we(89Uj;FY8_NcM?u%oOJno+4_ zsD(6utuFZ$Klw9yMu!qag=7;;!!>+EO9Ar29tvrHhzT@eFsMxc#$19JvwYa-e`PgA zgn!!T6-cIV!z&JUxZWdva$6VUrC*b}g>Vr4II$bTXJzqiW8*oixzOQ0<^^8AS6BS&}v~pC=~J$^s7h zeToJ?bj}aU};8kWX2MbIz2|*{uOp|9U|MAY=_K zN}wtVqAVWNhF5@9fOB;U0`FSQz`>?MhqDwVnwh8=1_UkIGFmbdlzaZ;E8!uYeH-nM zVlo$(f~5J%v_%=&5?VHcwkAc3n^v@^D)d7v4wYo?CxHsW_%3~A=M;H#78Mj!)4l<- zvWLduC))aMhTFDyE&$(G-nxK?zV+=Z@4vCVdH*BZ&;QCD?a7a6lh?d{fB$XIIMW}! z5X15V10$|VP5F&UCd~8tpD^X><8qbfg8s?|%e6|EXac7X325RhpIQGRwx$)bvE@~m8W{!E)TXl_B z*mld)ZFS-kJQ(brQM=;F?Ws54+&=V2&+bm}?AjOHbzA%IU$}L2^3-en|68;9W0v)fME-sN7$y@ z<95+})xPEE1KOWmeogler^fxaUHi@5&b!$$<8FxV&@~ug+4iRV1mf8EiwKUhgp@qo z(kVfcl;CFw+Rb&DX(MAHHlvEsfLPU|#v4t#(YpY&A-`A~dPWBoQUZs9_NFPd*3e#C z)!2c}0-FL$V$X0XeeIZk(2X#l2yYnaO5cLQ2Z@yPft!w%O^w9nG1_3KCWfinz*1x( zW#dPRo;ga3Z_=?g0SH%Hsv)F|5$PHz((u9o28oiAiOdu@;!n)(8s7mM{30q|kW&Yw zy+h7<1vR5MFpx_N`llNO7&uQ*sRUD8Ppr$K2l!k1*g~jly2?*TOOr2|=HC*VD=`Kr zRvK&ygxJ#YUxJn(rw^oNh33gLP6-^e)H7|uWbm;;0+0hb{`E|fP-9=OPI#v|PK$J$ zc%H@puS-^G7}~|SAE~=3tblpL%2vPb_Ym8EBax6)tbo(U}!-b8IYd z&Rd|{p7DjK8qLmmVK)M9EWiZ72|Ibnui`sG=YtaE8k;G5O7gVAMP+gpW4v<)9tM-- zo7o6iB4af-@jK(70V26B7(_1skr=Y04kWwBiAV-PHbgg%nwnG)?#YZvqSTn5BQB4PQoF$p&apW6 z6#Tjgk5wJ_*YmRpu$3tXItf9xO)67GXAzkY*+wGgY7B}kA(2c9fYMmB>0PSu%2pqoIjQ4#@hwBm*HS%tIF0F>WH zmMM@WBn3p)mL8DcjKCr6F_ob#)zrep$!2lUSBQn@y!ae36*=)&glnoZxCXh9F+%bN zKY&XDCxrxK!4-n41VP#nyW6(Pw*e)a5vj>e&6o&s_lJr^1ohuS$oOPKBYbKPkyui{>SWF zecK~eqxZgl)qV7DyLQ)I6P7wl9@?xN!+}2OWa)3zY1hbUyWU0+2ZdOiS?N#{3$NID zKr9ewrn9?=BI)XgLrgf6Rpk&Z7?(?)s;iXsS!htJJYk|-07-?K09cg;x%yWPzLz*C z!Wzs{)IrGNGC-Nqf32sZd!?A_^t23C2oZbWF-W?%%121*N=spH$roX&zjFytAJYpt zvmUxa#0JWQguz_XQ=gDXMeHc!{2OUyTTyT{u!G#ROFKt`A=Uw=ex23TE~fX(G2X;~ zd#Nv+Bta=A-}-j7yt@tm3^plBcSYnuZ5&etMkjHu>-OMUfU}-O}bJF+CeenL+R3Y zVl94wkQ-!H6E?iM*|^H^Y=r(x$LS1nd?0yt^J02+J38N|){)p(v}l34wxh7og)U$w1UmH1QtaC;J5zz(_YJ=x(CgxszgHAk00Yq@R@dRsm`v+>fUlIBkLer570|cRr6L}Cz z?3uhpYo>F+cxhcEl|Z+QOX5lZ?fCG{o=9crse$n?-7+DDR#uIS;($VDIIu;}*qnB4 z6IC5wr~?q)^(6IVP1LAi9$FADzlozk6;Ue%R5|(6p49u@GIJ_V80~qQ=jPa0;5@Lv z^z=zaE-$ofj*SJ*aSMF-_+{Ng+<9dPcFw?9&D_CMNbWFmFgUxI^k5XuLL}+gooNb5 zMGo%f027Tz2Js|mQVgks8qOH&J@q8-J}lhSX+D5U08KkdCBw2{oz4MpMw00WWoTv6 zXW3b&+0}<04VcWNlT#=6nt9b%$z^X3ZE3t zrU$&(FX2~uSY$;8A!~8Z_TShi$2019FJKBo5&0I$4? z8G`|hFfx#qc~v>VC}EkmAfFsEw=UY@sd1gAl}*5oTZB&~LX7;0Atpm=#J84YSTi4U zZv+7sfu#^FnD@n&(Fc0wZk*_x(Rr0R3d!V@6C`x*0RPn6rvLfW+A4{fLh}(gK8z@E zkx&@!BNl+^wRQq8vqlI|*0!Lx8))y#rv)innCNTV{VcWgVcy`V?ZI8;9D8oMD$V!q)GRpP<&SzOz3 zGlh-7)X}B^vqKbG;5ef<=1Sp#yyn%K;FQ18Q9uXFe_j1m92a|9-!{k(asq=q8a=>J z4l7xT3^?{3BB;bD8f$RdypaW0>R$?WR@Yk6la@lc3t0UC-jRU@t1Kg@Hp{Ss^269! za=|;cQ(ozMoA zc~E7cB+j3FqELRq7wTCpr!nKV3oex>>^nWL{>$&PZ<>^Mk|Ls*`Ic0fL+0Ohh0K=gCVXVlo%%1 z4j6cEMCSa?sT3QeFpedKJYL)3T01{7vR#^U%yobe@lAHSw0q&W@ zBQG-uZyLy6g<6)4NcKQc&0p*kL)+?HgE@znoB5CWOd?n$md~_awkNV6fI2HWCfCw| zzltLhXcWs<7Gn!0T29!8DH_$vD?HFnmOVrEf+Y-S${8FC69reN%oU<%Y|lA&IpTr} z2gRomIuSnBkh;XmKE?pRPGHW?S-cX(gZZKokYFJb{InwX4d@*)IN~%^g9U%;=EZ!% z24y{0XQ!Ot0Ijc>gV+-LMqkO$6ND@a7#5U(#pGP82<7mnf$ou*#WY9*m>W-Cq;o6^q;tS(tY9mJMH$*joZmnptE>fa(lLSmAL##a&)byU+ptU!dGoj zPb0A{g|R#|m+G|z7*UEcP8c3EsH_}1fyhHmMaQVaEwr2xV|G6+WeZ|cAzaX+eeqY8 zpcnlh4)|Vq%|dc53x>s_WjtU)SN`%rzI1j;Q^%nKZI8a5 zhFktuyY?C!$}$Z%637t`7XyoN&^InJ-1V2bz|FOTrbrBmhTa#pt^U}V|I@XLu4(`N z3;(ow)~xHFbL^h(rkn2=o#GjtJEx(cPci_r;E#`CEl&p#JG;A^(%rKYYA4~9MQgy{ zKW(aE(tpt3`_R1W34z@b8}XpYo*yz4&c3wdXE56Z^X{V6RvU4b%ysua+da{GwSVRgZbbz|$W8fhHYPqYz9iVCNUmP1Js+K9~j=fb~I zmXS&)&Q(LV+AR!)oF`7LrLR#g9014#F%>k@)kAt6MtsWxjdhb<4bEU~)q8qlu_>x$s0%KtfySagmZ8`iwgnTnYhb2DX zB99z)ZDfTRaiW9ZfiaT-VS}I4L?wJ<;3zwbfesl{Qm^=xiw%odf*odL_9UiPUgeIK zd@93N?J)E=U}8B!Eg>G7)abuRFwii@Ip+(2Xlxjmslegf`HO@F*~@kAaewEToi^H@ zV9Oo53TB8P{vd`sBoQ(ktk*x0A~HY|i^P#i7AW=6nAW14-01JQ2}Qc97G-s?LY2KG zR8mq0qPPh%z~fN+4uyPB%)(y!AnF@s(-+E60Z=qNj|w`(zYGZaCc5|=$VNn4?J-e; z_+G~rn@@b*92*Oq`xe;k#}l?lo%U^xjRnqe3*5j6{Y6H3=Sp*@N14Dmh*ObK+sP3R z1TzCLqyv#0kz~ri?MR&jK#K86KUvtUGXhB$EFjE8Ucfc+aN@+mL=C#B!C3;A3@TL& zc|$y#oR$vKIE4fjVaXXx?kdF~0Ej{Y2PCuTF*9CHNJC!a1jBV!-AU~{AUQ0#hy04z zVzG0LaL|E=ld@QWS#eFH&4Nq>O;pj_g$i_;%`hqC;wr=kx(Fr_<>X+gg8*I$qf*5( z{c)ld3z5jZZZE7_LPA?9gOew)sH`g-R+~$m^=Oz{IBFwgIKp5|BX~7mmH-no389KBaHA`M4p9(9!TYMN^7_ncl>BrVrg!=t zWn41IgqBR_OMA|d#t=X@Cs+o7_ETTW&X^HX4z(bl7@yG2#Se9$4a!EFfRag7R0tQZ z>cCnr;{}&h)ZZJv~{z<2}dQEg$D0Zo6Z?M8M*Q zho6rQaYyu??C^g!4f&lgcVPDL$RwrsE3d9>8|uz1fyF05OjpA2!W&& z8=??Wojmb*WL>Sk{w?on&)nB_Z@BZW?xwFC@6PO>uU0ja=S88ci=AwfPamsZlRoA6lws5c}IQ;HJIYUK_Uog`GzRQK90t(^QXNpmWz+l=PVI_b~>w6uN`4F&%7n*es&1_O|b(bIj z+Oc!lm_D}|WD>_R%w9i$fO`W)g^scVA0lh9GS2dICQ9s{R}KRTv5<=KP)&f2WJ`K# zc-mO@HdgSja%ad8OK>R=2sR5FfT65?)Jpfs!f*;w7zJN3)-8klk<^^8S#$;M5z+Y- z$Eut0!Q?Jl#gO{WwgY-ZV&JnzRmI!U(UEn3s*>Os6S@`uI zo~hvuoRSzAnVm^*b8IYdZd-tx;kd+6x$xYUbR*ry0)PFQWxMXzneDo~==d#Jk^$>@ zH=3OTJ082ZWKj09HM6yh&|Ik)+RPuhZZXdF784rlAgM4Ztn>r6>4#|@sRw8iZ!(O@ zifYDxhWcDtFprYRTu}4rx+tlxG7v<;V4jm|i^E#^H(qZL&EQvy4I47 zD^TwIl|0{LO$I~}*7`1VN#V%Ld_q+*sHqX-y6{vtbbx$T+gh)d;Nis^zMu{xiiL0L zc67cVkFg=3Pn^I)oEE4%VS=wz_^T_{mXrOpZE)~KbmTM!)tFT2#2i{HEt#Yso+Kcu z1i4bliB^kS0?2MoO-EB%Vmph;>?V+^p_7~nNeXUy2g5nnNYM~*!ow7YLS$jaPZ4QS zr`gdrlM^I?xFH}P)e;a3WU8&}opESn9fVZ?&@vo&DZ<445L7t{(O!!Mg2IT z9k=KMKtxJJMOG^fu-G*ADIo<_R^+!&#~lL#lrt%0fq-hRL)b6Uq)ssv4*^96&Dl96 zW0_|7ZeQ28y))3ebve4}mE#XQ|J92R{P2AymwxYi+Oywz&Gcq*TTYQr zu)I<%TQ6ZDzn&gu+0cUyY;_-Gs{odLPu3oke9Oywe-c2#+s-01as(C_Xxp*WhyTc* z8|;G)|LmvAzuN6;=;ubGcg#=cxof54Y4o7;<&F^xj@hbh&rgo-bzro(`pE|;-*WA; z-3`W{z4uiA;b;Emt{wYDxcY?Ct<`AXRg9T@*J-({{h(niB1UcxXD@-v+?vN;K_yb^ zL=6HkqA0cRc-ygtcTB7Qh=S2{Myj|IsEhkoe$PYOo$W;TxnrvZW9f9d!yeJjVgVN0 zMc6BY3)HH!4Yq)l5$cilwM}5J{~QZ^dab*3+Eh;g>aa%MV+qQ#mH0CMEvLH-@{4ZD zW$%6Ct0 zNq=xG65T5CL#xOF0gSvsRA5)Ui5&(J%mOUeu1R$wi(>c6gn(B4YL)MvhJ<1TQmJ7` zgKr=b0dWq{b`pZW+E`z^n&r#yYUv0Zy+C%OCldmxuXUuX5PtZE%+OXw_fwq9RAw(_ zA%LYyc#$Z3UmG9!S8bFdo5u=h}%CSL?pG$PphP(u&EWoGQ|^--~NR{ zv1=4V+9FDWwsLSmSQMigJMqSaf6mBM`dJlh11d5L6cT8sI>G`rwDKvKkY~dPEgWmK z69k<*Bp@SdOoTcPF(7e@Jkpd$V%j8yWyvTpED%wHM~GNdT)|;xL>uj!V`G7H-2$g_ zJux@foa>@)pVelrj*LZJjtdO(J#VyKLT@ z#y?{>>5*hk@^!UfHWP|5-Z_WqAo+^4$0>t2L~S;AnOHfs&eb%Z6g+`wgy1BJ0+^&m z$p%2I^(#12YQl&i6;yOrDD zbI!NjoA>K8*FKKKKX9(H*Y~^EdY(B)KgOJ6%=tX)S@f#4j6{G_B(#>zHt83ulJ-iV z6pMO?BqehMy4umoYb4{i^tMU|ckP31SD{W6}MvincMAT%e? zGXHT`|9UAi3h@vo1QY~&Pqz?3?`&@my~w#lS;f9MTzeAqDW+`mXz!13(Xxc-NOd>r ztVYmD%8J8%uyqa%tTVuBoM=Sh3^jxrQ;C~0sx=MtX9BjR5_pfsqRjYm^KIhHlL zWQ0IKj>PT4^fYAvq54pbRu5B@AjMN`Iy&eFyi>sl;&tm?eb2jU`g_9q@N#+ittXdP zKY#aYfBFyH{>eZ6#mk@k+y3Ou-}6uX3=#O^BHP!Sa$dszTO1g+*77=w7ce`k` z>yP~XxrYe-PMRGkQ&GKO>f80*z5kMdZl1^M-Lt!X@$vrh)gQUN`@?_eigRXMW+sr~k$O_CI#{&cFO8lJ!*7$!x7N4V6{AB!1m2_}3UWo2FLWl$dtEx(~qmSz3B zzG~uWbGiT$0+e+ZR)*;;`}Wzv7~s4RUD%;zirFGDLhR?2eGSHA!y{GUd4|MHZpupf z-a5_ei65;^Rur5&iB7NX<;cE~#+Q0FiUheO?e>bAzQZn3#(TPNaht3(DD(@NJ_pd^ zJw`Dd^CR>8uYpCEKZQG_WJ~>M$6&T2mA&!qK&p+w=$B~BlOAGuliv7^70nqi!n=8) z#>vI9_Cr~>KiVb2Cf6v^*S!elr#vIzhzV>w`L_ArS590ksW#|~8|-uYo8F6KI9*qO z3_2X%ZTKf4zPy9+!zNKD()-rhSto@f8FZEpa3isEa!!_7`=nj8lO$6B%L+Rfdb&jL zK{fLbgD%YGg8F81@`V*}Pq?IK=LZe6^Ztu6r!yskov@6DSVKw=8g*or-R0nl%O14! zu=x?(`ePDqiwYs_`g>3+nxGYl9sPt6Hne>-@emv8lh5WUUplCGJL$j8T0n zdyisF2TKw-=PI)FR!<+&f8c#gzM6X!b3EhPsw^_XTK-5l)ILV|ZSLXJ*WX8KNKgCeRozDkf;A zt8riEi0C8>F(nv^)~ee{*fDw$(v5DPs%ytq)-75l%+NW9#1((jb8-?ORWRspWzraB z>gq9NTfsYe40SXpKHBlg15a)&v$|F^#srx>U72#jc zGBR2FpGi_=D+?of+|*@J>mF;Dke=8A$(7|hcTC*hJZk~i;?_JoL}8v{>}gD z&0qMt{~MRT@Z^UtAN<(=_WaklC%G8pCO#HSstWV z>ZM6qN5cop^$*Q{vu+$b=x_(2UI74I1J&V`c>H!NaxIaXzZV=`t()7-mD$3A-fZBG z0{oZ`@YVty(#q0 zAr^GAxJN2ny1`;USNH7A<@R&$U4H8iUM|1+M{np$M&N3aCx3;rcLvrF={DR}$LEn{`P=<5v6??1kL z{;NNH{}aFa%Qye&ZYlpAw@>fB-W@6b)4%W+@BjQi@ZY()|N39X7xc~LgCB1EKl5#h z*}R_x*aoa8mu^z(t#M*#pQxCp_;Ll}EHxdb5K)O$*c>+s5Z34>uR0b#HEhfVP$we{ z2So?k1jyvWrO+w8`EK}}4m_7dv$k87{lAZDZu!L; zN(|3E!%C{9qkS~eukz&P#O1aAnaTSMNm*sGpI10KRW{_($xkX76O(Q_)md9U0hi3t zrjQ35u?ydkt&{U-E>58hX&98&*^9}}2nb6P$Mlw5%er;fzYY`0jO@+<#VShtmWvfizV=l{SM9 zqVlu3x+LJ9M#WvrOjU)JuFhE*MArOXty{@gyWTNd(rIzA;w!EaAI>Iu5Qn8?FX>y% zMcXzt0#WG&sK022qqvqI4g(wCr4%)AyrWHz)_}`b-9;O%vR8K)2ak0`$}5!`C1}LE z(;xoQSv|y{%T!f@lzM%II`;DFE>V_!m)f<|^CmK?j!Beg*Ut{Z_bXRZG{7;o$}jN) zuO;A7W384wuvyVE+{OW>V4|Rwnfy#vUZ0<|SeuTetEcM!7FZA3j2zil7bIjfW!jJv z7@ew%8M(8Z$0W1Se4EG_Cd*MEu^6>B-lNm1tDK5lXSgW%>Z#XdxFni(xd~oo2*Ui^hlCG^CkgnOv#G2+3)lu zY_ilHloDj69O)2RX*7|nmXn8AYOqD18cE?3uwtDXGKJdhn3xQ)eyEo^J1Y2|Oukh8 z3H51|bmSxd-;|xe{(JKTjQ9&(!Ug`|E6od^DN#>?Y{TPf%Ln@bBjv)lqwsiy_oz-hPp4Rr?<$$A`t^&p!F^FaPK6|LlL_ za{05D-*tQQ+y2Yi!$Q`u6hOfA`Jpt8ezxf^U1od&<0Pn?fl0k%HaLKeJ>J)^3Af5oU2u(qhl#;b#zL z0!40T7Fj*lgVF61VKqHdZfS>&c*qosiX6u_@v*BqoVlb! zchGOVzkAZ<^kH;vt0HoX+a7i|u)9|4LclTynF^aESs)~~fM&z& z435V-YD>BIDI|L0)1CZHT6rLC*)1Bz(CvF6e&_x#{nY>M=D+r*{=U1P{_2O4Fp`8>U|F^!9k==dY~B6BK7OYM{U1*26|Ka6C*2Xh+xBb2q1lDFi`?n9)T3 z%HcpbaSfg0W6&JJhbBW%f72%tOrj~J&IT=;VJ$0uo-qC;nn^g)5RXW+Y}c3iy{p6xO60-CZZ6mPImRqmkxqeBe@Jrc5ewuxS5F z_Vm!*am4!X&wrwxJdIlb`eAQPt2&(?(GPvt$`pK+ax=>ey-QRHI}%e)d7)&yhQQ<; zxA{I&(d+#B|6Nk|b7B=nv+QE7ND|u>8nzWkd>8*&vZiR{H`uEPe$P38;3&vAx zQQ+Fd5|aRph2A!h6g7KN9zUPR5wBhhN^%DE-e(P&pr=^qrg^(n>{3@i;W2l{?IZJ0 z6r@mDlnCz5^&tKL&MSnQ{Th&!bGbG8&=&@JcsW3_Fr$F<8C1NrXBP*^M?&xxE{Rk4 z!OLh7U~8f#Pd{#1pq=0gz~U?eXZEfjyP#avok)IXj3*dj_F*1z5Wfm*i}cIuZjC15 zolDUlJiU4UGl6tNYd3MX8ITmeaDV&D|LEoC|I|P7=4byGpMCZ(JpKJQ&;Gzac>lZq z=zsR+Z~Kq`(B<#?4?e$t_qRX0e=pHrbfxvxhnJgQ|N7Ss%0^XB7pYUAx)4={QArlN~ApF^5FZ(g(r zamn$6r;Dh?*7;Q%(KhgH{#l)XTR|7vps>Ug};^GTTl0|(M-G*MT`=Nmc44q zNa?rno!H5q%2~Bx*K8p!g#pn;%SN)}HStO}IUh;`=9Q4&rR|HJ71^&0{^S4P+yCAl z`75{o=#T%<-B&+!&#h2stqU{Ag!sKuQ`@v!cEvP59_jz{n|o zP_pY-$pm%dme zn9s&62Nxhiqf7qvEvg6%>}`wvC+|JGdG*ROIqojM=ZlTEzM}B9MDffMI;UPZtZBb~ z1{q%>u{K+z9FVCI%1MgkHD&K2Li0{C1qs~IB$}aEMzk=)Ary&eopwsY;ng?ZsSIC~ z&}~lP$>at{H~o%H7igoZSt~bhhsnfw#m5U&`jgdNg=ce#LMB6kJ-!|-@aeX|d(Zny zm}&XbE#{+ij}}-Oxc~V6pJ}%3OT5dsDLDYl1dMmxD~w&CAW* zCwXk4aC%)PiHJ?{=i*^a ztGLM;7=mBMr_#oGZ=-><kPlK}fI`h6i7X-r>~ijmcYptqnTz zs>q5d-pZ`2{1T5G>=MZcOrk@VF*;3u_<@8P+)!#d5u7shM(u=RvM6E{GYinf+|2|9 zWLFi*6*Q7Ie0puEBUozORTs6*pz3meHlP>YLDBESbzJ>1b>i5sT+~u#Yx4YLvN8Wwh zJ?0IZ~eoUpS^tc?Z5c+v$yxpf8_H1kNy3(pZSmc!Mor4<3D=) zgTMa=?tkm=d3O1U|IqWhFZEP*Tah0n_xF5yWec4TKj>*3-KN))%{SY5LQt`E2DrI<`s`_8#tdCV*E;2xs-|`Ux_;iy1H|iB zK(q^vBG5y~KJ1ot@n#mC<(Y@Qd(R5p$c~*zC}>t7v**)pN@xXL$iHmWkX(Mz{Ct`PXl2}88FqIg`t-^3{Ly#VFP~4qFSJea zVSY;j_h@hYCdq=|*q|xYX>=VtmN_Ze+Va5=%>jvX=2LoS&Jhzyou5tJr|I2fTZBZ? zM8#?gqRaE=zxn>|^{+ks=|A|lT>eM@DLKwUWaOR(^vTKUwmNmDEkzjo^$Y`TO)p;`M-zzXP?Xc?`pfJJ)@g{ z`=gJZ{Hee4uiyXW|HGfV`Sw5mzrVZu!e8tWhacYeLlu2Jq}_#n4&m*S&)q+}c|Xd2 zzVf{ke3MyP{=8ZsRiB$PR zymcZJH%rGW_ZCBOjzbaUzIk)%-&@g>$SQ}lF7j(`4VlXjhu5c#H8d#L;MI4 zq!^Cy=o`=t?sX2P_7m8XVg!;Oe3awABSxsJGxP5#QOAlj-CV$)I(esi-p3cF$=*I{ zHjD}}j9Gm8hdLe!aoBadUg==sJBNKHMOz8=pEdRaM)VVuy#qQXy;4@sq(w}6J5YxX zHi6HOwXnqGq31c<1hX6(wp|QN3xyu19C zuf(7oDO(wcWSg{1`m=9%>>!A)^NtiYh2uiPqH!kMP(q$C*f&*o7K}5XppOAh@(Dkv zc=mQcbe{kfgH333oCjI4LoXwssM;~Qi}2t_i65h}_8^QI#owfGPKn`=YJXDQJUqIY z&>d&jS0owb@Oo%`{Cu>)r^Etx{a(z|D)CPO_ERF)qZp4C`2VxO{Xg)(dGhvWzFvZO ze)F=+d>-WV?xWUkE*CW?tLP+=)i`CN=I5@DS4}U$HV5xIm}$GRKu)s)wQ?>u1QNf7 zfmG60GYtr7wrl<6L{~_HukZW@@%ozC-Ki;kEIU2qjndk3WL(;eC@E~kQN~i71cYw? z;?rsMbe9=wF3}`RIBN^6V&(y0Z2+cJKvS*yfdSYL|MSBIJI18NP}rFR%`PxV(s{!IAR7gC(EiC_`$ilC!F;^MA!6e(N*}K z@@GntW!o;ehnjv5cCi2ryD+TEv-5JoV>S!25alrM{l^zg9WX{?GMydGa-)_~!7_-*)rli$D70$rpa4uV4Sf+f7_i}}qPLcB&p!?1xj5+0(V2W)qUSj~0;+=b%WKj`ZWzq=isU%LFc|N8I0 z{MrBJ4_$uyKlG2>{73)$fB4yx-~NTmhwr_*`NI3%j{hQ)n$sfwG-tf}(0qAw`OeL& zc4A&!KJ0;qAO8HeZhrM&{Y#fu|F6Gx`OdHW%FU~v`<2V(YyXcnaegD6T4_x;Z!d0M zKK(71C-1cpVkOu{Rwn35Y&%TdaMCuDo0Q65$yZEldA!}`;+L*jv#(vX{5ty0YTG4X z*g`K|mxE@9f`XmGuxH%RKH%QHjJy6>ADO$%NsKiYg=KC@X%NdF#-qVHSpHzvdDn^W zrelg`9OKvF!;3bigY$KvJ`LGWl9wEOL(1p}eBvd5~|7gu3`Yqs5JMhy+F?xI7tvh|KhtHY|;@1t}>>;8IsBlRawnKa-kGGkOkQ8J7<#-B=`Jpkr zQ8R63R2yzmO1;+R_^uO|Q88c}=Q}%d8d1b%(9^{xhzItyXm5OzTMv9Xr$Q>)B#n;v zsc(N*pgx3Xf$)wEmR_GUXWZTY#>X`J;{A_(<(Dsajqz^y@m%J9k%ImqL`Z&N-#OT> z0h{z+G$GZ`_a;;f+ALVC&6$a1i;jKyj9e(8?a$!tleQej+s0rsk?cIga5nF6uV6w?$N+^vdcO(Vm`}*gaee?;V89B$d3(Xky3rFFjWOQy(zt`i zw?_+nN-eN2*L_N5dlc)@0-spmXa3qZZ~qRL&`ZIU4$a&pTp7{TjU~!YCUteX1iLyw z?kL%m63(nIn=J*E4Nq2ypiNsVKCd0_nzCFjy~tlpK3I4fP9N$RZ&ke#4llK~Zl23pI}8f4H?@S5W` zdh(^b2~zf*+_i-U??`D|B!GUbN^(9D-nFLayM{pOiu)D?L|ow#-;u-Y=vdKJzAM+! z9+8Mm_NklxE;M7m!X=u%2WdSvuvvllsufKWuH%s+LPxYfKOdxlqGq(&x89vn%vB_E z)kcUF^)5tOjHmU{ur5rJeaL|(Y{2OmE{Cl6sc4u<(ea=B{XrFSE^O5Ave_viNtgU$ zN5tR(bhyM+HAa8s7(K5xQ9vIpBAZNzDN-EGh>=2Riw})>toK2iU}wWVsx`=<(Cgn( z#FMM(MYJjAAOMQe2VC#|3$VpQhh&cyLLxB!?sbj*Y&6Xwtru)`@V^#p$-us~E}UaA z{w(fQ&slVIpl5WMbrcPmF#R<6?@z_zo;V%aJ%<;K6{G@$r!m0V`=PtL;ddfW86c?+)-+ecG zd@mtdD!=`O%iVAM!u?(Awr~C1KYsJg|Ff@ni$J>#h_}pMS?)L@JMY-_HFFeW8slR| z8#m{lYP7aE_F#Z+D({Qtk#+1EV$DEJioQH>c^MIl#ucdg&}#|bQ}S4Dg=)9Z?}bL) z!)<49FZ!E0(6bO4@!-sK+Uc{Zy<{ir%K|nMfx;ct-YB(@{^*x7>#yGY^?&Y<-v6)vxjz?a>X9*FeVNpckEgA30HiWAa9Olzdsghy29#^qcCT_rGsOu`b0V zpJFc#3HYj8n4Vl--+bnG^dklTPUL1WmSsP7S=+=4cam?Got(4rA}HZy&y9YQ=|isE zusL*SbqE$Mi(j-Cn6nt-n{n1UV2Cx&$N4F-f{Do9X;EkD2|RG|KSUN?q@_Y_-Vi^w z+^OK3(dvzU^@IDxGA=2E_(6sF_yVW{P@rOvA1`> z>!0eHl#GAdb#7_2KS`^dTI=YhNcrb?v4^;@dharUpN8j{MS_b7$s zjsb~UYFIAOX|6vvf0lyW3tAcqR_W7KlwjNc??%*~H(iNTWf#?@%*khsHT;rTm)}PMQ7dD92Tvbm#kLh2!{JI2Rni$!M@Ap zrQNGCVRcBtU{^Ug4RVBfC*vewF?sdSkFKvw)N>wjm>HN84C+CKl?VHNW!KtPK4+O3 z3w^JN)Zql%6ARXqh#IvXI(>+(+Qm-F#x|!^FbfW~?OH`mdLOwXPaL{)Zy_?B_&}M% z&?_9 zMLF*1IA#XMIBYAYJLGCy*+6j{0GSv>X()EKbkjOH*=IF9J9ms4j$L@o35!i6&^yA0 z7bd}uVWK=dN(9f;QRsw{)Ws-kMq$@q#OiqT;l&+w1wX84Da-ppCf*y%)$P9Rz`i)2 zp}IjnxnhzLW9O9{h5vC7d?qXU&t^i5eUYLle%dOn(cYwU%$m^L74O@w*LT_fa{sLH z?15|VKCa75(eQis-EOWxv&G+DY~EG1{xIr<5;RY(nr0|BAzQ5LOTF{5>i2D1&a`5* z59t`2gD2n~OuIoa4G-PFNe9hzdTA}yi{hWW(x~HF5<>`dLasG>>#%ml6Aq&Zf1@>c zq=whdLAy~Pj0zg`J^go2Tq}G(dv%NXv(FTsfrM`M>i;-pZeA7X4VuM3wrtiZ*pC$1 z%I&#+-Ju8wyFf6p6EnFsuHt%ZF@cFSn(jItzvha@N63i=XT9hbp_$V(;xUp%W@j6h zxLd>uY*Bjjr(ZGO9?i4Q{?7Hi{pfo=;Ncqux=onA{Lw=J5Bu{Gx2%$|JyDKu#z4Ct z9ldw=^!exdEyNGZb(zcP>^2NMi>Y_rVR85NlTlOi|x$<#>YPm-oPVNp__h$IH=b<8;1P?=>=XSzL- zq0bIDGG&~G6IeEZ+A~OqfG01S%!D`*Lg-C&<^6z*JJ3&kxknr|Oz+#r`fT6%x#{M? zm*0+0y6|+Y5uLb2jlq?L=d2_-k&qf!#jzthQ<<b-&rgWg26@&vrduY|Og2d7@?M2X1r}yH za_6ytuE*D-1wPdlxP9@a=f*67e5&Pq6zDf7(D65>`MWoIVp%2GSj za?~+cnP4epS;O>Su=_95l~|;MUbVU?r<9g&RSN@>(xpraA*oJPSq;zot&WGDhxIn>@xM`@i+T11kfzH2G8-04<_9UOv(wTfg!(i!Rc zdW%m6-D|xEKYkXC-5weJYj+YTF9-=8)__$)DUhCL?Zv);_-qX36|OtBV?Q)Cht#P& z-t{GZ3r$HPPLyM(AOcHJRvY05-kqroYLEYJ97Q!sYP|~X`WIAtQ`#N$PBV8T8P!wM z$!1N^Iz;K0B$4rXd>ruv2}%g(a8V#0Y8;=gjq+6TCl6ZF8-7utf<6ODMJ5X z^FKK{#PryJb-_k+qRgC75F1pAJXAsP1PQ*QQ63Uq$s+dD^9s>aT#!A4Q#M+GJG-de zI)HJ`r{Q6lc{~6f$Gd*?qP1IK4AKt8Xtn56Q3JJ6ghX%Y zMe^iq;ky~nY*%N;wY5gue3ltW5=Q^}ic!0xJ#oKP)$B6*-qf#dB*^yG(4I**Tfj0? zP#|`#L<q;(#}_#YM)vnoOuLqaevnI z6WbW2tx3Rm6xmI&XM}~%&Fwl@@PC2}Dv|p=v?%$Nhy8D#XyfF?i_86czpY;qd_S4e zl)mMrvppQ=zJI|wJZOdCDnjWLBp;Z`pw7fm~QSVYhp9LWbS{G_#kfcNH%MWYT4EN8o9k}L?xlYK$+>ASz}VO zh?%;bWuvBrU}_Z@b->`3*(#O{K+XrNnWpbt(y)~q>bgTZxd97TzGEcP#NSk7pH1&8 zfi|3zBCC!r<_B(Jxsk2|mnIFnA+)Fknf?TthuI+%Vt>7*SGrm2#_9I{+1-6#kh=Zo zjT`>o-u%SxY;Jk`_WtF^BE`O|4fUK6iCFZsQa0syBKG1A63oHbq(-juHI(!_VXMxX ziX3y*mq}t&7&AUz8)>`X}3V%6zh?(M%&}-(E^`x3*29R%JZa6t}9N< zw43D3(lwTL%LbeJOGA}elKj%0lY zdbrH`#4Jp1z<-a<(o*&ny82(VsM~3P~TqIn#ublsIcM=iv6LTZ z)?=zpxHdv5{RzpH14V)Ff##cqBkhihMn3(bzfvc3@}HF#rD?=$aMH^Y-tV`L+Eq;$V zjGL9J^B?G>=*5Z;wa{Wyf5fl)Os^>N577f*XObObHZs(%bfF%+h(F1kH+`!bN`A zhunCwlc7{})ir%*tuGhrg655fdrT%?zcBMYzl{e~a4`+L_{11y)5M6gwG#`m@F z%g3^Qt`vXKX}nF23iYdl^gUoY+@dZN)pV!RFB+DZt>&vlCs0p^v(FF&__Qyh={Yl-uo!j^(Sck8oh0N9H`BL?sahI)3uxQ;msp`RlX+yx?)&o#_Mi} z=(5EXKl%&$YXCL|hiK9`3sq%x^KLwQ?XAN}ny{9i8Q<3DnL3hhGjI_&{p@_u*_qMO z%8(9LAZB0@kssua?#_5v+!s{v)9on1iTzG`a+|s)dS;Gq8@b|W1nS|w8uG)|H(8}y z)7lt&)5O&59krW&3F58Q=_G2k@wzGb_GRCuY9O0`635Sqi31-QCz1QblziM17VW*w z1ETb)VTK>k4d9tIZ5_cygMJ;&JVX<~wFYu@7bZQpv7oRO5%DP7;h1e_qKF8V2uf3- zn>RX&^Cn(HJd)-N1Ujsv{4gJrL#y}e$f_yjx35C}`0L`A0g7J&_d>$3IF#c+YMg3I z4wH$n8f&A_ReOUp?BvZL(I0*v@=;+D31*5yn?kwO`u*^|dNZ~}PW{$*ZvG(0fv`;F z&ovYgn?JVAkhVouom9!xOALW!CA_TN3y9>LiD!VBkQ-v)zwtVCRW6#84uoSLfoK#= zm?-w&(4OJ4N&z45AgRtUbP@y;d{k;mCq7Dx+5=;XnHbduy6DJ$W|3XVn14+@@A@r6 z)5r5}RPp@d=byQ~|K_*)&A+~S^vD11zJBuI{pG{r`q_K&PA{UhjcF(lY<|PsFDkU_ z72n35*kqHaP=jY4^qt~FH%}em7O$)k`ML#InnWUDC--b)%+{Adrr6Y?lqe#L2&@0t zyk>CXbXGy8HWo2)ox}tepP4pQqG5tQfP!t{e#ul+&Od4H2C>K2qXoWCEYJl4Q^Diw z(E^`#3;Yk8{ZE4HilCHd)=g-WcIjaSn|*C(745oe?rWB%5=ob0x-@p{sI} z)DDaB#@Ckr0V+L`LD9B)K}L1g=;$johT2<5rCCa_NHDlu^&7j+O77WB*6X5RrnDO| zlPZP6SzU$*d*LW+?k_PEfc-E{Ir+VIb05}cgdf6P0K{8|6i77UAwCbGge&Kk(*q_?9{ zmYT?Gi>>hT=94_aL?ECS#uay4WYY_M#ty7v?O-K=xU6czQhjWA8Z*76gR!IQsB-wz z7fi}bHd+)Ri>YKkU2GT}p^W$`gGqsgfpTauLaQE@AJ{}4thI+gWR z^kCa`Ixqmx?f>E{Nbwj9p+h({EcE>*p+UrS`U$wfe(bwPe`D^q;(O9~4~Zo*Bs9=i7K_a0+g-s$V=2fr1iQw}{U_%oMz7 zfQ{;GWLsOUdG2A#WH49c_|A|}S_AvR&C3O&17)Y2$?1~E4hh(!-%GjGc; zdT@8m!Y5%oDzSz(G{gw&r(hg4#=--4^VQ}JGetBH7>xk*-yaf zh48aX_+^f5UQ|5vEW4wZ4^pM0QBLUXkFLNloY;37B6dL0E3Vm|JY!3P$n*+H2nSN~ zN^XKhAUG}^2(kio?AUXy>r2E>NNILzI1MCtgCVjc9&hsV7%}`CO|tM~C;Iz5e0x9B zNLG);GT7alhIsj$fuDF3@!idLzTNMcc0bJD`M0<7|60E_(PSJM`wAyks0lyG+&hF( zQ%{|lsyW=a#u}G=nuqWgrc*6}VPdFTUmu;!g*DI>&R(GCb2a{sS>)OsYsmskA_jW} zC*5E`9$23p0Zta~Jc9IC(Cb$Nu0W3;j~4jUTHx-@)9$x< z2>(y5n2&-zTEJ9W&2DZkYxoVLa{Jm`K{5xQd?2%IrmyVn)qLF*4tag4qztm|r6JS0 z*}N>bwWB$_+_d?<%EI)njF9;xvGZDaB?G7#@7(s>%3zfPY3@Bb8y!g}#MR^F#dlqV z9Fnd;Hs&P5C1BTZtA>5AuIoDVsG-i1-dUH2V(UWAtW!~oc@46WvINq))nIf@>i!{o z_8!1a2j9sHWteM_wYO-2l({gc^6apmL~}ed)TevY>&|FXpq8Uu@A7oTbGbf%Ucpdq zZnKsNWqqa6y%>DP8k4OFoR85qR;Z!*)G68(3ME|@>u){jkhODI9OElV_ZqWou?0ak zQ@J2Ek}k`{$v<@BVi~~aVfI!;@nHQ2$3;xWOxI{w7miFs+4YnHEmla5-<5XglbA-B z`&90ZtFGja*!6bRqa&H9L8`orKLbx4m7L-vy&*0R)-mvqQU>}2@WG)(+^Cq!_0IyL zr1*jzPA|<<_Ztw@1Rjx7Z==2`w(baEzi^5QpgwTi%mH)OA<2%XW()lG$F6Hb4C`h)6L+MZ zKF^fR(V`&oM1f0Kx1R0Zg5t3AwAu0-Tf2A(!aU(bDjed`cve80apE2#MeFaw0qJ&< z>}+->$+1OZbF10rkS7%=0uU;C#`po)xMGm&Eg!&oA!;X_S)I}u>sck(C^~L@5`6S^ zkqw^4-ue$hNAK>wn2RV=TqZUdYm(bYxNg4BTJsBv`S08@gJCo%9_hW(#XT0%jz*jU ziKc@X^m=;6X%lwCYl20O9WnxG44)VagwY>?+QgAy*{kz91=BgD{FpJeJ~BqUwp0dt zKERMIrv^xu>-?V*fJQdLokVg0+6Ob%7VZ@W57WbHo0`qkfM()hwsCX~f_HEFb&9@@ zV!zR^ko3f$wkiAJ$&X*Y`^uBIw_pDJ+sn7V)@b$3roxTok}-lNqwTA8cvWG>>8$Oh zoMsTs(uD;9YU3wINt8T=RbZ^k)-n}Jof`Ukt)U~IBT!$*GX~t?_2&9e~<=->4VAE~s|an?bg&5U#w1sVu=|(3FC={w z^56FssEBAuoEb{B@_ctlDzuf`P581v|C_j_Gp`kFk|~rDiS$u6?O(|Xg-WC#13OvB zN4Vw2aaoEw+(!OP`%#M?P?hR(vZ9M~O~>c z${~;w3m)2pOwwgGLX;=%#wRqrCh#DbLm`M`%p$wZgQ&DDaSk;qR$4KYb~_84a05{H zB9G~)1m^ivge#H8u45&|`gb9Tbh81xQ=XBmJh^K#tLs}drCLlPdQ4Kb)gNN*rOajF z^`-Q|StiFiN!e(hvlij&fILm}Xp)g#alkc40r}p_mUsP z5R>*7+tEQX%KSYyvF){_9q0M2qg`T8HMVUCkr{=_MdKq5 z!A^e|NFV8RW}{*>o*IvRsCj~ZcdaA7>b0n0O`>W- zb^#54uemNIqH}g6=(%!z*6MgG+~l;_-yH!qO8U*dPhJT2-}8XMxN1LHJH7FPfC2im zx@Jdm4AvHGz}pmPhx184o7e-lE-hl)TQ#@G9-6m#Y`^=Rv{c1=M$$h2mFda}62>V@D z5@HU7ZT8Y#?BEB(mG%hrW=QR3{d)2f)4B_0IqJk%b7P9e~*bA_b&D?R|1C%pj=P;8$f)8`HflpWS z7kthjUT+JBV~yJNoAW?r3*vO-_i$K(I9Gl&!Wq2r;e>w#at9=e$ivyZyNQl)QLbzL z;kV9d(VU3|u_@Vhn*&tVK-m51b->}tMZW$?y4TH~kg^_3Ja`l{Qbk`B#!FieI3A5` zW+rliOz`72R80Npg$X>|DZtV-20S^AwxhKO6%Y`-hAPKi^&vzA78AkDgA5b~{g*LM z1VlbK+;n@_zt?U1Wum_GTFfis7YVdG7ApObKB(fS2y(*y3&nS@Z{GAfl?Kq0o99Do zZ|d&l8@oLp-~92PAphI@Z+xfOgn`8lVn@tzzIr_&Ok?=;-roE9MTJQ7f9>ZeOEN3+ z32Qc+FOI&O+(!MuZWA0r>LIcNpE&iO2etQgz4gUwj4(sv&^gE4b7ZWyKArrLoA#=A zhB=H50e-2Ql>Fx^{UPe3l>dIWz{?Nr-?Srrf8&>}p7wKm zuU=YOUM}x-nZJiIc}|j_V_(iJi&$(_eaLn-HJ@)HmnS=LQNmVtKqVcEyR*32Zoty! zuuk&u8@TdGd2snDo)QSEr!J^#R|VGYxIA}FOf?05lG*W~bgeX4G1ymdc6&VrTPVwb z$c)is^;?HBu$m`2&?n3kmApz;whpQyLX^hKvmGS0T<@_`Jwy8LzHjXHZ2WikZz`6{ zS%JBcuc|e?FTMAf%<-nLIR=?X3bkEEC~?LeU4=xMZaWK#-X%co`hXq9%c0v>Vj^}_r=u7s|25IW2hTSVowHbSUYki9`QIRIqmY)1+ zmuZ4;q0|EVTD{hORu*J1+VkV|C$)usf7W+3PO+P3)*qCN*R6-&MYvNc?R!clyThu$ ztOGoGec#{SC|W`^em75_waGDBt5My)n_-$a^VB|r-BokE(Fk=Lep#diWxszecCDT3 z&>0!~W{ks3j;s8iWD|hw)4D(LfrM`(;CX_R6Wuwla>ErsWV%COyD(k4ZyeuRm>*ac z<@+{c&}RII8h@KLt-I6FETVn-gQ^{xIq|`b^M7v}Y51oH)wyg^Uft7^2sg(CQJhh@ zIH3=^1uYXpz@MQy?7SB0+RTWs%YK}fhG+F+k0l+RrswuM?5)^=+B%Er*oNWK>lk+G zaCnx!f4Z*^bQ~K_SXu(%d@+3Fv0-IpyZFy3EFvC9nLOG4j8rmT?UnuP3!gX@Y=nT* zKj@(>G<&9o*K(SM|Gy}>$kuhVuwVf?#j2931CKxCf*EzfMt8Y*wr@5lz2n>d& z&iJe~Mx%GgXc#Y{MFyC=Hcb4w){h%{tXA_L9kL{1eyBD1SYvY(`8r2z*%EK4ZVSlw z=3ANW$?lYScX_XG=ZM_h&CsthY%d@<7BCiN+sr@=R!XMcP30UIZS!x(CZ6>Y`U<`f z+cSQ&K_utZZ24g&Oqh*oeJnlHz~~M`hD5jiV~V95ka-b!H$0WaJ3N005_Xp=cp=ww zM7Q^6CGNg^`{w@R*B{ni^WCeajF&fWvqKm9Z@>OcBl-6B5C7hNiQ*UET|Ro9AB@Qm zVdIG-+4_3d?A`hJ#GJs4CTC=cV$L86qd02#bTt3g+40}HYw(pd=JqaTmtVr$MFx4`u4iS26TanCv%B<( z@2E-~Cz+IsYmZ)~LTpRjLsET^FtE;j_7@f&5|7nwbw`Y3^D?4@m!4Z#(a#DeUiG=F zI>hP}TFGBBZuELAz>+TRHAwufk0o>0XVeD`b&oZQX+=%qs;>I+zh?@Z`+i$$_OOD8 zLlVR#PIVCxN!G8?G*aW>Ork+0I7 z)7RQER)7C=ZDAkW(C0Cthg~OX)57s-P=O-0+CHES7orGm;YJVfd z=25MS6S95so$J_Vgq+<&*5QS3QR%90`dSdrSo2Bti)z7L1W!@+8@GJn85e{DNg=$C zQ#*=3xbr_gt;Wa@zowrlq^nornDA^a3XEDOJ1@r9XrxA3VZ^B5vS23`SKSrhPMwe@ zXE=5OCL3Gwm~PTwmQ2~l%GMmzqw3fVf6~gZ7vrI3pcN2JET@alY0*1bh4GAL!F^LmK! z!*k=CFJ{E}28uVd(-xd~wBVl$qO`?A{T7b;V#NtpyV#nFK=A#;oCH_jcZkCpW==VR zm#ji2V%?({B*e*{d0{~>;X=)$YfIU(MAsgoL3#R zfaT}>lkoMMCi*WH$hOJ8`F#~b9(nWTqpmr;?LHwE3iSE&_8M>R-?mTp^1B~iJ{#y) z|G?$$<*&Dg)K9u#8{v$O_e31YVv1=dM;%0;zs)BnpkrPC<{*5?LVE_v1hW|lNE_Kc z`ZHjHyLubH^VbIglDuhzt#qNzP-sV^&nB0h3seVd3NMW0XQKHRQ$gz!!CohPCf|t> zmgYz>{!%;IH$N5A$Je6;KIInhD*z1wrS0SE(E^`-3vA}~drQ)%Bv3J`u(~fVn~+Oa zt>av3Gi6^Bbd!181_5@VyLT&}l4709wVSv%<)1mcPQ~Yvw#@|TMxCSj-X(yzlqd`v zsYp&Lt8B?`@^3(O6=g{k^69gX$IaTiJh3I4E;%i-9eM?#76_y?qj=_z-b$9fRi8xZ zoC>2GO#cS5%U-GIGXBYD87NZgjG)Rc;!$3vx~&jUH1%a7BGa&K&2AMvh zWwVuA&T3SMlIpWF!Y}SXqdaLb%iJ?bv^~3GY-)|t(Uj?0V9|j0^(0qZFs{`{wn6aP zuQ3j@PEd)G4;F&q)|qZczhlwZD0BIV{-N$`8r{~Naj-&|JkwtUoXUq_bc}Z&0gOk- zj;?ro$9mc4DuaSMfocwnDM3tu00fI_N-XuaGLgVhQ(}%0tZ*&FY8m|&AJ-~(X2)QW zGgf>?IIxV{KLYVse)L6|b?M=MR<93_L?z0CVUo5gpUPJW>YKa-2i;8AfpN^%{WJVpR-h&1!)?_(-8zh$U6VSZlim-tZ`j#R z?;>WOy_xU=LnGexaXj1XRm8P0JyO_RqKlmy^L8kWz2O{+p@Agf1~GeQhi#5@SQ{a` zq%905sMz0pfPd})6wb^rGiQGDbX1b8{zX-X(7`4svWCruV${KKD{%Uc>n1x#Xt;%< zc6OjS$`Ryw(bZ>t1nHDx)lG_fZ#S%_<&X(DT zu=u;X`Ha?uc&Guj4;6Bfpp-IsV2`PxI~uf@wK@rG{^#%wU=TAv@&$K79%gY6uN3wj z`rX)qaj+KI0y%k@QL6?D>aU$+(zRV@cib>{JyfrcAA*T@A3dqhCw*tih=)4c&r383 zxMRX1V|+m0`?O=>36`i0j^2Wr@q)v!b`BQxN55grqM!&(F&5VyUDNPV9u~+ zGV$j^aKP!!#>TM`u~=ySh#DR2T@hw~=)kI* zCdb&?lOi8!F+YWG>FNd{Q?X!_8;`HaSLU0?X8%mrPc6Us=yLbw7kWt<9O)GRn1MvsZBJv^NCEf#cd3Hr&&HVn+kQ zr)}DCXup;bY$i63g=Q<#T+6?HACysL3+x_Wj~4hou)upB0%)v#AISPB;-dwc*ZaCk zl~Z?GNp;eN%b{7O75;K+B~o*8*Xw%)a!oQWg7vTPCOU0|L^#KEPQJXvs3h&Uc%eWvCJeiTT0lwY zHFs-CKB81j`RACKNawmAHh87i5iFpCU4V>#j^W_md*)nnTJdI9!HJ;G(BgZF)vMf$9Wk$} zW0dPSJ|t6-QyT%28EZ;F@juc#)(obP6=(`Pk!%EjiOvE$%(?G5l~4=GkTG^xF!Ku= z+IRe ztwW5-wC{WnqW|=d7h9@H4`6*>M@4~6eIA127-A@HTxX$krGDW0sMF^jRM+Pam=2dS zc{ByDWB%hxBF=`zRWkINwsTQ3PeSo-N)1wxk5nd2p>VQhum)5D_TN!-2NSUv%;%tb zneY=RA;s_3RmOgdj$1H@QoxpB^EE%7_z{1dq&9qX7(B{UI}A+sq$11V=(`siui~So zA=fFa7Zse1o;QKg-lO@kt<#Z)#D2ln(Bo@oObp_l574FR(Y1Tx0e@9 zW#9PK*KNPu-Td+Y&Cku&P3&PbZ*yc~1Vq$b%XM1RYvb%r0= zeVF+}kvl8A`lz~4(_M2>!Ag{s zVu405F#?d9RJ`FHQJr?L0WDH+CCIug@PaMNCt1hoQiJ;gWMRq8V(^sFQGW6PL%n@{ z^R{B~>3)vP9RRDURWeY+&Fu7}Pxo8*zBAdfW2@WFrO~=)5QbWLakUz2S0~soUkgW2 z@2U-Yqz&b|T`)(;$`q*YFsO_OjP(*GX`oqAx}_t^cYTqcsdzHX^R9aL3M*0jrbq8{ z7>NE60f(NN)N!RCVoH^`FC;qEPx6EgeR%jx`t_10nAB1u9{#=87Ti{#;w=J zBZAR`88^3WJEPIsd@!c!M%XVxS5>N&(N@6%89txp8PsUXO7!cj=E=V054%CKOe4mu z`8MeaP-|b^G+AM$pZ#lJYj4Svg6M4=0SzLg{^DJySJ*YN5n~9+StuL;qv)-_p5$?Y zVG$7lR`ym@TjU<^)eyTdqK#Lac33;E;*Mi*w&x+Hwdw61k{yZvzBDQ>BTQ4I*mD!c zzK?Fag*{iZT~LQwSct@f+bP01`{B=)!_!Zw2@hZ zzKB>}AO8+}8B{SLt0{|bPs|Y&qyQ2>4jkV^+Kzm>oO3j=!*VnQ+2}U!?Zc`?%JGZvsWgoWYQFnz_oY^KSQ_h0tDa z`tb*Y98FkcFXMa13s@VK21mAtH13@V$=c$tVVv?+iTUpB%lp@FKBP{$p}WNTIXj{M z-ixPqZ=3Jmym@#3_T}rlzxTJkzI^4YukXM0*Lht^kE`2>SUnsuOTtDQ9cV$@rmi;9 zHj+ewd4;e&D=D}mb+FgG7Trl4jWl5{7GS*oi)$tx-bGf z7Nevbo&q@3H~G-csw#>>cb=H518T7_W%cSIDjfPVcNDBGI+l5Ho_w8e0Z~KB!4f6a zpVbEm0Z0XuxRC7MpW0ydu7P{&2|8P`Gt%^!KIwGs26Zt(m5+CJ3f8qD6d^be5Xb+k zZB*Ypfqx((R>|oMYwlvHc-K;_a#iHDk9(?r5?1qQoR57YCve6ywha!j@u<9wRs`$Z zIZb+oyPhI zVd0M`*Bx4mhEBo)TP;sDDjCY4&Nv)~j-$tDPD8Q`dqq5=PKyKX(MPz0W*8!Ud_@1~ zHzizOYDcnhfrrmgF;DN6=4`TlBsqwOcHHU0d6r_W+Fcd1>4@PKOQ4HNWP%BmfLS6@ zi=XJ3LBJD|<@G0W2z5;!!mv4%^ji03q_BYZKI^MxLh)wOxSw{r1UHpOZ(0-qp1x!PXxEVg%Xy=AL!1NzM-VRP= zM9WlFEpSI*f&u@C|!%6ikFffpHus zT=MiL3OYYBg?eJbFfHDR?(+(tn#1b6qZrtyz(m-+WsJe2SoAUsbTjnHqvs=D0%&## zrodunz{f~%I^F$6N%4vPqgp2$HVEPzl=|(Qj!!m!KqtgJ zGnr_Lc|dj@=x@FxnE%EV%j!>i&|ES_h~J*$Vy7eC+r~HvVfMb?oDD8k=b7?nS5O3a z<*@0B6P;(xs4ppn);3gxhaVzHAxdi=;x2dAOK?{^sTK_0GR*m?upB5!fVu*A)@+5OA^zv~;i$HA!a~ z8sgvNnm$xYf<6Q7@7&FiX0@}bs0m}Xn0X@4miW!T2F}B41I_7ye`rSOcm=}*a_)8P z9YX&FJ2jvev->3C4hDM&FIJOEAg5=0zO|F42_&(yGX{^ZM+a6M^0;P8&fx)@M8|kHbW*K5P5P4s}Uq$+)XF zv5lqYt6T0_@Wm)#wF40)%6O3&&)V$&FW-;?i+ZH4KBHAnIw&;AwHm?7|6F&eNp?a| zT75&)USE2*4S;@L;m!RA&p)5qdzx`Dy*z-Z4?FfD_Sk@NMB>;YD7jJ*D}gh+gilk+ zdnUGE#E};?4wPAHn+oJzW0v!&EU5;;c$`+Qbfn(arx~?ug2mj?L9I*0h-A1iio;pb z0wY@ZbyQBVqcVDk(}4@s2ge1v6DE;RLO26x+H3A`l7Ehm`VbP#i*uUJEiHp_e(+kK z6boG~dNyH{VN4X>27~z~o0!N3n+To@q))NhPez>wFw)hPwSToY7VKHw4 zsmZ`-3DB}`z)?zxIy6Has~~8QmBmcd*6z4g|3)%4Q!OI|bj&%S&W%9 z^3EY>WaEc_BRr1v&v1pUd&mW8A4Z-SGEM{t0@kyOtB2^yPctscf=IhnC)FckH>Z}F zres#@=zt?}eP{--pcRu%e=I@ZI*+8}06*POS2Nr0%h=V||gH&koP zTCmyQISQqc@E2p})`EIDVh|kxjy{o{)V`bV!r^njfG}Z7DY52~5kJ^OEY{kfKcOCP zcs6x-D|!JiTF1RQMC<%(z;I&1gd0u3y^K}DrjUPH%dpuW!A{H-fj{_vuyqKGn#bn& zKRJ4JY+X*=aOd!27&3J?&O)sg!5?-!;@aVL&JcLRHS2U_hcviNv+SM0$zD@#%k?2A zq&p3Y!Rbx)be@6)6#B+Y$6jb+pGLj+FZ`LKz5{c{N&vYDFkj~Hw=Mo(zKqGcyJxNS z%~WsSzP*3%>GS*d-+M3IoZWo!z3+W=`SBlkck{cy`sVI?ze0e>^Z>xSo*Beo#5KOD zkT8Vcyd!bP9q6ke6P-=Afrx%yAh+kxnk$hQJ+cOG7-Au28`PT)v>sgpn!TOkFr+@N z#{%*E^htfO`X|}+7&!5at#pr1Vi0sxXfKs=DQ~lLP}QbCi1(q4i6WV-(&h(77QJ(Y*7(DdGf_s7EgeOsVdzU5#H zPWJ88U7e^hK@5-~UdroGu#rK*6&Lm8Buz;D1lsbDSnJVFT3OU9hz=_F=+)Pdl|AR* zI8u$Z@poC31E@_3FZF5Ua#eQRm#7IF_~>`A$O!sQIbiJwo_fp>D2XYwH(@aHm;&UZ z6FS(&4ETe55!e}GT9YP>h*jxWH3!QD>!f=vz3u$L7DS-Up%Vs(x%g`G;di`h2<^!_ z_(PeDVs3Kt&SBH1?|W;&V_ee3F^F1vn@qFE5%1K>SgL1d3Lz2P&?Z{wc)5}liH)Qq zP3L##ad+tRFJ)$%xR2M$?U0XdXzpqf`Em?t9;|~4kK1Lt!rK@*y96u7atHmOh@k$b zXykUj(~+pbXFw40e|#7`o~P^$KE`-cb4PU-wzZM&SPX34c@@D3ShrkHvpo&S;whAY znUfX*J}|$^8rd{zJZJ;gSm%bTE6(LH94BJU%yE5pRJ13O5#n$4G{+u@Ua z>EReInu(j8^0z*3^=zD9U6LG81h{|rFXXUD|MVe|WO(SC-#R0%MKpoCUjm6^e6vfN z7l&qu4oJVTj7|OgNbg0-`Jj1of<82gkqkxEc zw*hoce#CesneTj)=4yu;8HE%D8S=*#bG*&;SkCUyXF&&`sa8t@o;CksqcOlS{LSMb z_}k>PnI@zJ(pcb~-df&f%xKjdS(0{?={>8oa~j2QmyL5}^0AGMJ_cLwCsU68oXBQ+ zdpPgpaY|~fvvQ}+-zJObW0w{}HQG!zdcASo*rT?<2Ohsfn#AahW@m*$-}bwC^X{X| z%U9ox#v3D`|2+h-r~kkA{ORrIKKD6$0+(0)cJROZYcC6pmp6ark9_^^@{PBbU;oDK z?RziiOuYwFL^t}CVhNw0l3|wJg8t+Va@Z7GC=jM|`yfZ*oVp3o-D?cHS47OG3l50f zLaB~14yLyvL$n?JZBE&ki|Ew>SulJ!hgY9m5=>Z8TP{JnCe8%U(BbzN-QqIHE508L zeV5cy!m}X!q|$b)q8?gvPw$VfM+oGJ>D+6T$dAHx4E>W7foBK;BtwvpT)>3s~fi2+%T&}2fR~Jn1kylVGSL3uS zB}*LAGNT29nyO52*vZ@ytrp3mZ;M{AH`^tB?Xy@-2aqVp3VkJh?n8e|>cOareAF zzxhpQy!V;QyLYcIPhY$r)_G#$Xt3cp*32$wNXSc>Wy?^v5~_Ou^=GT#WPdqVajM5Z z?fpxBAQKRNM5vV1wWmgSktq)oUFu&>ruhM8Bt>n;(2rIoxDrXtEGCaHcaLkZF<1_l z+rxp~k2MU2WGp96l6a!^rliIpNUx(SQy=VumH(3^)|gcgM7v!Mh&5m$ARKCn2HTvd zIw3>&MGGBq4vtNCI6Mtd6s5Dve5>3Eu!bP7BGQ@p$bEdR>(n-e#LCkZoNShjwrHr1 zVgIpypnu3(sCm)+(B`E6_cxsgiTRq*|NpV~FTb|0dw$G=dO9NYKCmS@v<{ za=GlP^11JQ&fa_Nb(?F>$@}&Bjde=mFW5&l$C_)5-|u<8_wV<4zTfBh{l;%N2NAvG zVjFG8BF(mh_QM8)BvSx}1|o}p{%n{~4>FG&qbaJ5eZ9!IRhy;&u|*kcd?&&R`Odda zJbLHLK|u}29!{9lyzjQzcb->OY8$DIJ^)jzWqpVp?!C{nn*i)Vki|Zo>gj)~ke_x& zKRFs7y$L}pI6sqjT)58vTor-CV*xa<;2FYOBGoba%`@c2K}>kDF4{+&$MdogaOjsE z^po*oTcD9myK5HRYm{bxd=9#EO~>})Qh@4HY#;fTa@k_-M>xStugkYi)a8dwL{WWW zxp;Ae+QyG_#B1#^dgzk)@|7QO&Wx|)D4Rj(yom@Va_{_0=W)^+&IPpb)p5e+TeHDM z>y4_>ozrPkSN{euE;~a1(3fq^1Jv>L8Tv=@NIOTw;$MF~9Sm&_$6Guc^05b7SYR3# z5gAw9L)~n335TWF11w4$e-UD3DZaregL z(T-+<`x?*2N5{s;=7He&)YxPic@^w73L4PUHyU8Ygu5_!j-a7UU#f?kDWmW5vWZ}F zhJDC>U>t)Y)WbgBG3DbpQeY0f7g1h zA27lW-7H9Mg6?;SD${EaH^vBkIHhcz!nH3TBqK1j^~ry}D8mPMqknum=k5K?>*uGp zuU|wC@8Iiu={!?>_`$a}FP(1QoL;{C^7QuQk52#H-+q1n>%aE$>a#z%JHI|Z?y#xpV;7iK|QhAfTn#GxyrQ5>-X>f>c9MOQI!cFUf*a7yt;Yf3q1S1k8iY~55hfIfCZY5Ka*Xq`Vyhp zfAg~~YT56JrG`re(vK`;*)$|)&DN#FOJd)J!z1G#$-@j^F(q3s2`!mf7MS_bkknTc z;JRE=ZZ8u^QBC~Omq$7(+sZRbVZ)Z|foV&i^(^s`4UW9jM}-50rW6g70A-z{$!>-w zsf)Jt?JW?gHK2n-G~QK?ngC%RmJ)tt2XRq;e)qZ*dOrQr*S{Ft+q*B{zUV6e-3@T{ zB-q`>7Bve+@`o{cXrM_F>bv3`I^nfD4lYeoB$N^{N)^mC5AjfI6hPI-7$M4-ik@-6 z%Zo`mtWKPs?q-*FTRW{lfS<6rthV50B>XY!bH(1V+DZwuM>HbA2E4_|*rY}J^cBd- zgD0~Q7R?LgPAyc)hF_)%=JxXa?kSp~L#bDw?Jz!k6;8}%`Q*Z%-2xg<0Yv_H;;noX zUigD49;4Z3n9luU;cRhSrPLc=&5x=?IuY=P1|8I~1^^Zu(~7M8Gh8H-9)0q=ikird zqt%DbzzN?UX1%t^VIYFDXTvbus@yQ9jD-hmC$q=mWak43THRH5%lg%uC0M5 z`hlO*8T?|{L9Tk31HE5&$06JqHF~7Ev3ekA8Dwx300@gb=YR(?d$+IM~v|Jf4&N`eK0@It@ zXtzh8#Q1Q)u@e$zuhp#+0O3U%o|x>zTMK~o{LIY>anO$&GMyU+Ya>6O2eGjb%c2_0 z#U%RD626`b4SM5ewK-CxnLJI7VtT zvM*2AHe$As1gACOL?n&08;2G=@wouhxA;3`t#5XisgiY2#Hc}xBy`*glaU<7{MTo& z!i}wUl6ibGky@s!#4fz zz4LS$A-3-dP@+R1RR3;(dCxEcTG@zG)1KuJrTckpC2O&&2kK{un2 zB*IIt!S^Xth?kk7!FC;OB*xq*bi=<^e9J&?gfG@#Yv{cHgX7JP>6bQ>i|$$bh+py{ zAuiB~pAVz_a*mpxbG*#L`T!8@u!BP~Y@(#X+tElyBp~Jk7-{&9oe6i{pdTB1h(FW9{B~%{_Mp>a3wnqw<8^F`JW`aQ${Xw{ zv7r(HzU$x2Qh)7^lOebf*^Y2WV0Lvt#CI-k`rMo|=b{-4&A5>V+?>^?K|BUN`)@cY zF3c>mF0@X)d_^Gm+oNa*INB&nf`9e@wgTz^e0YDbz&FzZ&#rCZU8?BO|nhD%M@2{YJJ^P09=*1?vju!Y+Jc`-ZJdKt|aPYQ?$vv)w@M&Nn%N$ zT;DjTnhM0%-j{-rDFN)_vdpUbtv}MsAS+5r>>;A8evu;LTk%?vva-(hygdn-Mn+m* zkqi|^ODt|7lQq^@x-4ZKSxrrVB`IZDYW1%=?l?eBQkBwDNd-E(k`}BrPoTq4gfAf{ z0TLGORc=B4`A44;=IYD0U!AX?yw@`p-#@+WZUDLmm?0&?QS=s$Vy1s3cZl^;D|1AD zhN}=*MwK(WR~s3tud2*6i_%_%MdfF5w)R%eCKZyX#vlXO)=|VoCbQ+bde}lipzW{` zp41OG*X9ER)Fy4nNX@epUr)lHNs$0{B|P;~HOjU6yajqE%ALrjZk*cM1up>kj-<3)}=s#;l(&Pa7Ug5o#$X;Ld< z=E)frRIGc%86AOKAP`b98JD~wpkwh+3(F3dvGrL#vpEq8#3FOkL+Gd;JLya8MBS=nLr;G z^3@b0*49w*?~oPBeIjFe0cu8GoP@|%v{)oWo1DX&495mYsCyYCq6GhqPl83DyNwz# znS7~7f8j}~K92kbg&)KWUb!?zJu$SfFs6Xd1A&;0sFWDEF4BiOs*GxI`L+X!(>3bm z&%;XbTD0^Df7*bvW7B90U#P7QsM`m__kWFt-mz$Y;?6pftnd$hz7GA=Y)%APu!gSz-EO>5oSrDe(aBjm>eVO{{C%u zFuZ#8)&1L>SM@Va>{9FP?e4BRfAqwFp{{Qgbxx4x> z+~5CRSGv3UpN;~BH`sHcW)wfpOb%Ur1e&(Y#E@*$$7}&Ekc+F$$<-tB+nl*b%Z^dm zRd;;2+4LPpL_`PTBmjq+7xvzYDM)KZlu;Si!A1wL34urCb{tGV#oy=<90E%we*7== zQ0?zftk~Ma0?3jflWLn0p+3AGEbtAtz}rSydEVCB!|TBU-+T-7MCCsqR~=3L z(zY^Ct|&R#Bq%?Jrai0?EXzqOazh!e9tkzTj%~}wzlZ9Rg1Cx<{;6S&*flb~4qwOXv|GVE^g5ou@-Y zW(cg0h}3&%P4UyuSN*oLi$3W_{?7Zui11o^ zDa*zmLN6ALCH#7iliA83if9OK7L0#+h>S0>(?KIf!p)q^f zB&r(oq6;3hqggL>v2+P2u@BX2t|?SL2%>d*(1&4wa8IGT2`6F<*aAWP5{D@|5HIz0 z<z?*abI|hw(FbIt@u^ZpvhD6zMJNRNNBF%9j9)qQ0LtzAs+h>{2B#LR0?wmKpiT~X(}>NSOy^`XlM_7B%Jl9-dnpQy=2kOiAXA^GNr+_|e+9q$34)W^ zj6TNMCO;>xIETa*yT^Y%I_8QaE+XD-DWT@kgp0>r*YbDO|ILePr<)ohaM1rfK3Z1s(NI~MNfQ71>nMCV z&P(m0&3gibA+eN9LJb6z$|k4|z?Z>W!tCON#1>>twlum;x3ogz_(!sY;zW%R@#Uy0 zq5#)Fuy}w9Zh!~SB-#2#Mq`DosU!O2?)v_Phx(tNpFci*bpLUM&C6G>12~^Q`tai_ zrM~MJB`Geg)niiv6QxJVd^HujCDY6b#$shM&k8d>&Q=MOnGkeI)e3RfIK!eUA~_>g z{4vuW_uiq;e3(S}9Tg9KN~&D$rTXz&|0Dvqf7q)`dWnMQGwBu|5U`qOaHy~e&#Cqm zIi2I=tAFg?f0m98G1GOr)DfCy7I$_?8n8tUv7mI6m^K&inO1_qVb5Y+z>$S)Tf@Kz zHV3pKiEc49YPIJPE${TFSg={|gW2E^ zonewLS@h(ixOHgxjj6RGXtMFdaWo^jAZrsK&#@@t!~+4df5t4^)o}DF%93Riqpc%# z4$_Xr$0+slYDsb-#XhNNe6&{t8CYy17f5>0uj37R%Z)qxEMX7_ePeYt;=1wETuU%6 z>sf)O^8?<^BI&m04sM}ae^KjwPB zeRK-L@3v-S+PF^w@RT)8+gkI#K|2}elc%h|L-5>|Lh`h0y~&hPm@*XOSMySQ@y z!=I+jXV1@n=@(yJ|FwVPr?+4J{_Xi6e{p?!*7x+LOt^fTog!o_BChsVmN!y-f zEhZ^KWcS2OzI;K#WT!)a^Pr~1kl3#UOfP|bfnb(*?Ln-UDM2w*ueDy{MvTpV2PHl~ zT8NBlpT_{HeTYPvESv1~B`vZCA<`E}YcY~aqL0cV{9(>N24G5wsE5~s1%5s(;6mub z_f>vAeD$YZ%JRrhB$WO9jC^UY`KAKpRUzDDeD!FTLd@8KnScn5M+OMNsadT)$@_Eb%o z9;jsv*xt#j`=?JoI{(V~+o#jbkE^wAPk-k9U&?6JwB6U3Y^AkU6q2`ui6?`))JJa_ zdji#e5tT7lrl%LBV&h2Ng46-txjytTNVvnq9O3V%H3OQ+E6PGY_`j>+MzFVxU>C;e zsHePQDmsJL;hftOHg2`E1)-2i?l%y=s*x+6!*1HOMyTsgza%XxKCeou&7-Zbpl2WuS4HHokxe&cIBS2*Iz6C zu`#Cn6aV=-g-9|>&J5f(9>pqp>!rcFj=Iw*GUFZ59c*we&cd;n+g%q5(*=x&dmV7o zZHiZ@9^?C+>r$Yt7&r;#yX7-LBFrM25VkEqm2R_{qS3EEAF^QlH`&B0DN>DZ4~FCu zRnxh}dvCEUwoV)OuZn20NR~5Igaz89T>yBOU3!~6RceH~iJh!uYUOa{LAmXJpCvC!H-V&uirlU2j6>idhfBx+HDP4 zFAt@`!lf^v(TcP-j|iRA9i(%t(hydS1-tQj*r*Wgfcm`1VAH#}L}Ksy4bOw(g>JOB zmzAJ1Z^YqFf}|jnW1VY@G~sNKM0tHihcKOgBAX@?Y9@~O<}H$q<Mwlw zK1TM4?HJh)cy#r393Nf}7Wl?nzzt57-Va{^_{MwcPq~22(57)$F28W-TFZ8um#s@N zw<@Qgu6tVox#WlDd0D0=^^O@`@|U=LwtSW4qxG{K(mWpOGD%4;h{~H~j53wP9Q{zr z$JU%KxJW;VbX?_-0r?Du?U)f8pt$?b`sK z-+$R}0)O=3FH~$lvN=#HY||su6{X3d(!FG4dUQ59Wbs&zL2nAI-MDNwqjOpOD0vCN zL_W6~=`*PuW=2&Gvm|Zbp(eaC13?Mgt6L_f$PB{%E?%W`-(Y0SY^+hV9GI-9!gj@8 zYtaAzKmbWZK~%E@qw=mpun_j2I5j61tQb&hu-NRMV7V;oboF<<1z3>uFXZE4Ul--5 zDSG;L3K3F@8h!Y*c0qt&TgQ&#;UJ>a=s#)HaSoMv$7fy45oNj{36@g6U#oY#VsLN_ zbSxKhHKJFKc<7TD!|8%F`V%4$VqEOs*~QI4gjOG00CKWFZg5(W<$vvwjX{l+pe@K@ z6Bcg&@wRBn3@)}AN&P${tB{L=qp!tDp5CB9jX^e^^aackF{=PO$j&0X+$y@>gs4!b zaER)=soh%;u78q}EW6Vp;SLFg$48wq6-JYY?7?Fd0mi^XU=J=C+}WZlfXxqv$-ymf z_uHm5r4O-bgk*HH?Xk(hc6HNr!lf@8*FAZN+)>BtPmYAgWgRhqJprPJ&>9T?PFzGJ zFtH;#`iJkq+0cooSA8yRHzorGzDX%lKWca!8LvqOAA?YUq;SStGstgdU zO4+O@|0k;$R2Al&sm{bV@3dHKcNo7X=* zzrKBy7Fp7hi;O*IZ{ObE_JF@9J?QU~k3KnFU%hvC(|D4zUp{|%{^<`tyZhxQZ|?v0 z|NP6-M=xKV{_fvPRkHh^R%SNqhDgP8_cicMx_a~1q@LpQYTmO6IjrS_B$zob_<<|o z+CnnXl%jhJJ{lSL5M6LEe`Ea|G3Oq=uTAr-=+5PqX=XWnOp8nMjez#ia&H0aB|K$Fo_ddOU`sCBAQ@0I9jx|@c zK@BvulyIqo-bANgB};tk9C+HYZ<4K!LZOpM!a}nOqtPWSn(Gdy1{Bm4voOSty!T&K zm`oiEma1;Eufz(N6H`5hevvs!1_#+Npw)6-vXt6 zM!#W!(<>zepA(ttE9sQVP+LSV)gTN9IcQRrV}x`Z^-k#$Ab(Hsbvgg}>RM2=Ez z#ZjN*(;jLu(3tYoC5Vn0?j3Maxf6l{dMQf@E?zRShMw(4&ok-eR>m1W`|uQPqC~v z?WsF4z%n^r#m9_`Nx~_-K`nR=@x1e6pTuXFJ~Rqr7Sh(s^rXmd9Mxeq)P*iy4YYtg zv~sWdD4Kl&A_^T2>Nsi8MZU#VVs#txu!wr>-d$#Ia0MXsjv0oHYr_&rr=l=?53&uTB&F$s3KhvhGroAPXM zAkPkDAkrAu7}fp`;b(@%==2@GdX9(igt6Di$_)fIUcLDI{Oaq^`t`i8&bP0=Zc2NT zcyEHG1Afzdd(rO1=W?z*^0W!;m^18Edv*$)4h`Byy1O~w-Si~>SI^IHUw_$C{J%K8 z`uZoQH$Bwv=FOL<*RMM7_)Rc(E$P)qNfpKZ(Wec9n>YKe`uUS@ee3FzPg?vxeR|)Q z15&J8D}VCl>dPPh`0m?J9$)>f|Jn1aPhNd_|DC`4`2P8ut8-WX4F{nuh-U4+4Umko z%w1o8&$uZFVl44wBHz4dM`)TCJ+@(mkvL+BBkrn7+G#o5Ui)Pyk#nMfPH(YCizbbU z-ZW(_&WxZ9ki&{3JdiMOe2gXHA9L?G*P-2n3aK=^- zObqZSat!dkfR+iq0`c&Au)sIo0=HM!j~m37O#O`)_d&ov zzpT|du4HLazQ4cjb}YN+uWs(|pIvvQyNe3@dZFb;3AIdKs!)?HL%19y@innGlS^B& zSAU?Srl|HY__ZLu26{n?0V2`5j`jGrIynssZvIS~e!Pu48 zH@vWe?y9fe7)XEU644F&7?P2QjwP=ISVPBEDj~y(E6l1HS7XvkU9(#URXr%<(Dj5< zB|#Ss?(Y8^cmK@k^z`oZ)tBErzyIyuI(_Tgzk2)fyMO1=)!W}g6lq?q!|DV_7lrKPQa?*2diFT3Lrtsk z3sFIme)4!0*HHIpgtOP-*6Ol#5N@>C7BTAOC>>CnDhvz==BF)G*QQ9s~gt z{Vjw#Sxq5~_+$DcKRHy9KC7OFMc5h1-Mh80%i03T)y3xF*d&~^@ogy2q1Ya!Z(OzXdUII!zc_;$Qi-K08(H*Iw);@62xNwE*)z;obMXh2DO4Cq0+leRfK0u#i=`~}0If?>JQ`?CRA06#vwuuGN6eHg2>69n*XBb}?fGVg5K?S>STWZK>AVCZEYEez(RV4je|O*@2aQ*-Ckb z=ATH1$RTI=+DWldiP!u~|AI|Sq*oT6K9s)et_mK`huunEdobx7>mx3>d^Cd{Pjgp# z8AlPfbws_cLZuk&M0t7CJqTm0`@O^UPWtjBYak6d+ss~+~AKxSD2tWBFqtxXM zy}bb2K^jtV*5{<1y*gt+L3jQ5eEYiR;aq(XuiMkhn;wpPf8BU~(k(e}3!MPF?Qm<+ ztLx;vJKeP4fBWh+qhzjQY2W15N&IH0ENrgZt3Yx**-1~rWMTfO839;$i0Lx-G@&Pv zH|BFr(V418%-9)!M>~kf-M#v<+xzcMj~_oiKYjnhZsmVg+J0K@3~~j+i}Mfu_<0)( zuTH;u{q_0p{|C=c?_b@VzVo*qpPqLE!M9rDwn8>wTc)Q+UoI>(GHX&K=QZ)=iomup z5?~p8=1pkj)70LqJ;fqdK~Y)Jz;4E1n%SSu{E)il32lMmez>Xe`cAdkPFxrLwH@AP z!~q|O3%-ci(MEtVqAP}shhyLbkHx#&=hCOnB*G{p1%`X{=A-b&AqDSVb-P+yAdf#d zo!`93aeaZ}+0!(tG`&ARdaIsUyTrIcprl`WPHrtyj*Rl=^hs8(Ejt6B0mv17Dma;n`VtO)v5!1;vcR0S45eu2hMXpE^IEzdFA@ zzrJ~S{+Wq5KPwsAB|KRq^n^(8L^6lS{R`1^2z8o5yFlU<;A`D!`sh){^@BWUw zeeelBUdM*WQ6L6U<8(}ggpp)BU2(z2jB2k2CtzVuYw{*b+GNP^*r}7bCWTb>>LsmABE$p_H~Z4;X`FiXT{U zsh_-F0eEK~R=ViE^+&cz{<_mEwbL?TMc-QR{27^O9yAI{RrY|6{v-*5W+B{lwQL*( zW@R$-N4B?sFSl1P7yPNfocj7uXSNLNwDVqv<+v(f_CFW9v9>xDY3#N&4*!mJGNW-s z*J!zq;vHQf-@lp)x87rE0e4t0`*q-9p8MyjI37(Dq}(rj#O_e4gA!l&l#(;z*$rhfyc3DF z7*nf3kPtYt@T|PpJw~LMrdJrrqDHcZkWFUj5|s`sGhM{<8M3`s)4H z=eI2`@7}&hMn6B0z-M$diwB_ zU%LO`{g2w{e|rD;ItTRIgI~UUb^iYE{Up(z-~XGx^6LH%{-C4c0E)bzL-&)xlbxFKgVU3xK&-f52pFKOEV-AQk;D|+{ za6Ue=Wss?5l=$B&JunUwi^0d(QwwSwevO;D1x;;75FV(|ZTJA5F7-Ezi`Ro9+5EQ{ zqUS*;9^=zz1k4BcB>lzNg~hY(K>d<(3O~T5`K#{*^zeGHz&G9kUEos@O1%%S2Mhds zSfJ>-_B+Rpt*E*xGkSyyf$WSdo(}tP*ZD6(hln-a? zsnJs_jCcyp(dq>Jk~Yf$JnUOHfSgWx6xdjiH#K6_-(;z~gE7|7*64((pqoCr@)biz zT;T(ntx1L>Xi1i&DpKMB$%@f#2{zVHQNa3zx~t8}M|#rGb3Crk!iOZ}_cYRaQX z(kQEvE@KF#)sYO9@WC6qE&@)ifaV_?HuPpWf&_<3#K||!eH=(xBnj3GJ`IL%w`7?w=$NGX&Kz&k3Yl)QR*LrL7U~`Qg7t_7o@}WmadJZBqB$r zV0&{?3$N|yFsb+cvuwP0q~6V$q{&^bXvQM`%>>MoiS6N-Q}{$g7`ozdc4K(G-4sQ_ zXa>YSQ?kQI)Hkq+ntY3vJQ7_OLla{j>76hw6Z$@JvYR*B%VCi0OXgq;c8ao%OO656 zI5gf5gF=Mp!*C^X(unFwG;+1n5n(#^;fUQ1`V%Y;Y>MUVYhf^=oErZq!omxTI67vs zz%>I0@1d!E92jJp9I0^h&y^+<+}XbVxNaUtfGZFD0U$Kxj6WFS3#;RZUyd;~_p`!` zkdZR9pg3rS!&nC~-X;%HNB4ywLyvT;_j&?IL$`f(V%riuI8h6aG)=b-*(3QFx3^^K zv;XkW`n8zgh_`SYcFq?^ys+_NP!AoJ?>GM*L}s3-bQ2ZW@tt@^5TZgY zeL5x&(a}dKQBkMBZ$rS_irnn z?|N+h!LpITPwzBO01?-B1&Y5O%-4p%)#JzaPp+PHdVP2GsIL@sx%_c=zCZ360_Pqk zcikNXj~`!W&g->Zhj(=a&e}2D_}?_OWsynb_f)tw5r zdFcB3+5N}g{-x6gAAWp({P(kJOTyXLM z(|2BOk3zx4S#&e-YSz0zbCA*~j0z;y77bubn)*J9TtpFR730D5>mSm2v(fwN}- zRK;w3e$$10knh0)EYNKJ_35@m*>bkP?Mg`b;HTYnRh4%2;ZvLP$M0G+b*t1&^Q*g@ zNR+#9+T1RUltt<;Kd26?3rywVTP{jx!RYQNL>YYqWJUR{Y@?+@b-6=24!l)J)S@eI zbtY#FkGOz{gwCs3+-nAj5 zPKuC7N4+fY&gk{j5a3jf=ZcWJN8MG#Nk4n6jz0>epqTKet|oJ$9zu{RVv~NvGsij) zJH@KCSod+}tap(Yx5Axph`N+gM4;9K5#mu%#u!;CxU^vN#5iIrp%Sk9>&2^4aTIO! zUwl)g8+3~yDk0kcC?$WVTQ^+pbg6NA6R=}-8mmXPFlogOqZ10Cr~5eXxMso!O+*I9 zOk^QMGGENC4Jjl%)X-(w&kUNfv`-Z4r;`hY4CMW zLjdj)k3wyj;ojjq!IS=2wI?r`?$` z>r>0fNNNEdx#=`x79EE)$E1mofI2MrjudOpzfH2(5H?CVBWetab$^6vR0sOAMdak< z-!8o6>I23?gC4M>aXdT}fdgObXp-50c(>gUXZlZKM%g-!6B2k^I2{sa)Y*ouk`^B# z!;eMU5UaMl-mK+9?7HiuX-&X+-o#+@An;nW1u|bEmj^itLX!~rYsw?RB)_l!)Tt4x z&D{sjfz}=nPnFzIBk%au<~bpIxn1E3p42TYvOvPF1+=9aoUp8UD2&9zC9 z%cfwsI462C)T`53t!!nm*GS6zmNqiy;X9FYjEjtL8O4Vh=@JElh_GQE!4dsgVFnN9 zogRp=i{EM$Y^_*1lFVyPj>Ab4Y}*Ltc}Bd*JNqA+X7Wh)^LXbcPd^ym>z;-4=;^a= zb-%iQ^ZMrg>iu6mKih)(Nek|~t5nZzwX78rY zn|`YJ?cM91%-@{=w{OdhZSa~YR4)RfNZa|`vj<0}26=<&N`9M5v#(92uI%^gu6;G{ zt*89I?RfyNdk(Xzc_#Y?|pH4`{j$ff9=;_pZ?%~^waad`>%d^^@H!ez5iQ(_|%YV!Jbib%9gy1 z_iSVDfp4wmrP^ek0R@ZxV_FakogMkLgEW|L!9WlVermL?4NG@5n42z%*Ldg^VbjKZ zJf=bkM?@l8$Nps(xcrap*aajYCRB|D7L+3&gk?0P6=IaZx4&cj2nvMY{!GLR9F#E; zf+m5}>Dfmcf<1tt-Amv5x_z7zx}N&@JD?w44;J{QTfkQUy5FvVe|SAu;OE2wudhzO z-$eV163DWTkI!GrE%)~ycS}~Yx1=hMEbr88>0)BHJTi?pe>dwZL@IZsfX+JIp0ve- zOwnuiod>xTBNJ3x$W98fa<*JGO1+Js6j6%=qeD7Lgf0wuBw=_pqT8V|<&r^{kLvU_ z)(TDwt{?*~&B-+qROJByEkV)LfhRKZU1j4+%oU3iRIWi*34wQIm52%osnxawEuXa1 zetCL%fA{LE^S|@0f9du=`uhKL_5H8D_vqW#pWgo3$G?8{$B#e0fBo|L>FEcb_Wa{r zPr*Hzjx_7AWSucCW%?mhjOeQj!>z52PA3r~D)tWcNwnM?cyO$9+^@S2b{=(Z@k=Gq zo@WyO)>X(uIkKn|YbA|iyE791TDQ+n;yPwwE=k}aFcSaHoI_{R+A!NupNHwEQ^1QP zi{g0{Y{G?5`6)ISItr_W!=0{a$qWIBsalocVfYg_t>Ih9_m3pK?F+N15o|ExQAC-h zBfgEV>O2yv>H8{4Wp?XPEHlB16NEa7Jz09swqj-W6IV%GQ-ZY&O&AfU8Pp1A3jh*V zQ-hv^qD=mX9DOiQ+>^X$!}!F~mSiLXgBM7&GlK;$hLPcOhT9eUamQ}|BDO1;yDmIX zq1^h)Ge3^uU2KilMv_~SV-&f08MD=X6C>2BcTQo$3Dak)Sc24)l`wSkxHZ zeL!z%+({}DTQqfOv^Ddk0QZv2T)}AxrIX!#P&kZ^3{NJ^tt4o6*-SMHSO_1(XgfeN z6O2(2S2#zCNMn?kNOiu*3Xm(DCr$%=8>Iq{RBXj&2^J+`73CqWFZDC{G-d<@sLPGC z*WAAiLa|z=mTTzVqB&4Oy$x z#AN`qKuf=9=3Y2$bVesaQz~7IBa~yAN`;CQ6pIBBMHU7ChZ!#O5P9`8=w|L?+(ySA zNoMH!tr;^L>_BUIBxhv7 zIEl@eS()31GNz_p2c))Dk(iUJ$PgiHnUKkpUcR%AX0W4zBYCe(8|LV} z&7CKYp4?wQd#??P_gd^<-#zku4Iz{M?hETVMt5)e;^Ys0{ObPZr}X*!^yfbP`uxBB zD=)A9h2L!N|MQpkfB!p=&fov~>HU-IJ9m_IGlOUnYJ8d9n2NtLD^cgxjHZ9F-NAhi zF9B=Z`s6$@6z<}W-<;T7>L^&sq|>N(!l~woX2LLYYPNVRIzvqP4OZVQC?PV$tP>-& zk|>n5kZoI{*E_o@o#w1 z+kWzkBj(ky08!O{pq%VoYH)JU!wlQrse-i_tL%T z`E?a$&BI^3zCXVzj6ZyK@4I@%bW{Bn1=UGbN6Rv%ap}I#W^kFStf6?2aLoTJNh))q zS}IV%%516y^`9c7<{%|FHB<;;Ro~ROq&YT{%1Wqk$_$gbCIsRi<;!hnvQD`}=8|DY zs-lNJO2^V)bdHRA+czFmOkIMJ5ihlme5#gXnvT+Hq^M%EfI`%CHpWY1maCRg5^q=1 zPB&NGIC%g0qu+V*>sKG&esuogi{CrF`sjBu>-GIF|H?mq{=+~1f8F1G_(>1=iD2^3 zVYDOfq@2vG9VYih#W=1^j&b#Kq=@8oDaHvzF5+%e!xWScvQ-pEc6L*82Uv|an@G?D zXz|a|)0}z{QZi;qwFfX3=`}6)IlQ_9u2)Y00q0ZxTvV*qwft7Q#Tz?yXJQ}1X1kcK z7RkX_IE!dSi{e+)1W=)*?e5EJn!!g_y^Rk6d8)HG+E7GVtl( z>gjMgPt;wJQ6##1!YbkLMM~1YtrNUrad5=HV;2T39o~lDpODSe zRFr}9$DCoKpodvZ`sl@aist8SdZodkb$tc%#BJ$%Ks!}KjMWvMBGQ|Vu82^lO1a8f3`~Ojb7m%ei3&W6;-oikc|Le>4+j{$jwa zvF#q~)vOVEC|qiWz`vFaw8Y6I4kKgVrMQm$M2y;|Yxlo&`&Fb!l&>&?am+>nKA}^1 zV)s7zZ!Lul3jRNo+hp2Lu(5Fnkq}cE&G;%^5TfR$2F{>!KIW3z*4H~B<~n1UN{HATpT zfT6^EqhFU%uKyT6c*$i}Xkw-FShB&Ue@(S)#th}4ojzDmj3Oh4AcxPZqL?x@^;s-r z$B-FlAu*aD=3reB%VudGLa>4XjZFgyBZI6D-ChMAp$f0wIC18>*srtkT{h*TWQ%N9 z*Q0%Rdiw0c`nHCW zV$RtqS?tU?B%P3LGVEsrF|_B)cF0@R9vV@76D{+TJ&W{f0d>j-#sZzmxnOW`|LFSt z_Sd+N?dhNW@bmls=r6xK|JVOw1iPEz zd;i(h>ARm_U%j|_bb9|uUfrzMn2!@XOWa-5?kAVx&N+r3)~3Q<9dReI8sA2`bK{_Q zg0g#(d&#T47Ck*CrKFX-aS~aJs9cas=zTz{gH#a_V8;frfr2#8?U?>Z5d+f+$krTt zX|s_W+y3`DC_5UN80d%sA?-}5jy8dGtY^9%!9mGEw;`kV*|(0)H_tbLpFD%AeERh5 zlb>Ml@OrSoH{Alh0RP6f1Y06{r8)nP28=#N>i_1pL&YU z)zzmxwdQH-=9f37`U-;P?h@#7!my(vXOvSzuBFuB;zfB15FUz*{wz9c2~J*#Rp~95 zYFLD2mk?G?9W>hT4{Wp*M?gwP%hyql#43}KhDjKu6bU-E@>o_O%<_#T?ix$g!Bs4` zQd^ys042i%U5!&#BQvV_V7~GyV0p5a&+I{wW!aS`0kcv zzjXgMzWTr3zq)_kWq@Z_fA-tIb^k}-{hQ~T3ZLuuKZzK|eZ+!Rw9E%FRnu5&#(9Oy zBuPVh6Kcv;aVM)nqPE^Df|k1pxg36|5wvwd_qSK}%zJL{j8RbhkSbL53JmF9>l``S z5>Ao2%PvGrRq`fh_00H(ob5OY)n4H#efHi(8?}ao4;{OpZ7Ht=OX{O+nd2&`i2#4s z?ldN;-2nyALMxJJS$4Rz0=pWD$46=YN+qQTetvO`wADVu+l$?VV}j1?Gpv{mKpCZM zvCDYp=MIpN|#w%X>2giS6@)q`E&oaSS$pEnl&la>Pd=UQrI`2?#es)5ZnwHRu! zX~Y!K2&l3x9SME}Z14%3#F&C*9;M&ZOjJ0*cq~nDxoMS~B5-3keHb?0`^ZckSyWD$ z6{w_MVVT6JjXtSqtaepjZonJ57wfLBJoL)Ihn9IfP9lh5g~m`5c;+Bqh6t01=LUwO zLpqX=8LPwfh8lIcIj%2rWZ>eVs7+3XfRv+$8LJXA?fj}!)8(6%j){+RMuWU(YHOpE|km8%wXVP?jia<=iCXh zAvM>KB}teL2ZAziM-IX(GLU3nx3$X=S!5U6JA%2J2Li_zIS0v6MAWsw@)-4~&(@Q# zqlDJW!V~EtA6ZD}Yt>b#n>wr`M@d%@zZ1r5D_V63J?RH%oEHQf<&$l-%`oF$8KmYuT`q;(wC<{_q*q-w{PxlfBg4v&wup&?k%{vJ-vO>Zz^3ECz+wQ{F*FS z+Jids6$2+eN$my03q^p8@>ip+3AlSm2v%fvd;v z0Qe?L_#oSZ1^)jm(9iw-zqbN*<8#l*S*m@}Q*poQr+0q!qx0$gX6dhAOBofGWfZwU zX1*k4x6L)XUvfng{T6Xcahu~qTf(Vh=|rOIDy&mUX<1p31+0Q(*+$MBL6rn5UD^V_ z>{W+q3SfK7rMHAyzEsc2QxTU~B(O{7mlE~V5~4x{UAZW*inAIOdJ!X0Vg(xE)e^3b zmqd{|l454+*52S^8lc*qZKOT)A6MXf|L)8Cf8yrV{a^hvfBF7z{Lz1W{?Si<`1t9k zzkYi95ZPnr3 z5;!$dn-a;ImNUkB)I+sxnM}q@#Y9QP1C%~kA^GEDjTq1biX*;ts5n$H9j<7IA{OgZ zMlzecA3KtGq250C^H+h4f33x8 z??fwfCP)N#a)?Xz88&|NHQDQ5{Deq4EGwy2osm~jyiJKdI|A&rfZ=KE8LYScI}Ai& zK8bpQuwoMIZvL#E3^}WYBVt2EV9m#>gUS&2bNtyc&0uVy=Xn2(HO{GhF(1RmTc>2? zyXm&id|Pd3K=(q$NB=oIeP=`W0Bfsy%*IsBsGb?o5<$ciI10%AMXBS9ULs$-5yZ-A ztfK?UofTe4`LwEN%*QZ*j8pZRlE+*C0IfFrJU*idMtl&4ZS%<^DiV;6HYOIpzEY8wk)=m*wB4x7?bP&5(m>cQ%1K}qLADxL zA_zfBMNG{XcY|VdJvs(i~i`O->8f#cv%o$6}hTJM)+kHzzS;lFWX>ByzLO zqBd%EOd9+n72TGi$6Pi;Ov-J~gxFY%L~Zjm!ot`Hk2qK8oU%q%LLHM8mJyGVw}avOgCG@pp@nOed6`p4i5>&unBNBmf&4)S zir-N5PeqPt*fU4y#a}bf#c!d_xY;)^5m$aF4(N!zNRj9wiF3L#2HChdsOdI{i5Clx z(3wQFiqJVUl;j~_;+ydz1IyQRW`~6#exx`EqrJGpJpAbp)=VZM<2BO|cCY!}k0vB+ zjSRkBS@Lv@NGQ!l2~+F^C(qP(;Mc%@yRU;W4-X*52~LwC5j(S(b$eCn4aZ$2ZXaq6 zIBd=aqsm|*cYgQw&FO;=K5c#7mQ=U=zyJOxeb4^A`yc+}=jZSJ{#VVB&$|fmG!I17 zCSX6&cRF`3-IHda?gHxJew8Kt9?*3HCF>JU)9aJXMZgs7cq1$x^g5;tuBk(WIA{iR zm)0X+Cul^@dzSj$f%2yE<#o>!c=7ey)9csI&##{MivcgHfBb6C?VHm_kH3EOmw)@w z-Cy~yo=^YH4}N(5^uvBMwx1{Y@$cQAe)yBSM=#vi@aVRO4Z2$(|E5cK@Gnl;+x%xf z5X3>|N&*AOZrP}<`)n0du!q#f!y=-Iaje|LJ*4v+w~YkazTef8J>X5X`Tcs*F)n{RU_HCs$E~MyZ+$W`SlMx6tJ8BL)ks(o1f(@ z)qtekbggEtGLhci72}dlXR^*whYdpUB{lS>$6jywCI-#^FnUQUDymMUoZIf#97fyS zF1g`Qep&V#jgpLYd=#OUf|H=UCI2Acssh4C;S<3v;3J?i4MgtRYkU6+zvNO%rm*#u zixQ72cDi}@f?K=(&O+}v_{jD3?G_rhSMQ&$&!2z&`R!kP{O7Jdee~)1v#-8$`xid> zjq_(Oe{%lqU-{j;AAaxmuWp`yc6#=~w?oD$#40^^!@BN|J9#6%g3iL4ZhahT6IvBf z^9mW2U&(Zrk#b3Ab+cc2Ia~CXcx}5N$~L4vP^NYrp(D1>fLNV|!J(3ZJh&xbxD_3A z*W*%&?<rgsbv`?eyMa`7yEzUV#0eJw&-vVbB#TiZ6XTt%B z{L7zx3T%%Q$N^Lx&w6%S|515P!f2+-xLnA)iZ<|z#`%zxQ=p#tiov(y&>kz!u)yf# zoqjbZSNa1~V#cRsfMD=96Vyp&hDzMq?9-l5zyVJFpK(b)=7~yv%$h(V!ls*VC|Dbb z+nj8g=w$tiyKNij+X5~yG7@C5(%YtjNLXX-P8tTF>(*Yuwu5k4XQowz^&J4*xGQ@X z{RA6I`K*r=Y5Rp)v?pQ_X>q}oMZhUC|+GGkJVruvi~oz`hKw2oMdP@aFiYCP4 z#lE9s3Jrivgu4*9%n`!}Ia6(LV$ieg?WCLg`Of5GSkec%=|K1f6rb8(o7U*3KH^S9@xw*`K$uU_5s zD>?|+TEOb={CbhahhPNo>xB}a*=37S=wPx|atqq+bO*NxpDpU?mJ@10NI`#RY& zNNe}|esG{)9S~A}i6D9l2wyiqpV7)=32}srcaq0Z@Gd(Bvt#3{-fXuS*?=TGf7CI* zHILS4RzYU8>hRYwzYSsjlnb2pzA&Kwft(D%^XCQvWdD~0E)yr(IA9Y0<8vCoI*@HE z_g4W>(};k+^sfUAZcL{jDX;}D?Hx>^YD7G zz&GClPuc58#{cJk_`Mgu{@3n$EU&E4b8V~N`kCDO&)S*)r@npv z_!mAnpMU?yyDGdS&=g*-E6AI~D;!Pg%Tp%z{&tbc1>Sn}$vN^MD2#!~hh?Y0X_1pXJpMG@p3)lbD=|BAR z-@5;|zy4pJKf8Nzdiv8GQ1&> zbNuy2!TC(qkr0SIGi;lp&siYETu4mCv!&=3$x6K~1oZ{NwW1$c!mcoyNLH;!)V7Qq z{dY#qDASi9QR$^<IwIDIR`w_FK7q1GjM&@ab4bD988od3 zf#;~hD|{*}#XCYRwo|4fddx4|{7K(1>e;{>J%fN}7Edl=8E37wG^cZZvKw=`z;Ay5 z#|2X8b<{OIg(E%r!lL;oDieV=k;Pwp?q9?&5>jG_$BpZqC`NeQ(V_{7=P#3*Iw|JXGbZPTu@!xHRz=d8e(2 zo-V1H6l*t;A|0I+qp+jlqd`wg4T2?;(8`&;jeZ&!0HKMloE0Cs*JNyPbL5R_1dnmE zvRwnQlP_^itK1TK_8s3CiT|yTv!2Gt6ux2E<6Q`iaBL?$9TIaMG2AB;S?EWyKAHDo zAbf?X*bK;@EFw|?O|4_h(6^XQuPh+>^m6OF`J0FvHx4fGx4YkPiut}OlU(LiEHn|+ z7{cA3pM$0ka(KhrOAO5%8$XvwOOj;U8W2$6NX|tR6$0e1YhkSy4Kz&+485JqgsSa; zGv5)v2E+m-mN%vq7MEB37odUG56}w{eS7<@UI`8zK-zAcB1-9{(WCmJU#D1MbDS8QP8gn z^h>)viubKsxbALxj1lYiBYjPFg+t$V?6F2qZqDy@7t#m0=c8x$_aD7?d-}BR>;LNe zxA*_#N4Mu+{iWBZZ+~=u|M>l$x{@yUFYeB-esp*L2j96q|LFPE=|zqbx!ufndXFuA z*MA{eYuxxk>>jbz1bM_^7WWXM4F$am*u1up&9uzOY@*Z1bW;ki1n8Y{$M-l|)5@md z##s=IK>ndC+M2{iXP5CoK2F>#X}OOVqZjN^tsUb>1pWyaE(gs7G`Sqgj-lwns23EX zz2k7~T3pUA?O+$|*b}SIXWeP~=sHKgJfGjR;1|HY!SVR&A0hkjda%IHg9ZFls0$cB z53)YU_+WuqKnnQc(f{&!_v(FE$#DPpx)P`f`rOlOS22D3?6}>mMeX@Xk7jL!znUt% zj--OZzC>1H>0il55u(%E@`3uTe0XFUNkZbOc&^26bEkHa%Pi^)C2$L}QH;b@_4f)9 z!|KQ_ib#nQ!T5!BBuC4X$n?K@Nsh%Suw|}_DLfTuIBLWAZ8hOh$ViQ@Fp>T!Zl@7~ z*wkKXP6i9|P7|UPQ5wg7a*?mHqeRw~kA!}H_vZBa=KFX5y$}9t=YRLJ|IO+5KKr|; zfAQD;()kCko}Yf@SAOgK55N0&?%%xl@%ib8zZQPg4gBoS^b?Vhs`B|%tQSKaMqdeVY=dycYV-9VIp2j#ulu_m6ug=~l({AD%7^L%60mGm z4whU>ygVBSUcCtQPc1WU zdAwc$zz@j8i2ld&vf2zz4~zEo&W^=XEqV)sj(Wq0QZ8L>RV)IK2{!(Vi2ZeVq9XQ|r47P9V8!S`pVi1?5vX>Z- z54}1vU;|4Xz)13JkGX<;7{P#wvh`hkdxq$tYsblI4~QgC#eblCb51P(`JIFv>t~7_ z>5}B7%zno?Ci8dw=7I=>LlKWq48Isl*0t+3i)i!W4gol~gd3Y(B)`l_6b05Bc^4xK ziHWc0z`^nzshB0=xFFUmm~@-~$M{MgFs7RDINV=enzXbPyBE}T^2Q-WD_z@ZEz}kp zgqyj%vjShYR=o`H1zL>}P8EZzLZ2jVkB@nrvJc~S+$_m%d>ldm^#L(*XVChlSy%@R zvg?dm6Qjjw_RJeKL~zJI`?#Fue}lF^jBqW(g zCoMiT%xhtjX*SoEg@D*BbVb_m-%g~j+u%9tg1Ufa)drkFP38%XM?|PFYow$bIeY;u z$F3O@R>g@5%{y6QC)kI~R{@Ny4zW^mA{_Y=8AC-EI$VL;A>Z^{15#m{ht_yuS~K!* z+W`3Vmw)sA)!UmyxXH{PWbQvc{ZIbI?lAn~<@wQ<{Q}?B8xr81a2vxk9?{RVOt3a+9thyURI^wZ~8_dj|0sPFbaS=iWs|LBR?m$3?r#O!_gXCf2T zOun&^$$i_Y7A9hK8?zfH5jfzO37SAJ2O!Xh0h>PIW%~wGeD(#3=FsAX$iptKxM=Ut zBQ*zY`>iu(Fs=wSj+db5ob1i=;wCU?^*NaWB6eaQY}gscF$OA1`efi<;-f*(E07B> zeJdl}Z4s}bdIQ&|31u`cclSU0q~?70`pfy^-U9&N5a-!{QX|?AuLlc!<1OIvX-zR3 zRNr`E9|U}`z&i`{!z+LM(e3T;mVs`22Kl3_zGrv$lNWtAy=?T0-O%2o{pzN;eyT+5 zuM}`Rq_0{<5|YRI=Yq8~P~vKmw>+-?t7wwPYKeTAPE8a1QT{7D%77tR`SvP9v;wWa z23bP7fx7cfTcoQfqu5_ifD$L!mQaohWIAg)=pNGTd$E?zdpt+W=RROtta{-hp~13N9LCL(*?e|5S!|H19|&%b;B7tjCRv%h@zAO7TT zoWA(>fA03``pM&;_PfTv^IQLw>%!##06+jqL_t)=^WXl?e|&oT=A-k|r|+L`GU#zz zZfxTbnOOI!fJtSNq*n*>uX<&5<)FyC3qTI+aMV8nOhtog0y%jV%UpzZeZkII6sNgT zCKKUzn5qf}2kM8-cfiyK-n$ak5?kresj{`E=>Up354PuiX`&jgw%iHzAgSmj<nL=p--Bk1$i zJJ9;Gu$mAlD++b`f`RN+W`p)t>kMO6uAL}I`$HZ9g&jdqjw#uhzOue@FQZNTx{a7h zz1i2*cxAsNNm$7pXmMAzkJ#K}y_NL}Rad;z1qA=JlUv{mFL zwOb|fgVk2IawqYqgzj@@?Wu98FkYC`VQbMAmwm5ijOcn0raQYq=y+^Xu3esVW7teI zsxJcliQMl!0#|0Fm(H?ly4P5YGF{V62)n=o5c#TaY8s42Q6@2yv^ zIzFS{O~iG!It)F1U5F+7pb&r<-KHtGvmB#=xuZ1p$5nFeBYwOg7N(Otw`YBfy*5%% za}o9q%|iEQ>zIRU{yDx)j8mhL_$(ELX_Yj`UXmxwCrd^<7OsJ2)Sy@-qz2>oQB*sN zc+5)Af9On1@pG}5T$DdhP0}-y#VgE0M>L2IGzhuP8H=!fo*f8DX*N-VnA1p`$2&G@ zM4+&6>qjWqd7fUosNoq4q4Fz^#tfT?!kTNHhbPlvzYT>!&!W%(1_qqc5j%d4kOVa) zy4VnNwG70{ix|m=&EYh6mS2Q#mNaaVYzLieA_;&E62bn>ZK%Gar;9to#?` zB#Y_cflduQX4toBLdY($zoaSBQ%7WNa>A}cBB4qouQ z(iGFa!S*)hHl`i}zFb_3KXX7Ms7~_-{$>Fk^H)S>mO1mPxUMfXZ*N}iC-gr4r>L&pI_1iQd(5sGWBg|ebG}|l@M3y4--f~C;yN{QIa3{m z3)2vuSlS8Kd6tz(-HUPT%8pKc2@ zTW_YmeR}l6(mFme zV`LOJ@?|2)KymT>t4=}Tg6Ofps-g2}sjj3bD;A<} zRq=CNuqv+zuvrJ*618_{1WYnt*GB_!eeV|V6IUStJpC+@m1CESeACBejU#|4fWd-8 zq+aw3=@8+#QW+n-)&`y=^{cfyjk`6<^trr6HJa035sXD|{D0V_1~%PY+6RRsSbPKQ zxGjf^_NjjOES>74b)as3n10dYz;LjhWCK`y9Dy26Rv;=#ysTy}@gfjm9~2I&v@VD^ ztqyU?*}nxIxpNn9ZrEA=*3 z4FHE5^&WSnldmo^&Jzch#I7;fCO$OeddI#o3qHY#xh2EKC6e`8FCIqXX#NB>Tw}7z z+a^OTt+6Igt1pnbEA+)j2czV)<~9RTqPBy7=Tj(e4%9`a6J~ zc^#%y_+%SwK>3FcW?(qH5&?8)fQe}-6UHG!1`1&lSqkdGbv9cwQ;0ubr?Ot5+Q=WO zVZK*7)ErD_M}PFAV`0tnBPu|tzmxj3yM@NE5z^5dk$3jCcXvc!D6xt^?CFjP1u;G( zIHqSlBP5J^u+TKxeH;{>)`z|>zjGVW4ri#PyKu^Pg>9bJpJ^jCLQEWHh@Bfm#k?#) zH? z2XnJe12XrTS9GBe+de`WF_ImV625axHVexCfgfX-A(!9z8-9qAYRB-Rzv1q@gZ0Pg z=&K2iNeyg1*g(?D{lfSddv$sxslj{5nRpZD($z;7J=`sJdh)Gm4bNEkEGqlm$R_{O z?c*m`f9C*xcz>|KH{SwnZ{Byya)aaH^;RawPxmi- z0^svEcc*VXJze!R0A)!%iqEF;=IN&Ul9CdqysYZ`|FL)GKelC8ec$Kax;4DHU%#GS z+oox|yWO_icE_VI2oQ!i!XXj}h$4kVAqfzLNGK?TNCZQKkb(jP@&ihc!Y}y&_yr}3 zNJt1W0R)b4=(h3L<9q$)sj6O0x2oF|z z)|wAE2#Jz@-+^zarQQ{D$-tGY;xDp9jF#jGB>zPx-{UB0twUG-8EqVn%^n$|!d5{X zp{=KsO&GxQ$e3T)3we4-+f{hXBU%yWAQ`oOh946& z+<+qC4TQ`<)jzSnvW1G8>INtf!^U3(iT-lo<5!Hxw~rsD*@7hsSwDL5)Wl;=ujZNFo7So=l*&2A6jQl%-e~TzGDHRt+E!e2EEngQNOZ`jX0l> zm*_#Ai0piHrlz6qLQT3+b=2BaI;2g)cXGD)T3Fy1SYXC)g3=0XD2r`jfn#8S-~8?b>wInU!pxsu<=$QT zdQ*lOukG!+yDXzmc;@b*Q;a*L5Znz+4rTAn@WCM&AlL=WD7^%rmBFm8l_;i|7MIW&`~ftNa+H>qLL~KM;_FrREd8r@ zc;!MCHL~#b!BKnt;1<6obhrKTxxd~{9xioPU;2Fp@sm7jc+fHAbyV5Q>`27H%QQ7XX!WhjkDQ!iXviW)g`5zYWibscs=+Osk`8d=_`^ z%`oOGWq?puJN~8Nc|;D3N~XzBPqowmDt#$Y2ycuG^Qro<`KboOiR^efCi%mFHW3*xm9IW#Yji@kn^2{Fa zx^IE7h+dHPz$ON|9gb7g&{GipuvI2MA>;)wIY1L(0W@T_jjzR*1sJ@-UDcq(c{M#n z0EmA`L38kwWUL67SNquLZ3GM{`6vQ0;_U;7QdR*!)`ni>Q%qR4Pgc2OtsO0#Rzg)|ffkf^A{VSj`I&B^Ej23ZYQ85Jf-mra zGaxMCq{NN0i?Suac+K1DM3Sgj88It{f^R+L!rSSFJWx84SgZs~JQRE^9u)?rl1-)O>ZP$HY6XYDscZhILr4QY@>ym-C>VqR z6$xT~<0A~0_^SwF(x!fLh$n4w`d-YSQG*1*hUd`q3WIw2XgSLB2Nq*NfkEI%ZdC|D z8UfP9K*hg!P^b}xIpQE6Rgj8btS|9OS`869qKtAf{ep$G(30CiQXM-`<~RpZ|8z#|I_;egaQAPd5aXOp@;H*XCE4k9?6HeMr#UY5?OFZ+7+N^mSDtyh8b8rqf zXmRd^4zCsHU#Gmf*J^F=TUA&nkf$Q4ZRgLS+HPD3R6hHx8UyShluOze$iD1oeHLQj zZ_E(;L9!l3{>7S{>Y`Xe9Xyhe^>EXq+B12z2z>UU2g{Jh6gtA;TlvhhL zAq!mH@yor=N}~==Yb&h4EVD|px6#cH`T1!a#8V>t2-WDWSc`38f#YHU)*#cilM@^l zp%-%g<+XslzS>{>(;v7^2F&Aejhpc3vbnFV=PkSQJ6rR%$^iAy>HJiycV~gYSjLdV zSV4DR6zXz_0~KRII(SC~|NR1#{K0E969$nCB%A@u7~&!|$&TX}o6FcWTr<#eu6P;2 zOrnHm%{-|Zr8Pha;4mg@6Lr`*G&>AtY?3w*4(l14_<|)smJIrvWH4AVLUN@ET7t4i zsPHTi5WqTx8ZJSZ;KC*8N%)JZ`Xer9^kWp{Vg~oIb=~E|OQQ$&-_rfWh3{=``>XBM z>u3d^Rzv>kDXlwTf3fCvy)y3=I(d%%{yTtaUkHJDR3@^7_v z&2^48q8#4jP zUW}*^aJfq}Qu1w0PB=nU^_Li#gng{}cCu%Y5Hqod4thsiB7ujc(UU$_^oSjmRTRlV zvL7h^<(3#6;iHM-RYwzwU??$PG6y1v0m?c0D-gKa5B{zXLLx=(DfGq-b+8nBLmwrA zCG|jnqI`%FqCoSw6I&Z%5D26(2}i&Kz!p%*u*clMd2w2FrAjX&V{HgHI2t@P z%7w!CThk({$_3W(N&ljO@eoYVP|?+=f+_K|4{*{OI!}op8vN5%!&qOqSz1a;8&*`r zlhG_Ur70X1RWmhESPn6foNz0MQc5J2z7Q&K6aXBG(iBqipy{a)8K#QmSu4eiY#v7w zX#=ZDxHX8-QrHlV5v7F$?4nEcrJTW8IRQS{+xE8q_Q>N;w$1x@x%JgLx@(_k`qjq*qOrUvx z1?n>=kKz*0lXk0AvKY@*duQrMsV$9@_}NuOfJa=?_x61m=~v-7h&e3uNNfWgQG4p+ zddngYinyKzDTTH{+9+J4V4s-Ajeq{{XYK~kPOYB*wx z;+bC)!DtX+tVr855d2R^$J(62yCJLeqZstvlEvA)LNSDjx*bLUfU}XLtpb+;pS@+0 z&hn2Q(*PEH z5i#8JizJI}VS!_2fz8SE?r6H)kM|hfxZclW0sZJ6zvJtw`uR0R*5x*0Wi~^h$(-Ye zV}zYM2g%bYOW&U{hN=t#Ku~XfX{N=9;CScI?ATD(i6LX|ozV~$4w#xxI3@!bD!po# zWrOgboH2$0@@Sai$kE&4q|glf4zEdWfb++I9E*@r5%_m-O-{#fV?9@WGw+b4+?AJ< z4Z1E}P%1x;9lv;YEu%cgoI@w`C^5o~nLEV8@!;xXa8vo^fy`Rmlw;!8-S9_?P zZCh9WGs(|kzrrPiYd2o$-~ILv&rY0sqT648IazZi4^n0T=l%v|^&+PFB-0{Y zkwh9T5@c^PEx9#DbWl1dbHcY&DOdiSXV0l3B8>pdy=*jr&U@MrBA5czbzfvK(T0XB z@`0}y91B*o%fy+bQy70zr=QE2ev3t!D`@ZsMk`SUA?ZPoE8>cE>KZHFQ_^D)xweGoGBno4r4`%uOK zn-+6I?v+h}(zY^5m!92FBq>1Z^;Jy8@0iEJ)a!O_Z|mVeiqOieofG+}Pgv6Agz|c( zju28QQPVyGSON|b;wv|+lk(Rd=$-pBAPYRj*tfjMnRH^RFmgUzHZ&+K!jRCFkB(Lr zrG$qzPI-7+9l4BG`yczw0tA^|^mb z)X+382Khrzc<9>~BfpK`aTY6p=W&Ur@v5(*CB$;n(IL3tLEU`h;yx7SySMu%zUH0$ z)ZVPyxO=Z(;|{{v#ujzE?N>fViagqFx9&0?^CG}89|ZRHe<>z0@CEQo#H^o{(V@Ri z%uD`U*P=pL~#?m-gY#dYb|LB2ODw=9PSy;#(FA zc<`>)^IeXheL#bE0bm~lbAf^0gSX`)z5QV=vKa+N7kq8_oVXVkRxU=cx_}?ylV5tk zmadbap*}%}^e@N^Z7_fht*j0~#{~?2PP{I-(4<+^h<~!ef{d=@n0pu;_IGM;x5Jr< zlW{_4%rPH%+W#S%fyFs~70)j*pl{SrVP)+=HA2Hr7{gEo3KAOD>#o}i_Xjpfncx{mD07koaFVYg2`MugcaARZgoXrZwMR9{ zha5v3*?<|Qi3}Xfs~ot7!AmZR;bxM<5xIKi0Sv&7-2AzsZylAKsS`&GQk=OmUlN$F zP=ee^!h{qlG+`nfgQd{k{^vOA6xjO$KR0_Fl85cL-~N+iP&?g=m;d+t%9S(;{p?_n-}CThl#b*GkVOLtAc6RNe2Y6f zOB6|za$d$`y(buY2!#EiE2C0XG?!q49~S_tepJK<0w;wc>ky1zN3cY0$p>^z5^zLJ z4iAF0nRF``XC!b+p}P1MGl8Q3veAVKXQ8PD3pt)Bp#P9J!-K^ zLoZi=WTX@#@Fho#0*n_W64la|u4K&A$h^Et<8-ycQlrJYSl`6EC_z>M6aAqcSxues zq{NMLX($i>;f&x~HH$A^fiyR&A{7)mILhp-5VZPGXNwd@UbZ;pGo>bN4JX@Ds7YSI zgcO8*L&ByFVU>?NFrSZ<{vP)dWcKK4Uy^@*R=L2m%ZWGQ`^NV>{} zyAYsG?NA^gj1XF{c)N$1Zv}S-m#L51K@g&8_E8=%D9AX?U7Mwc)=qSYSx7|0^bS;J zavAoq6|7~1T%6MLBY{%{3&lU8CP&38Q}?{9=zQCP4rah;~|fSvnLsQTMPEPP2v>d+*)=oIqp0f*m)m0Vg0dKro&G z9^}t5VDSQ$n$)?Vpb?C_&?gX&+KRfTFALLZ=a>szbwn=jy^)=O2yb}kju-6V4Bs$P27|~h-dK{q!iw*ivh%Tyo&8Umk>!lMR7oigOmI;wypFX=1j&)mC2XM6 zzLbx8=qWKk^l9?YM-=8*{=RoXK6d{l3A}~XuzaY}>>x46B+|f{-oM>Wp1sgLa`CP6 z%hzteVb-lIPiI?O_u2)XvH#}x@Ag|S&D#3zQd^D@WLk7$UIjuH;v=Jpy^E~8vhHp5 zz8%GoU^w-LfunO*A;8gtifEj3^&Nu3*yddXNRUHMw)#(cjwgNcQSMq@pL zpG3}G162?agP?)@ph2`9PQj|Ev!u5beJyv8|g2*t>fI>SN+#sD7_GO4oHGvF231>yuU=hEji6-FMp zP+P{z-T}ZnBX~n(!pw^j{Pas(Yo``30K{60?f6)rTbj@l+VL*7g$0h81rC!sSa|orZhBFGR zp#WA6G|8^i%7F?gBu(lLJO}|WlgRi4aN$JQ=pzHr9LX|dI&iBFh~~_wlEe=AI3ZbA z6@_6mb_f-MD+;4_k5?XDJAA48!01EW_nrDAwtZpz+MTcT4?Xx~zqz}?Zw>FXXW#ja ztb}i}7`_D)$dFBdE;amu0^W=OQHV3nDkD@8BgVmIZZ(uBzm`6!okQKSq-VjrCHvMf{3swS(6M$1lJ&_<|K;@Iv8o46A zH@B#`R)|hudd!(KX0N_LGo)lptu|18{*syn9zYVK(2xb_!O9-a>R=G~$(KTcjcYWy zmJ9BAVgT2A>a$2`jihJU1nRLVg zIK;LQgV2AvPyb-tBc;!C1EF!;=ecOStc+A^LXc5TiJ~j`GL5tqkfjTx(9f5rSt~6+ zgAFNDhT@;q5UIo%`HJ4!&X(gaD0;+<4rOCaq5MbeBLxdbuth?O!L>eqNl$=Qm|k<} zDw;2bhPcXtT(W6jj;TjNK2<3_h?NSI#~BpqF@UH8$=P6nJUD1a9f#EL|Nm{4>m$Xe zFjRMg1)z!>0bH|CSUm$jjBaD1`Lm)o-MI%!H+%PcKGtsEzQ>#Yx4N_EM%~R@0L=H> zuYU7*G(I)$Uw)0({zKQFdUb*hAw`0kSLPivoaCw)!vUm6PI@^Llu3JTh>uh>nlZPI zn*n&$-Nh~mIhpoOe+zw_Tv({MoWXGFG9G2(4D>i~Ud`M1;mv0P)8He5B;_k26Zu$k zf($Vq6+R{asmO(0u*Nt~PeL#9a4{SH5e0P&eP7-OUEmOZPApnP3-{dtM|UfLLj~5- z(sXD7sXGkl$A+E)Ca=Z8!UD&@0*9l0Iy)B<7u&)D$IJrPS69A}o7)-e7|EEmGURM? zFV>B%QOBEF$B&$0uwoSA{&!z$lgR|*lhcy){Ekv|;hZ=KnB+JzP~|9N-(Fx0GZ{!s zA1EfvsE2)};b0hKFxk_Nay44ZXZ!2)Z%|Fem~$qsicEb{eG|CZ&^F#%(Z^S?Ltf8c$pasaJ85$8M4W7?tf^|FA&9lqQ#uw0yj%6@~zK{R62}E+=VMazTPe7Jv^StyOiO zM&si`fMrOR2V2mmif)djo)$|XhDd(`#SS+I7rK4yFs&$6gOv+P} z4a%WWNjTF*Vpp~Kw^}&$--LXM-NN~NEz0$+M)&zvuXv!R=6a*>TTL--j{6H$+Yio z(N%d^165hy7;&ft_|{a^Y3#k4*=5fk&^FwT9E7Do{l`DuD`Q_WaV zdrKb$v@mzoXaba>KCnQ60Wa%0&X>cwCzPqbt7G2Lq3n@QAYtLdngp>ZdqrgvSD#q| z3_BG;Fv{%+MU@EP;#1-`wzGQy0n~`DOq{?ld_dzaY;Cs1F1Up!m)B3@s5NSIQAIHLhz4 ztt#k`(F;v)+E+Vb@Nd@vGR|R(;KK3I=J2l~x-yl?WKt-M;bZulo9m1$c2Lx%8E@q*EXSu;}b86=Q1fcN~Us&pR%H4 z2LDL|Do#vg1h7@?kRLG3r$yaSZ5&t+Z-7RlDKD^{PdBlWW*x8}UmuZl&k>e}*sD5g;GDC4PCpcyWq7K_MpckcB>r z0cjfCg5_caSNvHp^7RDr!rvV5l7S9u{D+rk3J&H^??qkFL}EO4wW@WDU* zfrGuaf8UhF@ueFtnHZ1yYa3m6$f)tynYOgX&1?Gy-lfH0NE*yI>2SliV~3xC$e)d) zI{m$aizy0yenuJ6F)zoOC^CAA2C@SKMbH6??+hXid6{V0o-$;JLt-T}po7LYq?t8( z3T=2OePxyoTCSsKlywC1)PYDY@~6NQhI7#(Nzzg{JKg~xXD;LC1XPL;&!i!Bl?^jq zGngZD6w_=H5Hf_!np?<2GGPqE~{fjqF7Ce(`@#}zTL zNnvc~bnWP>CQ~`|bD9woXzhSuEXHtC3ax-98a4DGti>4nO{Z$*0YEr=3chn6R|GhZ z98~fO_^{S;9I1-_qMjTl3PBqIO#BL>idll-eF~v$Ab|!V?$#Ic+&+Sy%BG{J@C%2|Z^9B6^ain?+ z-)H5@)7U?8s~Ca9Zb+vL(lS@qb~awlA(=Z<%|z`m=&qy!O|E^$kbwASe5=i>ZEW;FKuu!37nwjr&cz#XQ`kJi8qD}UyR@? zHPo>f`-)&qz?SxrgGk^}JntHlG6tUkUz<^(=OFUUCKX1rBu6o`9_ z9tybpkp5g=^mMd%B7oUFt&LmpZB2L`8sg{90BmWfi04Gv=yE^c<<1XoIA_*u?*UJT z2w^#pXzm;k9^Tx@3tgn}960U@V=;iM_p-wtO6)}teGhMSfrh2p9Qt5ZgERLr3lS&l zl8Q|gCzL4LpoyWR|M}0CtW^TXrQB9$frd|tTe7^RfU;gfhB0n`<UMh1wGQK%+VUOY}3x=C8+UJa zPrc*o+QW~%xAl8hcwN92{3oiVonS3-}p85o2tER{{B zQiG4|*_)YztbYC_LaBFQ#{#g8Cq;?yN{}fZb@CKl7z0I$f`(9pRt_|&t6VZqV(Jh< z#3YZ>th|6rYB0bm*d3arWFdS+sy~ddU@=258B`)5w3lR6!DLPn=${&bK_Qam{OVpzAs;8Jcm*EejOd999Y#t15Rx$9 z6o@3q3dn|?ln5(<9T9Bs!VGprWE(O}BC}!{sNi|dS?tFhI0%nU;GtGopWN6#n}fGH zXrCyPNYeBQEN>lyH*>2ClXh48ltvQ^B~PR=j4`q{FAL|wc!V&f5C}>oRQdvHZ}g>7B6U&06>X4 zd1P5dSSr$%QfkCf4-$&RG<+$OZ42KS?Zk}zkm`ty9D0{6sz5Q#JXivN71G8B`VCmt z$VO(yJ|NiDc^xIp)6)|KNXoz%IB2155v_&|_==!?+^MA2RwSgb3y>N$BC)fVgouai zv`&fxg)xm87Y4MNU%E3&ML%dqZw)CgWQjB?0i1mK&p?HIY5!`jH;a#Ke8s3(de1tl zWEPn@QS`JSiE62|f3&S=jy`@xJH;Q-SY5!{-?-YJd+;6YiDy4BzjFEIZvEDs?%c^q z+qlQ1f40~D<+tp2k3Z1OU;aWrUtYzB3}sM36a+$(Opc8w)Y2!okQefoc=oe2D<P%0EXGQGeDs&@E_^;qOyFbI2t}ZtO07hh*Tuz0vn9PNp)91Bt9h~0Vo)h?4&An zN#qZ+4na{`1ySm@YKS)e2&EyfF4nk)Fjpr!=Soh2VnJ)>l$ zi(Y}F4hCVX;82u!BTI&MXax_l-B{vB$;Mfwa=HB6S)@}z z2!K&4ng?SVDRh_S>)pn^SNp$z`mgmraQZv@e){FZ7hn6m?(FIX_#L$CcWPES>`2AE?_(42@^rmP&9 z5Sf5r4{Zl6#79AwJc@SF$fPqiRaGsgnaIYI%z60HAQr&X9}57Drb!h~=qeV+2 zI_L8YPgfnHrCt%?=p|q<0tmPoNw;gATGt-L6SxTqBaYja9-b(PppEkG^; zN!Ts=!(Aoa@n`EuB&1fIVlCP(!DjW~0&%flIb2$bnP;3Nqrl>(%^?8b@U;20P`iKO6CDB4x8;GyqV9myhUhFM%BObiKKX=I@T0(#^qXe*Md zK(3lfB;!x6xx5}CN>ggOi_dM0kU|6Xbl3seaIiCyCpRtATNE#CQ)S`&S;$-kc>{4?oWmksyq`o)?)c~a2ro#z>Ati*+F7ijuVd975kx+9B z0yg~S3?};_Md&q6dfG)F$XQJ+CY8eT;3Wqg^O1597& zk|7guu0Mn8)qj(T@iKP6d``0IvHUqenk;nT>6Lx)=~3fOPIIS#k0AiTqJX@XdVdw5 z?iSz$fYA4s>XeV0Bz^C9=EqKp?U}O>5IoCa2q0iloA(cduP(*-wp|S#7iijqmU4Tj zkFby7i^zcz-69vr-2p^BknlJOp`#sjCi7p z1P}Y*vlun_GD$3nj%a>4*k|L|!_<2Wo$#g!&!hi`k)8_&qznvAbC$tp+tY4*b)CV2 zAGvt^4C6E5&0PQ)i_8ovJ0t4BAp98_YytHAz&WImt(h~>y;G4IhbG}2BOLqravGf! z1{?-OY8);c)N3$NA~y#kP=IhKkrz1=IUUo9FhscG9lAj=sd9`}4oCVUg2}HOInBcy zrx8ot0EbrcKllC21qM&~IFdTsK&*^b8M;Fq$PBQM1B~knM^(8X=3FQP>9}8RD~xij z{rSNgqrLk#`(M2HcSau@eRQ(3`DguCUj6-kvV5A`;`aK>cW(6WeEUcGx4z>Oynk%H z?cIA_+xAO7fQV((+HFYgklfU^%6O`mdJM!i7*KKfM+wGx!PHrelLmr`N-<3gSiof| z$;8<~l8FDgrkzB@yumv(3q6;$otSDb%ajkoUPv`A%1m)w5mH7r3=A zktC+<{-y)+47Sxqg=pqO!S|M8ijY}-wS_uTA`9QiKp;c_&FBQqgi>qz$flHH45FM=lZQ%MmevJ* zw8KsSYEK|I1ynkMVdz4N7PL^w$J(%mE<$P@wFm_CeInNaKFpCofle-8Y7_Cj*2T&f z^o4fTlXyZ#S>cP|x}Z?!unBbt3htRg!yxu&ir}fJ+T79vhV0Ugb6OzKrqtex9rUzu zkpqZ-x=Utvu(1Qd5Xz(lF-}-;!X!ge%6$-wGmd92?j#mC6+>1~l3nlxuRtvUi7(Uv zMC^2#>ShdqpE^|nM(e2F(VYiQUz7(XWg)41rq_Jgshgz; zrX&!dGDqN3uO+mMe`&`|N(Nn^z%d^^Ax_O#q!fBNkcBZV4f!X&0T2jYvOqUrqEgMF z6cgq)eQ0PV1+gqUzhFv*c0mo}Ib9hPd1Eve@dha*2yPNmA^{ZF-1qPm|Upm=tU%kuGX7}Qc+@JSXrtS3`-FUL%0v1Ff>R`vx z3ECsxk>f)7Qf`Ey4f#xcUvJAt0vIcRF9`HPd0wqYprEK$^52C4>RmseFQeH0%%~q7 z^3#9J%DJtdGTp@JjCq-S+sa&TkTI}6qmKmYi6LBQ;7$Z>(s4_I6aThcHo$MtV)eq7 z;?ME`>J2jVV#t_=nYpzs2?K)|-C}GYh!Tt|pZEnl4XjiY_nf;> zWO=|PK*teBNS00V@=VK08FZ9`j~=p)+eg~z3mUd>TQ|%nH71Uow8`>`Uk;tc*TMqF z%mVv|JTpyW<#NViTUg-uS)lvP@0o42>5VbZVU6ee2P@0t8Na1F-`(7qPup}pyLe`{ zv_c0y-Di;WYIk#b%#dRuOE$(};Bd!-feb4Qz78nq@!91HS4JMw0BRg)8EwR6bBytn zDh3gtC=I4%hkI0jJx8N!^r!IMAJ{BU)QbXi{Tn3}>V7k^x8;;L_skpSc28;5iCVr7QLD0wq_qoXb}0e}VvZFBrM002M$Nkl&Fd zenNQzL*!R(E`d4)R(eCh1R3Z8pv<|Z{3>FVfMe`uS@Fk>5Yb+gc8B;>82n7>5Pdi! zWooJg@pIWN!sj?~I6j0wk1wDvROJLwq8qtp{CpoY)IOFtG(}B)DCOpXNI)qK0w1V# z`9%2ySF=NGsvn}Q1=@tbm3rbu-^B(Zs4ts7M+(%j#ZZnUiddT3x;zPPl_QKPL{`lS^`>OA+EkOiTB0t@sVXBbDF1sJ&+N)OWU}MSBwV2 zA-6f>4xT(uGmRvroZZYo2MMW%N<=eGA|A$4#gdwx|6#6Vu#_X_!s^PP6nxZ_4J|}) znL@LMC30}aG368_I%1$;r;RxU!EbsTFrz}D65-0In4&4Cl2K{BoRo!cJK;e{nVO0x zz=Z*n*mPEbL>g5-iYY1oJj8~+0Fkn^iv$XyM9*R?-3kr4+La8rB_9|dM@h=TGuZTv z+8Ez#urj_&_B0n;KPIzzmmnwzl(w}qh;Jy+KEXmj<&_ODp5X{U=sPX74~)CP!-Pfx zP}PTps00whp9hqPHqZ^K1=TR-scmJXPMv+Uh+61DD@f|B^To=08x)%&+mi}F#{16D zpX1>#{Sq+IO5Plmf7t6(%G6)KD1;iuKgx&Vwm0txh@`U5(LmkS!^G3u*Bxt}7DAgS zM4~yPjI_|9+N{2X)^Hvx!aq(ENx@22`y_?14i6pJA1J?nNUHSMV0x?VZ@t=1o<83{ z``p*})4iSk_T|@SWBQ!at4r9`*t1*hx zc4zIxO5dM4hc4VPHofOci$DLg-*`{N(&=-*0MKGvSm4-MfUM@g8yANbdF=-f_ zR>03_<)Fa$-~b7jR2itruNW<;H#>BQpw9OYkpTE0u=v3%HZ-e@XO1TzIs6DB83(Pb zzdOD-%y=O)<3A&nH}^q@Aq^OLgJKrydH$gz_q1BaL})l$&->@G1f#5}pOW}Va;oaU zG5DrJL15}|3JizTDPI*LUQ!#cU^uvQQy~VJ&{0gu-ab9~{CvE()P3gkr{>R0p6=FP z`K{5HuYa|B_|)TECRpiTyZtJpN8Q(b;BU^)JoeGi!S=Pbw{e9$1LJuq14&GJkj)$^ zo8qFmf{3at<~Y$102I{ho&rK`rqBp(sgtxNO$40l1|}Q;slB<44jd9g69Q{Y;y|da zs7fZ+a@dO(awK=4RE~-Ni#71Rl9_T8z$^;kbO9V=2Oy29>B-<@Y{D{0j}#IUgCEp{ zb20h&Z5;41E%9OuazNXOxBe1_;801;1vI#@vO;ml_KY%l8>WEppP&NENmFnvMOX=l zixSH3p$B}ypEKCRL+pKdqXn?Fc@?5l*Mt%{3Dlryf5k>%5Fl~%7E~j=!`m zq5=6m1tKkwKpp(2BjlN8>g^FyMH-S^J~n6DiT}`5UiRRsHGk}FV!;nseC0Hr;SSO= z6%M@Utsm6ef9#XOvVr{N?T1{__)?ew3>fDbF`0tvX%l=Pw3V}24WaCy;E2$%#slr6 zi!_$>i;d9Yi-WZKzbZ6F5^+fx`l0Bz^%77&V~9hw5RAjJsZj+AaGJ|1w!x=m zziA7^(PtXD=JeP!P1!wY#;AhobXzQa0>LiaUrVYa5LDXrL|n55QZzZZPFj`bG3WCvHilM@9p3H{Kvcd zd$a!Xl`Bld_qvspj-UKnmt^~uZ`_`L@Yz{+vYhb?IM~|*S%y>y=^CL1CEU>)7BQ10iww}ed zu)wjj!1e>}*LcrA$sRY`F&p95|Iz&3PS@_-AI(|eA3gby`M_v4B{!jOXDncVA?V3^ z88aEM7*Xu~9Xp&;Fd{e!O6ulNMeCR`*ip^k?-0WaavUukC#cJWl^r7szgFfGcye)W z1u&zbA80aIC2ymUG6puMv8k67Lo@Brw23ORDMVqK$-RG!NAQ4xc?Xm^bNcKcmt=<> z^%<=#g9ei@NAL_|CQG?-5G5r~EeuR1AZaoXK$8Iv{q14Y%esI=Ew(Z7ivhG!*RQb5 zvCWL+5Be9{^7wG{;`yIykFP%6Z(jMG{)LzSX#C)bN4hg-9%+~EUmab&f4x2XmiNw| ze&*Y_P3{z9=S%(G{X5*rNczJ4hzR2<2MLqvVLZ%(@EU<*Mpz`lOfj6=YB75%bNgh2 z5udvOI3)!{Jp2k#C04i4W0zzpu8P}3A98|asU-{jy4&R4)B--x@t{nrc+LXZG#Y-Z zi)#AG_z^W;HNgu0O6Aqj<8Y|d1`ER`(x-;Aql+9G+}gw^(n>k*Q}EzW zVtm1zX(deSin+FwFVeB50i;?6ArlY13M~wSxiHy4If8QxlT2u^yGA$sFbYW4h{TRf zX~ehQFHQrkhO9mqd@+s9f_gbqD0z!LgGlZ<7}i_Br$($=v7#DOSi{l3@E1r$C1Y@C zcYr*LDO^I}RKbiFaA2*r5l)3<5-5xE7y0$IOXAqUBxP(`T?sw*heQUtGN8~Xw^}khVk9lf#3jU>Ajn09 zAdT^Cf9!}#W+6V`oH{Q7V=QH$9udUntp0~EvV=c-Q)tVD85#(HMG_BUNuUzru(Yf` z+2LPfmOoxZiJrjor-G4~R)LwEG}>!u7o4eOD2r7@U_(D@kr?jci(Ta!60wv~1Ba<> zfMl16Dh>n{dhrNs0OG?bivad=(MX+OshZS9M3;m-#WvDnGF!rMyLW)$JS}mrf-Xes zFF(eK7okBPCh^b!!@-R^Kj5zOpx&Z!oHPW7d4eQhK+| zHGpja?H!FA5SURp9yvNOBA5v{Uc8yA znFlyPWSpQD#2R}T>iMJAVPx!2S()1a=hy^ohbbvRl0i)JW~l%r8O;FX@P?Qc3F#~i zc1gJ{@LplZD@QVy3_{qmjDZk^w5f-qu9ynLzXQ6XJ~}|zO-z#tikU?Ru-fg!(-2GBp;x@^b-tu$Z``11)KfL`X{bxV-8~t*>G=KE$ z+xp$v-ssi)m)q)@^ZoPh`>Wl>CqCA->2|-j{_^NxZ`}-qqyt-LuUBfh!SOi?z@AB# zq5@}q{xw-#G#H+05+HgoM)%4!R1Zn@A&HGP˅TyzN)Xy{i1z+V^Ae;_MNoDDCJ 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

odT(x(Q^gSP?}&QZQ=?4Qq}4&QLT0LNZN^m zwjAklaB939X8tseVRas^2M##Hj0p!g!xwQSxCx8IM>wA@{)=59IP>w$4Ji^-gc2hDOD=jJ5QdQc!aWaV zJYAa0|rIS>DP(f;@C?dHLo7XQDz*_?a+#tCCSygEKVeesWOHgBfQ_3PRKkG%zK zrZ+mSU)L6Rye+V@%Xa_HvztHi>h8$B`|E| za`GTE(y#OCeOpqhK8N@8P5~Izl`mEOtzLYmiFI{lSzMPLkV67vdAuB~mIEL&xA;j< z=VC29Ixp3kab3U_1CI>U5!xEvQB1X}Xnb1t!1mRDw?k4C4=mIyBS=>$DqYL%RRC2b zBcjgGGpLUaBV+LeSX`3?C8G8!%qk)E?V!@5#aifVsf1j`|L7C;EXouVM99}u zmcS%SwX6zjF855#287$Y*RcKpwU1(+Q6Be+TXLsz!0x6zi!#3qchsIet1QNHQ zzIvwc)i&;&D4ZA^!O^50v$K7=CFzoi7jgya+W3ZymIvUb@@pO5n^PKA}e1Z(N z7)mkN6q?~|gwPX1xN|>7_p2~x535Y}xI+v*N`{BWcri^f3wc``^|W<>oB#O@&W!Q2 z$rj5B6sN6doQHml6hqqbBVVD?oG`H!wTT(40chS*W!66}YaRM40@7D^-g4ZnA!PN+ zcUFVsCA#un1T=s77hc#sCm;ug+Zc>Y<$WZraUAMHyeVz$y25tw+kS{zFD9U&T}trX zQlm9pWUIX)D&P<&r2vU_ax|?oi_NHRztnkm$Z1Vm%Y& zytOXr79uVk4GI-lwsgiWf{TM@X7L%l@s|d)aAt0s75$

TDf7-RA`eRr484Ay8A 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%0DM;Yh!E(aEV}%;F9$U>mTQKOZc$fy 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 Mj&EQ!95U?x06XQQjsO4v literal 0 HcmV?d00001 diff --git a/linux/assets/fonts/JetBrainsMono-BoldItalic.ttf b/linux/assets/fonts/JetBrainsMono-BoldItalic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..6ff8032a5f1fafa87c455b0aa0d56d2406c61bc8 GIT binary patch literal 279172 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%0D&NFqpeTP-FNX}o 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

`NK~{(8UPzKw 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

gHHd7 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

D!n0dSj{ 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 CO;}|B literal 0 HcmV?d00001 diff --git a/linux/assets/fonts/JetBrainsMono-Italic.ttf b/linux/assets/fonts/JetBrainsMono-Italic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..71429b0108163b389f5db2ed0a9d17038966db88 GIT binary patch literal 276084 zcmcG%4V+a|`~QEfz1P}J&refQInK

2XSWQj8ii(*uPh%n(gA5h@|>5JE^o2qBDw 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

{HRB}dgO6WVCrv%U1Aze56ilBm5@z8eP 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%0ADPx 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

g?qYi$38nzUEZOK$Fz_(Y!<^!+M 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%0A~Y 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 literal 0 HcmV?d00001 diff --git a/linux/assets/fonts/JetBrainsMono-Regular.ttf b/linux/assets/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..711830ede02a366f8b99f88e52f3148405e67eaf GIT binary patch literal 270224 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

{Qq;dYoLXG*oL7!{wrG%~aKb=FoR7{s=k| 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

AzPi!HK1CYdMasJ%868x!dF;`ny|>LRT>eY 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 SharedString { + let configured = crate::settings::get(cx).font_family.trim(); + if configured.is_empty() { + FAMILY.into() + } else { + SharedString::from(configured.to_string()) + } +} diff --git a/linux/src/lib.rs b/linux/src/lib.rs index 04970fa31..eeacd7d6b 100644 --- a/linux/src/lib.rs +++ b/linux/src/lib.rs @@ -1,4 +1,5 @@ pub mod core; +pub mod fonts; pub mod i18n; pub mod keybindings; pub mod lsp; diff --git a/linux/src/main.rs b/linux/src/main.rs index 49e56379c..8d3a89806 100644 --- a/linux/src/main.rs +++ b/linux/src/main.rs @@ -1,6 +1,7 @@ //! Linux 工作台入口:初始化设置与主题,打开主窗口。 mod core; +mod fonts; mod i18n; mod keybindings; mod lsp; @@ -28,6 +29,8 @@ fn main() { app.run(move |cx| { gpui_kit::init(cx); + // 先注册内嵌字体,后续文本布局(含主题字体解析)才能用到它。 + fonts::register(cx); // 先加载持久化设置,再据此决定 gpui-component 主题与工作台调色板。 settings::init(cx); @@ -48,6 +51,8 @@ fn main() { None, cx, ); + // 主题切换会重设字体字段,因此每次都把内嵌字体族装回去。 + fonts::apply_theme(cx); cx.open_window(WindowOptions::default(), |window, cx| { let view = cx.new(|cx| WorkbenchView::new(window, cx)); diff --git a/linux/src/settings.rs b/linux/src/settings.rs index b7992710d..73833d6a4 100644 --- a/linux/src/settings.rs +++ b/linux/src/settings.rs @@ -278,7 +278,7 @@ impl Default for Settings { Self { auto_save: true, quick_open_preview: true, - font_family: "Geist Mono".to_string(), + font_family: crate::fonts::FAMILY.to_string(), font_size: 14.0, editor_line_height: 1.4, tab_size: 2, @@ -477,9 +477,24 @@ pub fn load() -> Settings { }; let mut settings = serde_json::from_str::(&text).unwrap_or_default(); settings.display_language = normalized_display_language(&settings.display_language); + settings.font_family = migrated_font_family(&settings.font_family); settings } +/// 历史版本的默认编辑器字体。这些值只是旧默认,不是用户的真实选择, +/// 加载时迁移到当前默认字体,避免设置弹窗显示一个并未生效的字体名。 +const LEGACY_DEFAULT_FONT_FAMILIES: &[&str] = &["Geist Mono"]; + +/// 归一化编辑器字体:空值或历史默认值 → 当前内嵌默认字体。 +fn migrated_font_family(configured: &str) -> String { + let trimmed = configured.trim(); + if trimmed.is_empty() || LEGACY_DEFAULT_FONT_FAMILIES.contains(&trimmed) { + crate::fonts::FAMILY.to_string() + } else { + trimmed.to_string() + } +} + /// 将设置写回磁盘(临时文件 + 原子替换)。 pub fn persist(settings: &Settings) { let Some(path) = config_path() else { @@ -557,6 +572,7 @@ pub fn apply_theme(theme_id: &str) { #[cfg(test)] mod tests { + use super::migrated_font_family; use super::normalized_display_language; #[test] @@ -570,4 +586,14 @@ mod tests { fn unknown_display_language_uses_product_default() { assert_eq!(normalized_display_language("fr-FR"), "zh-CN"); } + + /// 空值与旧默认字体迁移到当前默认字体;用户显式选择的其他字体保持不变。 + #[test] + fn font_family_migrates_legacy_defaults() { + let default = crate::fonts::FAMILY; + assert_eq!(migrated_font_family(""), default); + assert_eq!(migrated_font_family(" "), default); + assert_eq!(migrated_font_family("Geist Mono"), default); + assert_eq!(migrated_font_family("Noto Sans Mono"), "Noto Sans Mono"); + } } diff --git a/linux/src/workbench/bottom_panel.rs b/linux/src/workbench/bottom_panel.rs index 340c5d235..38931916a 100644 --- a/linux/src/workbench/bottom_panel.rs +++ b/linux/src/workbench/bottom_panel.rs @@ -1881,7 +1881,7 @@ impl BottomPanelView { .child( div() .truncate() - .font_family("monospace") + .font_family(crate::fonts::mono_family(cx)) .child(format!("{} {}", item.kind, item.detail)), ), ) @@ -1953,7 +1953,7 @@ impl BottomPanelView { .track_scroll(&self.run_scroll) .vertical_scrollbar(&self.run_scroll) .p_2() - .font_family("monospace") + .font_family(crate::fonts::mono_family(cx)) .text_xs() .text_color(ThemeColors::text_primary()) .children(output), @@ -1983,7 +1983,7 @@ impl BottomPanelView { .min_h_0() .overflow_y_scrollbar() .p_2() - .font_family("monospace") + .font_family(crate::fonts::mono_family(cx)) .text_xs() .text_color(ThemeColors::text_primary()) .children(output) @@ -2035,7 +2035,7 @@ impl BottomPanelView { .child( div() .flex_shrink_0() - .font_family("monospace") + .font_family(crate::fonts::mono_family(cx)) .text_color(ThemeColors::accent_blue()) .child(entry.hash.clone()), ) @@ -2072,7 +2072,7 @@ impl Render for BottomPanelView { let status = self.terminal_status(cx); let (at_bottom, has_session) = { let terminal = self.terminal.read(cx); - (terminal.is_at_bottom(), terminal.has_session()) + (terminal.is_at_bottom(cx), terminal.has_session()) }; ( self.render_pane_header( @@ -2112,7 +2112,7 @@ impl Render for BottomPanelView { |this, _window, cx| { let _ = this .terminal - .update(cx, |term, _cx| term.scroll_to_bottom()); + .update(cx, |term, cx| term.scroll_to_bottom(cx)); }, ), Self::header_button( diff --git a/linux/src/workbench/maven.rs b/linux/src/workbench/maven.rs index 645e95506..127281226 100644 --- a/linux/src/workbench/maven.rs +++ b/linux/src/workbench/maven.rs @@ -1191,7 +1191,7 @@ impl MavenView { el.child( div() .truncate() - .font_family("monospace") + .font_family(crate::fonts::mono_family(cx)) .text_xs() .text_color(ThemeColors::text_muted()) .child(text), @@ -1392,7 +1392,7 @@ impl MavenView { .when(!kind.is_empty(), |el| { el.child( div() - .font_family("monospace") + .font_family(crate::fonts::mono_family(cx)) .text_xs() .text_color(ThemeColors::text_muted()) .child(kind.clone()), @@ -1678,7 +1678,7 @@ impl MavenView { div() .flex_1() .truncate() - .font_family("monospace") + .font_family(crate::fonts::mono_family(cx)) .text_xs() .text_color(ThemeColors::text_muted()) .child(dep_subtitle(dep, cx)), diff --git a/linux/src/workbench/project_dialog.rs b/linux/src/workbench/project_dialog.rs index c74560d09..b4c0dd393 100644 --- a/linux/src/workbench/project_dialog.rs +++ b/linux/src/workbench/project_dialog.rs @@ -1001,7 +1001,7 @@ impl ProjectDialog { div() .px_3() .py_2() - .font_family("monospace") + .font_family(crate::fonts::mono_family(cx)) .text_xs() .text_color(crate::theme::ThemeColors::foreground()) .child(dest), @@ -1142,7 +1142,7 @@ impl ProjectDialog { .rounded_sm() .border_1() .cursor_pointer() - .font_family("monospace") + .font_family(crate::fonts::mono_family(cx)) .when(is_active, |row| { row.border_color(crate::theme::ThemeColors::primary()) .bg(crate::theme::ThemeColors::background()) @@ -1262,7 +1262,7 @@ impl ProjectDialog { .w_full() .text_center() .truncate() - .font_family("monospace") + .font_family(crate::fonts::mono_family(cx)) .text_xs() .text_color(crate::theme::ThemeColors::subtle_foreground()) .child(self.destination_path()), diff --git a/linux/src/workbench/settings_dialog.rs b/linux/src/workbench/settings_dialog.rs index 2e5c98ada..4b52fca83 100644 --- a/linux/src/workbench/settings_dialog.rs +++ b/linux/src/workbench/settings_dialog.rs @@ -771,9 +771,9 @@ impl SettingsDialog { cx: &mut Context, toggle: impl Fn(&mut Self, &mut Context) + 'static, ) -> impl IntoElement { - Switch::new(id).checked(value).on_click(cx.listener( - move |this, _event, _window, cx| toggle(this, cx), - )) + Switch::new(id) + .checked(value) + .on_click(cx.listener(move |this, _event, _window, cx| toggle(this, cx))) } /// 数值步进器:减号 + 当前值 + 加号。 @@ -1239,7 +1239,7 @@ impl SettingsDialog { .gap_4() .child( div() - .font_family("monospace") + .font_family(crate::fonts::mono_family(cx)) .text_xs() .text_color(ThemeColors::foreground()) .child(root), @@ -1375,7 +1375,7 @@ impl SettingsDialog { .border_1() .border_color(ThemeColors::border()) .bg(ThemeColors::background()) - .font_family("monospace") + .font_family(crate::fonts::mono_family(cx)) .text_xs() .text_color(ThemeColors::foreground()) .child(name.clone()) @@ -1446,14 +1446,17 @@ impl SettingsDialog { s.font_family.clone(), 176.0, vec![ - ("Geist Mono", "Geist Mono".to_string()), + (crate::fonts::FAMILY, crate::fonts::FAMILY.to_string()), ("DejaVu Sans Mono", "DejaVu Sans Mono".to_string()), ("Noto Sans Mono", "Noto Sans Mono".to_string()), ("monospace", "monospace".to_string()), ], cx, |this, family, cx| { - this.commit(cx, |s| s.font_family = family.to_string()) + this.commit(cx, |s| s.font_family = family.to_string()); + // 编辑器/代码/终端都跟随等宽字体,改完立即生效并重绘。 + crate::fonts::apply_theme(cx); + cx.refresh_windows(); }, ), )) diff --git a/linux/src/workbench/terminal.rs b/linux/src/workbench/terminal.rs index cf480a861..5f577dee3 100644 --- a/linux/src/workbench/terminal.rs +++ b/linux/src/workbench/terminal.rs @@ -1,55 +1,45 @@ -//! Linux 内嵌终端:`portable-pty` 拥有 PTY 与子进程生命周期, -//! `alacritty_terminal` 拥有全部终端语义(ANSI 解析、网格、回滚、选择、搜索、 -//! resize/reflow),本文件只做四件事: +//! Linux 内嵌终端:终端引擎、渲染、键鼠输入、选择与剪贴板全部复用 vendored +//! `gpui_xterm`(上游 Modolet/gpui_xterm,见 `third_party/gpui_xterm/`)。 //! -//! 1. **会话层**([`TerminalSession`]):打开/关闭 PTY,回收子进程与 reader -//! 线程,上报退出码与信号;终止路径复用 `run::process` 的进程组契约。 -//! 2. **事件接线**:键盘与鼠标映射成 PTY 字节,或 IDE 行为(搜索、视口滚动、 -//! 复制粘贴、回到底部)。 -//! 3. **渲染投影**:把上游 `display_iter` 的可见网格按实测单元格宽度排版成 -//! GPUI 元素,语义全部来自上游 `Flags` / `TermMode` / `CursorShape`。 -//! 4. **宿主 API**:新建/关闭会话、切换工作目录、清屏、程序化发送命令。 +//! 本文件只保留 Lithe 拥有的三层职责,不再手写 ANSI 解析、网格、光标或键位映射: //! -//! 必须遵守的边界:终端语义只能来自 `alacritty_terminal`。不要在这里手写 -//! ANSI 解析、换行、光标移动或滚动缓冲;滚动只调 `Term::scroll_display`,复制 -//! 只调 `Term::selection_to_string`,搜索只用 `term::search::RegexSearch` + -//! `Term::search_next`,缩放只调 `Term::resize`(reflow 由上游完成)。 +//! 1. **会话编排**([`TerminalSession`]):打开/关闭 PTY,回收子进程与等待线程, +//! 上报退出码与信号;终止路径复用 `run::process` 的进程组契约。 +//! 2. **工程接线**:把工作目录、平台 shell、字体/回滚设置与主题色映射成 +//! `gpui_xterm::TerminalConfig`,把组件上报的行列数同步给 PTY。 +//! 3. **宿主能力**:在组件之上补它未提供的产品能力——终端内搜索浮层、程序化 +//! 发送命令、清屏、回到底部与退出状态展示。 //! -//! 适配声明:当前是单会话 UI——没有多标签、横向分屏、超链接点击与 Sixel/Kitty -//! 图形协议。会话层不依赖 GPUI,后续加标签只需在 [`TerminalView`] 上并列多个 -//! [`TerminalSession`],不需要改引擎接线。 +//! 适配声明:组件本身不提供搜索、程序化写入与显示偏移查询,因此 vendored 副本 +//! 暴露了 `TerminalView::state()`(见 `third_party/gpui_xterm/README.md` 的本地 +//! 补丁清单)。除此之外不扩展组件的渲染与输入职责。 //! -//! Note: 引擎复用边界与禁止手写终端语义的原因见 -//! `.agents/notes/implemented/architecture/2026-09-25-linux-gpui-terminal-engine-reuse.md`。 +//! Note: 组件复用边界、被否方案与依赖适配原因见 +//! `.agents/notes/implemented/architecture/2026-09-26-linux-gpui-terminal-component-reuse.md`。 -use std::cell::Cell as StdCell; use std::collections::BTreeMap; -use std::io::{Read, Write}; -use std::rc::Rc; +use std::io::{self, Read, Write}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{mpsc, Arc, Condvar, Mutex}; use std::thread::{self, JoinHandle}; use std::time::Duration; -use alacritty_terminal::event::VoidListener; -use alacritty_terminal::grid::{Dimensions, Scroll}; +use alacritty_terminal::event::EventListener; +use alacritty_terminal::grid::Dimensions as _; use alacritty_terminal::index::{Column, Direction, Line, Point as GridPoint, Side}; use alacritty_terminal::selection::{Selection, SelectionType}; -use alacritty_terminal::term::cell::Flags; use alacritty_terminal::term::search::{Match, RegexSearch}; -use alacritty_terminal::term::{Config, Term, TermMode}; -use alacritty_terminal::vte::ansi::{Color as AlacColor, CursorShape, NamedColor, Processor, Rgb}; +use alacritty_terminal::term::Term; use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::component::input::InputEvent; use gpui_kit::component::{h_flex, v_flex, Disableable as _, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, App, Bounds, ClipboardItem, Context, FocusHandle, FontWeight, InteractiveElement as _, - IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, - ParentElement as _, Pixels, Point, Render, Rgba, ScrollDelta, ScrollWheelEvent, Size, - StatefulInteractiveElement as _, Styled as _, Subscription, Window, + div, px, App, AppContext as _, Context, Edges, Entity, InteractiveElement as _, IntoElement, + KeyDownEvent, ParentElement as _, Render, Rgba, Styled as _, Subscription, Window, }; -use portable_pty::{native_pty_system, Child, ChildKiller, CommandBuilder, MasterPty, PtySize}; +use gpui_xterm::{ColorPalette, TerminalConfig, TerminalView as XtermView}; +use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize}; use crate::settings; use crate::theme::{self, ThemeColors}; @@ -60,23 +50,18 @@ use crate::workbench::search_input::SearchInput; // 常量 // --------------------------------------------------------------------------- -/// 引导网格尺寸:只用于 `Term::new` 与“首次开会话”这一刻。首个 prepaint -/// 拿到实测容器尺寸后立刻 `Term::resize` + PTY resize,不会停留在该值。 +/// 引导网格尺寸:只用于 PTY 首次打开与组件初始网格。组件首次测量到容器尺寸后 +/// 会经 resize 回调把真实行列数报给 PTY,不会停留在该值。 const BOOTSTRAP_COLS: usize = 80; const BOOTSTRAP_ROWS: usize = 24; -/// 网格最小尺寸,避免退化到 0 列触发上游断言(上游要求至少 2 列放全角字符)。 +/// PTY 行列数边界,避免退化到 0 或上报荒谬尺寸。 const MIN_COLS: usize = 2; const MIN_ROWS: usize = 1; - -/// PTY 行列数上限:超过这个尺寸的“窗口”多半是布局异常,钳住避免无意义 resize。 const MAX_COLS: usize = 1000; const MAX_ROWS: usize = 1000; -/// 行高相对字号的倍率(等宽终端常用 1.4)。 -const LINE_HEIGHT_RATIO: f32 = 1.4; - -/// 网格内容内边距(左侧与上侧偏移,鼠标命中换算需扣除)。 +/// 网格内容内边距(与旧实现的 4px 视觉密度保持一致)。 const GRID_PADDING: f32 = 4.0; /// 关闭会话时先温和终止的等待时长;超时升级为强杀。与 `run::process` 的停止 @@ -86,12 +71,6 @@ const SESSION_STOP_GRACE: Duration = Duration::from_secs(2); /// 强杀后的兜底等待时长;仍不退出即放弃等待(子进程已收到 KILL,由内核回收)。 const SESSION_KILL_GRACE: Duration = Duration::from_millis(500); -/// reader 线程单次读取的字节数。 -const READER_CHUNK: usize = 16 * 1024; - -/// 光标闪烁间隔(设置 `terminal_cursor_blink` 打开时使用)。 -const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(530); - /// 搜索命中的计数上限:回滚缓冲可能有十万行,正则全量扫描必须有界。 /// 超出后计数带 `+`,导航仍然正确(只截断总数展示)。 const SEARCH_MATCH_CAP: usize = 512; @@ -99,12 +78,6 @@ const SEARCH_MATCH_CAP: usize = 512; /// 亮色变体向前景色混合的比例(ANSI 8-15 = 基础色 + 前景色)。 const BRIGHT_MIX: f32 = 0.55; -/// 暗色变体向背景色混合的比例(`DimXxx` = 基础色 + 背景色)。 -const DIM_MIX: f32 = 0.45; - -/// `Flags::DIM` 的前景向背景混合比例。 -const CELL_DIM_MIX: f32 = 0.35; - /// 终端 PTY 的 `TERM`:声明 256 色,与 `COLORTERM=truecolor` 配套。 const PTY_TERM: &str = "xterm-256color"; const PTY_COLORTERM: &str = "truecolor"; @@ -135,97 +108,6 @@ const HOST_TERMINAL_VARIABLES: &[&str] = &[ "TERMINAL_SCREEN", ]; -// --------------------------------------------------------------------------- -// 网格与几何(纯逻辑) -// --------------------------------------------------------------------------- - -/// 网格尺寸(`Term::new` 与 `resize` 共用)。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct TermDims { - cols: usize, - rows: usize, -} - -impl Dimensions for TermDims { - fn total_lines(&self) -> usize { - self.rows - } - - fn screen_lines(&self) -> usize { - self.rows - } - - fn columns(&self) -> usize { - self.cols - } -} - -/// 单元格像素尺寸。渲染排版、鼠标命中换算与 PTY 行列数三处必须共用它, -/// 否则宽字符与样式分段会出现半格偏移。 -#[derive(Debug, Clone, Copy, PartialEq)] -struct CellMetrics { - width: f32, - height: f32, -} - -impl CellMetrics { - /// 按当前字号实测等宽字符宽度;行高按倍率换算。 - fn measure(window: &Window, font_size: Pixels) -> Self { - let font = gpui_kit::Font { - family: "monospace".into(), - ..Default::default() - }; - let font_id = window.text_system().resolve_font(&font); - let width = f32::from(window.text_system().em_layout_width(font_id, font_size)).max(1.0); - Self { - width, - height: f32::from(font_size) * LINE_HEIGHT_RATIO, - } - } - - /// 容器内容区尺寸 → 网格行列数。容器还没布局完成(宽或高 ≤ 0)时返回 - /// `None`,此时不得 resize 上游网格。 - fn grid_dims(&self, size: Size) -> Option { - let width = f32::from(size.width) - GRID_PADDING * 2.0; - let height = f32::from(size.height) - GRID_PADDING * 2.0; - if width <= 0.0 || height <= 0.0 { - return None; - } - Some(TermDims { - cols: ((width / self.width).floor() as usize).clamp(MIN_COLS, MAX_COLS), - rows: ((height / self.height).floor() as usize).clamp(MIN_ROWS, MAX_ROWS), - }) - } -} - -/// 容器内局部坐标(已扣除容器原点)→ 网格点。 -/// -/// 先扣除网格内边距,再按单元格尺寸取整;越界返回 `None`。视口行需叠加 -/// 回滚偏移才是网格行(上游 `viewport_to_point` 语义),这样上滚后鼠标 -/// 选择仍命中用户看到的同一行。 -fn viewport_to_grid_point( - local_x: f32, - local_y: f32, - cell: CellMetrics, - size: TermDims, - display_offset: usize, -) -> Option { - let local_x = local_x - GRID_PADDING; - let local_y = local_y - GRID_PADDING; - if local_x < 0.0 || local_y < 0.0 { - return None; - } - - let col = (local_x / cell.width).floor() as usize; - let viewport_line = (local_y / cell.height).floor() as usize; - if col >= size.cols || viewport_line >= size.rows { - return None; - } - - let line = Line(viewport_line as i32 - display_offset as i32); - Some(GridPoint::new(line, Column(col))) -} - // --------------------------------------------------------------------------- // 生命周期状态机(纯逻辑) // --------------------------------------------------------------------------- @@ -242,7 +124,7 @@ pub enum TerminalExit { } impl TerminalExit { - /// 展示用明细:供 i18n 模板的 `{detail}` 替换。 + /// 展示用明细:供宿主状态区展示。 pub fn detail(&self) -> String { match self { Self::Code(code) => code.to_string(), @@ -256,7 +138,7 @@ impl TerminalExit { /// 空态、状态条与是否允许发送输入。 #[derive(Debug, Clone, PartialEq, Eq)] pub enum SessionState { - /// 还没有 PTY 会话(面板刚建,或用户显式关闭了会话)。 + /// 还没有 PTY 会话(面板刚建、创建失败,或用户显式关闭了会话)。 Closed, /// 子进程在跑。 Running, @@ -367,9 +249,6 @@ fn resolve_shell(configured: &str) -> String { } /// 平台默认 shell:Unix 用登录 shell(`$SHELL`),Windows 用 `COMSPEC`。 -/// -/// 两个变量都缺失时给出该平台最可能存在的回退名,避免空 program 导致 PTY -/// 启动失败。 fn default_shell() -> String { if cfg!(unix) { std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()) @@ -379,9 +258,6 @@ fn default_shell() -> String { } /// 列出在 `dir` 下应该尝试的 shell 可执行文件名。 -/// -/// Windows 上按 `PATHEXT` 补全(`pwsh` -> `pwsh.exe`);名字已带扩展名或非 -/// Windows 平台时只尝试原名。 fn shell_candidates(dir: &std::path::Path, name: &str) -> Vec { let mut candidates = vec![dir.join(name)]; if cfg!(windows) && std::path::Path::new(name).extension().is_none() { @@ -395,252 +271,7 @@ fn shell_candidates(dir: &std::path::Path, name: &str) -> Vec for KeyModifiers { - fn from(value: &gpui_kit::Modifiers) -> Self { - Self { - control: value.control, - alt: value.alt, - shift: value.shift, - platform: value.platform, - } - } -} - -/// 按键的最终归宿。 -#[derive(Debug, Clone, PartialEq, Eq)] -enum KeyAction { - /// 写入 PTY 的字节。 - Send(Vec), - /// 复制当前选区。 - Copy, - /// 从剪贴板粘贴。 - Paste, - /// 打开终端内搜索。 - OpenSearch, - /// 视口相对滚动(正数向上,即更早的输出)。 - Scroll(isize), - /// 回到输出末尾。 - ScrollToBottom, - /// 不消费,交给工作台全局快捷键。 - Passthrough, -} - -/// 键名 → 终端字节序列。方向键/Home/End 同时给出普通模式与应用光标模式两套, -/// 模式由上游 `TermMode::APP_CURSOR` 决定。 -fn key_bytes(key: &str, term_mode: TermMode) -> Option> { - // 显式标注成切片:带 `~` 的序列长度与方向键不同,数组字面量无法统一。 - let (normal, application): (&[u8], &[u8]) = match key { - "up" => (b"\x1b[A", b"\x1bOA"), - "down" => (b"\x1b[B", b"\x1bOB"), - "right" => (b"\x1b[C", b"\x1bOC"), - "left" => (b"\x1b[D", b"\x1bOD"), - "home" => (b"\x1b[H", b"\x1bOH"), - "end" => (b"\x1b[F", b"\x1bOF"), - "insert" => (b"\x1b[2~", b"\x1b[2~"), - "delete" => (b"\x1b[3~", b"\x1b[3~"), - "pageup" => (b"\x1b[5~", b"\x1b[5~"), - "pagedown" => (b"\x1b[6~", b"\x1b[6~"), - "f1" => (b"\x1bOP", b"\x1bOP"), - "f2" => (b"\x1bOQ", b"\x1bOQ"), - "f3" => (b"\x1bOR", b"\x1bOR"), - "f4" => (b"\x1bOS", b"\x1bOS"), - "f5" => (b"\x1b[15~", b"\x1b[15~"), - "f6" => (b"\x1b[17~", b"\x1b[17~"), - "f7" => (b"\x1b[18~", b"\x1b[18~"), - "f8" => (b"\x1b[19~", b"\x1b[19~"), - "f9" => (b"\x1b[20~", b"\x1b[20~"), - "f10" => (b"\x1b[21~", b"\x1b[21~"), - "f11" => (b"\x1b[23~", b"\x1b[23~"), - "f12" => (b"\x1b[24~", b"\x1b[24~"), - _ => return None, - }; - Some( - if term_mode.contains(TermMode::APP_CURSOR) { - application - } else { - normal - } - .to_vec(), - ) -} - -/// Ctrl + 字母/符号 → 控制字符(Ctrl+C 中断、Ctrl+D EOF 等)。 -fn control_byte(key: &str) -> Option> { - let lower = key.to_lowercase(); - let mut chars = lower.chars(); - match (chars.next(), chars.next()) { - (Some(c @ 'a'..='z'), None) => Some(vec![(c as u8) - b'a' + 1]), - (Some(' '), None) => Some(vec![0]), - (Some('['), None) => Some(vec![0x1b]), - (Some('\\'), None) => Some(vec![0x1c]), - (Some(']'), None) => Some(vec![0x1d]), - (Some('^'), None) => Some(vec![0x1e]), - (Some('_'), None) => Some(vec![0x1f]), - _ => None, - } -} - -/// 单个按键的映射结果。`visible_rows` 用于把翻页动作换算成行数。 -fn map_key( - key: &str, - key_char: Option<&str>, - mods: KeyModifiers, - term_mode: TermMode, - visible_rows: usize, -) -> KeyAction { - // ---- IDE 语义:复制/粘贴/搜索/视口滚动(放在控制字符之前,避免被吞)---- - // 对齐 Windows 终端的 `getTerminalKeyAction`:非 macOS 平台 - // Ctrl+Shift+C/V 为复制粘贴,Ctrl+V 也直接粘贴(否则 shell 只会显示 ^V)。 - if mods.control && !mods.alt && !mods.platform { - let lower = key.to_lowercase(); - if mods.shift { - match lower.as_str() { - "c" => return KeyAction::Copy, - "v" => return KeyAction::Paste, - "f" => return KeyAction::OpenSearch, - _ => {} - } - } else { - match lower.as_str() { - "v" => return KeyAction::Paste, - "f" => return KeyAction::OpenSearch, - "end" => return KeyAction::ScrollToBottom, - _ => {} - } - } - } - // Shift+翻页:滚视口,不把按键交给 PTY(xterm/IDEA 的共同行为)。 - if mods.shift && !mods.control && !mods.alt && !mods.platform { - let page = visible_rows.max(1) as isize; - match key { - "pageup" => return KeyAction::Scroll(page), - "pagedown" => return KeyAction::Scroll(-page), - _ => {} - } - } - - // ---- 直输与控制字符 ---- - if mods.control && !mods.alt && !mods.platform { - return match control_byte(key) { - Some(bytes) => KeyAction::Send(bytes), - // Ctrl+其它组合键归工作台(如 Ctrl+W 关标签)。 - None => KeyAction::Passthrough, - }; - } - if mods.alt && !mods.control && !mods.platform { - // Alt+字符 → ESC 前缀(readline Meta 键);Alt+方向键 → 词移动。 - return match key { - "left" => KeyAction::Send(b"\x1b[b".to_vec()), - "right" => KeyAction::Send(b"\x1b[f".to_vec()), - "backspace" => KeyAction::Send(b"\x1b\x7f".to_vec()), - _ => match key_char { - Some(text) if text.chars().count() == 1 => { - let mut out = vec![0x1b]; - out.extend_from_slice(text.as_bytes()); - KeyAction::Send(out) - } - _ => KeyAction::Passthrough, - }, - }; - } - // 平台键(Super/Win)与 AltGr(Ctrl+Alt)都不是终端输入,放行给上层。 - if mods.platform || (mods.control && mods.alt) { - return KeyAction::Passthrough; - } - - match key { - "enter" => KeyAction::Send(b"\r".to_vec()), - "backspace" => KeyAction::Send(vec![0x7f]), - "tab" => KeyAction::Send(b"\t".to_vec()), - "escape" => KeyAction::Send(vec![0x1b]), - _ => { - if let Some(bytes) = key_bytes(key, term_mode) { - return KeyAction::Send(bytes); - } - match key_char { - // 可打印字符(含中文)原样直输。 - Some(text) if !text.is_empty() => KeyAction::Send(text.as_bytes().to_vec()), - _ => KeyAction::Passthrough, - } - } - } -} - -// --------------------------------------------------------------------------- -// 鼠标上报(纯逻辑) -// --------------------------------------------------------------------------- - -/// 需要上报给终端应用的鼠标动作。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum MouseReport { - /// 按下;`button` 为 0 左 / 1 中 / 2 右。 - Press(u8), - /// 抬起。 - Release(u8), - /// 按住拖动。 - Drag(u8), -} - -/// 上游 `TermMode` 是否要求把该动作上报给应用(否则走本地文本选择)。 -fn mouse_report_enabled(action: MouseReport, mode: TermMode) -> bool { - match action { - MouseReport::Drag(_) => mode.contains(TermMode::MOUSE_MOTION), - _ => mode.contains(TermMode::MOUSE_REPORT_CLICK), - } -} - -/// 滚轮手势取值:Shift+滚轮在 Linux 上是横向手势,终端按行处理时也把它 -/// 当成纵向滚动。 -fn wheel_axis(shift: bool, x: f64, y: f64) -> f64 { - if shift && x != 0.0 { - x - } else { - y - } -} - -/// 编码鼠标上报字节。 -/// -/// 优先 SGR 1006(`CSI < b ; col ; row M/m`),回退 X10(`CSI M b+32 …`)。 -/// 行列按终端协议从 1 开始,并叠加回滚偏移(终端报告的是视口坐标)。 -fn encode_mouse_report( - action: MouseReport, - point: GridPoint, - display_offset: usize, - sgr: bool, -) -> Vec { - let (button, final_byte) = match action { - MouseReport::Press(button) => (button, b'M'), - MouseReport::Release(button) => (button + 3, b'm'), - MouseReport::Drag(button) => (button + 32, b'M'), - }; - let column = point.column.0 + 1; - let row = (point.line.0 + display_offset as i32 + 1).max(1) as usize; - if sgr { - format!("\x1b[<{button};{column};{row}{}", final_byte as char).into_bytes() - } else { - let mut out = vec![0x1b, b'[', b'M', 32 + button]; - out.push((32 + column.min(223)) as u8); - out.push((32 + row.min(223)) as u8); - out - } -} - -// --------------------------------------------------------------------------- -// 搜索(纯逻辑 + 上游正则) +// 搜索(Lithe 补能力,正则与命中语义来自上游 alacritty 引擎) // --------------------------------------------------------------------------- /// 命中序号(1 起)按方向推进并环绕;无命中返回 0。 @@ -669,7 +300,7 @@ fn match_label(current: usize, total: usize, truncated: bool) -> String { } /// 一次命中的下一搜索起点:命中结束后一列;行尾则换到下一行首列。 -fn next_search_origin(term: &Term, found: &Match) -> Option { +fn next_search_origin(term: &Term, found: &Match) -> Option { let end = *found.end(); if end.column < term.last_column() { return Some(GridPoint::new(end.line, Column(end.column.0 + 1))); @@ -680,14 +311,15 @@ fn next_search_origin(term: &Term, found: &Match) -> Option { None } -/// 从网格最旧一行开始收集全部命中,直到没有更多或达到 [`SEARCH_MATCH_CAP`]。 -/// -/// 顺序收集而不是“每次从光标重新搜”,这样“下一个/上一个”的计数、环绕和 -/// 高亮落点都与 xterm 的 SearchAddon 一致。 +/// 从网格最旧一行开始收集全部命中,直到没有更多或达到上限。 /// /// 上游 `search_next` 在“起点之后没有命中”时会回退返回第一个命中,因此必须 /// 自己判断是否已经环绕:命中起点不再前进即视为收集结束。 -fn collect_matches(term: &Term, regex: &mut RegexSearch, cap: usize) -> (Vec, bool) { +fn collect_matches( + term: &Term, + regex: &mut RegexSearch, + cap: usize, +) -> (Vec, bool) { let mut matches: Vec = Vec::new(); let mut origin = GridPoint::new(term.topmost_line(), Column(0)); while matches.len() < cap { @@ -706,7 +338,6 @@ fn collect_matches(term: &Term, regex: &mut RegexSearch, cap: usize) -> (V matches.push(found); match next { Some(next) => origin = next, - // 命中在缓冲末尾,没有下一处起点。 None => break, } } @@ -714,14 +345,14 @@ fn collect_matches(term: &Term, regex: &mut RegexSearch, cap: usize) -> (V (matches, truncated) } -/// 终端内搜索状态:查询串、上游正则缓存、有界的命中列表与当前序号。 +/// 终端内搜索状态:查询串、有界的命中列表与当前序号。命中集合是最新一次 +/// 扫描的快照,输出变化后由宿主重新扫描。 struct SearchState { query: String, - regex: Option, matches: Vec, /// 当前命中序号(1 起,0 表示无命中)。 current: usize, - /// 命中数是否被 [`SEARCH_MATCH_CAP`] 截断。 + /// 命中数是否被上限截断。 truncated: bool, } @@ -729,7 +360,6 @@ impl SearchState { fn new() -> Self { Self { query: String::new(), - regex: None, matches: Vec::new(), current: 0, truncated: false, @@ -760,38 +390,11 @@ impl SearchState { match_label(self.current, self.total(), self.truncated) } - /// 换查询串:重建上游正则并重新收集命中,序号复位到首个命中。 - fn set_query(&mut self, term: &Term, query: String) { - self.query = query; + fn clear(&mut self) { + self.query.clear(); self.matches.clear(); self.current = 0; self.truncated = false; - if self.query.is_empty() { - self.regex = None; - return; - } - match RegexSearch::new(&self.query) { - Ok(regex) => self.regex = Some(regex), - Err(error) => { - // 非法正则:当作无命中,并且不保留上一次的查询串。 - tracing::warn!("terminal search pattern rejected: {error}"); - self.query.clear(); - self.regex = None; - return; - } - } - self.jump_to_first(term); - } - - /// 跳到首个命中(无命中时保持 `current = 0`)。 - fn jump_to_first(&mut self, term: &Term) { - let Some(regex) = self.regex.as_mut() else { - return; - }; - let (matches, truncated) = collect_matches(term, regex, SEARCH_MATCH_CAP); - self.matches = matches; - self.truncated = truncated; - self.current = usize::from(!self.matches.is_empty()); } /// 上一个/下一个命中(环绕)。返回应高亮的范围。 @@ -803,232 +406,32 @@ impl SearchState { self.current = next_match_index(self.current, self.matches.len(), forward); self.current_match() } - - fn clear(&mut self) { - self.query.clear(); - self.regex = None; - self.matches.clear(); - self.current = 0; - self.truncated = false; - } -} - -// --------------------------------------------------------------------------- -// 渲染调色板(纯逻辑) -// --------------------------------------------------------------------------- - -fn rgba_of(rgb: (u8, u8, u8)) -> Rgba { - Rgba { - r: rgb.0 as f32 / 255.0, - g: rgb.1 as f32 / 255.0, - b: rgb.2 as f32 / 255.0, - a: 1.0, - } -} - -/// 终端渲染调色板:8 个基础色来自主题 `terminal_*` token,亮/暗变体与 -/// 256 色由基础色派生。仓库里不再保留第二份硬编码 ANSI 调色板。 -struct TermPalette { - /// black/red/green/yellow/blue/magenta/cyan/white。 - base: [Rgba; 8], - foreground: Rgba, - background: Rgba, - cursor: Rgba, - selection: Rgba, - dimmed_foreground: Rgba, -} - -impl TermPalette { - fn from_theme() -> Self { - let palette = theme::palette(); - Self { - base: [ - palette.terminal_black, - palette.terminal_red, - palette.terminal_green, - palette.terminal_yellow, - palette.terminal_blue, - palette.terminal_magenta, - palette.terminal_cyan, - palette.terminal_white, - ], - foreground: palette.foreground, - background: palette.background, - cursor: palette.foreground, - selection: palette.selection, - dimmed_foreground: palette.muted_foreground, - } - } - - /// 亮色变体(ANSI 8-15):基础色向前景色混合。 - fn bright(&self, index: usize) -> Rgba { - theme::mix(self.base[index], self.foreground, BRIGHT_MIX) - } - - /// 暗色变体(`DimXxx`):基础色向背景色混合。 - fn dim(&self, index: usize) -> Rgba { - theme::mix(self.base[index], self.background, DIM_MIX) - } - - /// 0-15 走主题 token,16-231 走立方体,232-255 走灰阶。 - fn indexed(&self, index: u8) -> Rgba { - match index { - 0..=7 => self.base[index as usize], - 8..=15 => self.bright(index as usize - 8), - 16..=231 => { - let n = index - 16; - let levels = [0u8, 95, 135, 175, 215, 255]; - rgba_of(( - levels[(n / 36) as usize], - levels[((n % 36) / 6) as usize], - levels[(n % 6) as usize], - )) - } - _ => { - let value = 8 + (index - 232) * 10; - rgba_of((value, value, value)) - } - } - } - - /// 命名色 → 主题色。`Foreground`/`Background`/`Cursor` 与 `Dim*` 变体 - /// 都在这里显式落地;不要用 `named as u8` 取值,那会把 256+ 的变体折回 - /// 基础色(历史上 `DimRed` 会被当成 `Yellow`)。 - fn named(&self, named: NamedColor) -> Rgba { - match named { - NamedColor::Foreground | NamedColor::BrightForeground => self.foreground, - NamedColor::DimForeground => self.dimmed_foreground, - NamedColor::Background => self.background, - NamedColor::Cursor => self.cursor, - NamedColor::Black => self.base[0], - NamedColor::Red => self.base[1], - NamedColor::Green => self.base[2], - NamedColor::Yellow => self.base[3], - NamedColor::Blue => self.base[4], - NamedColor::Magenta => self.base[5], - NamedColor::Cyan => self.base[6], - NamedColor::White => self.base[7], - NamedColor::BrightBlack => self.bright(0), - NamedColor::BrightRed => self.bright(1), - NamedColor::BrightGreen => self.bright(2), - NamedColor::BrightYellow => self.bright(3), - NamedColor::BrightBlue => self.bright(4), - NamedColor::BrightMagenta => self.bright(5), - NamedColor::BrightCyan => self.bright(6), - NamedColor::BrightWhite => self.bright(7), - NamedColor::DimBlack => self.dim(0), - NamedColor::DimRed => self.dim(1), - NamedColor::DimGreen => self.dim(2), - NamedColor::DimYellow => self.dim(3), - NamedColor::DimBlue => self.dim(4), - NamedColor::DimMagenta => self.dim(5), - NamedColor::DimCyan => self.dim(6), - NamedColor::DimWhite => self.dim(7), - } - } - - /// 语义颜色解算:加粗时 0-7 基础色自动取高亮 variant(标准终端行为)。 - fn resolve(&self, color: AlacColor, bold: bool) -> Rgba { - match color { - AlacColor::Named(named) => match named_base_index(named) { - Some(index) if bold => self.bright(index), - _ => self.named(named), - }, - AlacColor::Indexed(index) => { - self.indexed(if bold && index < 8 { index + 8 } else { index }) - } - AlacColor::Spec(Rgb { r, g, b }) => rgba_of((r, g, b)), - } - } -} - -/// 命名色是否是 0-7 的基础色(加粗高亮只对这 8 个色生效)。 -fn named_base_index(named: NamedColor) -> Option { - match named { - NamedColor::Black => Some(0), - NamedColor::Red => Some(1), - NamedColor::Green => Some(2), - NamedColor::Yellow => Some(3), - NamedColor::Blue => Some(4), - NamedColor::Magenta => Some(5), - NamedColor::Cyan => Some(6), - NamedColor::White => Some(7), - _ => None, - } -} - -/// 单个单元格解析出的前景/底色(已处理 `INVERSE` / `HIDDEN` / `DIM`)。 -fn resolve_cell_colors( - foreground: AlacColor, - background: AlacColor, - flags: Flags, - palette: &TermPalette, -) -> (Rgba, Rgba) { - let mut fg = palette.resolve(foreground, flags.contains(Flags::BOLD)); - let bg = palette.resolve(background, false); - if flags.contains(Flags::DIM) { - fg = theme::mix(fg, bg, CELL_DIM_MIX); - } - if flags.contains(Flags::INVERSE) { - return (bg, fg); - } - if flags.contains(Flags::HIDDEN) { - return (bg, bg); - } - (fg, bg) -} - -/// 光标呈现方式。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CursorStyle { - /// 反色块。 - Block, - /// 下划线。 - Underline, - /// 竖线。 - Beam, - /// 空心块。 - Hollow, - /// 隐藏。 - Hidden, -} - -impl From for CursorStyle { - fn from(shape: CursorShape) -> Self { - match shape { - CursorShape::Block => Self::Block, - CursorShape::Underline => Self::Underline, - CursorShape::Beam => Self::Beam, - CursorShape::HollowBlock => Self::Hollow, - CursorShape::Hidden => Self::Hidden, - } - } } // --------------------------------------------------------------------------- // 会话层 // --------------------------------------------------------------------------- -/// 会话事件:PTY 输出字节,或子进程被回收的结果。 +/// 会话事件:子进程已被回收,宿主应重绘以展示退出状态。退出码/信号本身由 +/// 会话层的生命周期状态持有,事件只承担“状态已变化”的通知职责。 #[derive(Debug)] pub enum SessionEvent { - Output(Vec), - /// 子进程已回收(正常退出、被信号杀死,或读取失败后的兜底状态)。 - Closed(TerminalExit), + /// 子进程已回收。 + Closed, } -/// 会话内部状态。会话句柄与升级/回收线程共享它,因此全部可跨线程。 +/// 会话内部状态。会话句柄与停机看门狗共享它,因此全部可跨线程。 struct SessionInner { - writer: Mutex>>, + /// 共享写端:会话与 `gpui_xterm` 组件各持一个句柄,共同写入同一 PTY。 + writer: Arc>>>, + /// 主端句柄:停机时释放以让 slave 收到 hangup,运行期用于 resize。 master: Mutex>>, - /// 只用于停机兜底的信号句柄:与 `child` 分离,避免和阻塞在 `wait` 的 - /// reader 线程抢同一把锁。 + /// 只用于停机兜底的信号句柄。 killer: Mutex>>, - child: Mutex>>, + /// 子进程等待线程:由停机看门狗负责 join。 + monitor: Mutex>>, /// 前台进程组 id(`portable-pty` 让子进程 `setsid`,因此它就是组长)。 pgid: Option, - /// 主端 reader 线程句柄:停止流程里由看门狗线程负责 join。 - reader: Mutex>>, stop_requested: AtomicBool, lifecycle: Mutex, finished: Mutex, @@ -1064,19 +467,63 @@ impl SessionInner { } /// 关闭主端:最后一个主端 fd 关闭后,slave 侧立刻收到 hangup, - /// 阻塞在 `read` 的 reader 线程也会返回。 + /// 阻塞在 `read` 的组件读取线程也会返回。 fn release_master(&self) { if let Ok(mut guard) = self.master.lock() { guard.take(); } } + + /// 把组件上报的行列数同步给 PTY。 + fn resize(&self, cols: usize, rows: usize) { + if let Ok(guard) = self.master.lock() { + if let Some(master) = guard.as_ref() { + let _ = master.resize(pty_size(cols, rows)); + } + } + } +} + +/// 共享写端:`gpui_xterm` 组件需要拿走一个 `Write` 句柄,宿主又需要保留 +/// 程序化写入(`send_command`)的能力,因此双方共享同一份底层 writer。 +#[derive(Clone)] +pub struct SharedWriter(Arc>>>); + +impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + let mut guard = self + .0 + .lock() + .map_err(|_| io::Error::new(io::ErrorKind::Other, "terminal writer poisoned"))?; + match guard.as_mut() { + Some(writer) => writer.write(buf), + None => Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "terminal writer closed", + )), + } + } + + fn flush(&mut self) -> io::Result<()> { + let mut guard = self + .0 + .lock() + .map_err(|_| io::Error::new(io::ErrorKind::Other, "terminal writer poisoned"))?; + match guard.as_mut() { + Some(writer) => writer.flush(), + None => Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "terminal writer closed", + )), + } + } } -/// Linux 原生 PTY 会话:拥有主端句柄、writer、子进程与 reader 线程。 +/// Linux 原生 PTY 会话:拥有主端句柄、共享写端与子进程等待线程。 /// /// 生命周期契约与 `run::process` 的受管进程一致:先温和终止、超过 -/// [`SESSION_STOP_GRACE`] 升级为强杀,最后 join reader 线程。只 `drop` 主端 -/// 句柄是不够的——那样拿不到退出码,也可能在子孙进程上留下残留。 +/// [`SESSION_STOP_GRACE`] 升级为强杀,最后 join 等待线程。只 `drop` 主端句柄 +/// 是不够的——那样拿不到退出码,也可能在子孙进程上留下残留。 pub struct TerminalSession { inner: Arc, /// 展示用 shell 路径。 @@ -1084,18 +531,15 @@ pub struct TerminalSession { } impl TerminalSession { - /// 打开 PTY 会话并起输出线程。返回会话与事件接收端。 + /// 打开 PTY 会话并起等待线程。返回会话、供组件读取的输出端与事件接收端。 /// - /// 线程只透传原始字节块;子进程回收也在同一线程完成,因此“输出停止” - /// 与“进程已退出”必然同时被观察到,不会出现 PTY 已死但子进程未 wait - /// 的窗口。 + /// 输出端交给 `gpui_xterm` 的读取线程;本会话只负责子进程生命周期。 pub fn new( - dims: TermDims, working_dir: &str, shell: &str, - ) -> anyhow::Result<(Self, mpsc::Receiver)> { + ) -> anyhow::Result<(Self, Box, mpsc::Receiver)> { let pty_system = native_pty_system(); - let pair = pty_system.openpty(pty_size(dims))?; + let pair = pty_system.openpty(pty_size(BOOTSTRAP_COLS, BOOTSTRAP_ROWS))?; let mut cmd = CommandBuilder::new(shell); if !working_dir.trim().is_empty() { @@ -1105,20 +549,20 @@ impl TerminalSession { cmd.env(key, value); } - let child = pair.slave.spawn_command(cmd)?; + let mut child = pair.slave.spawn_command(cmd)?; let killer = child.clone_killer(); let pgid = process_group_id(&pair.master, &child); - let mut reader = pair.master.try_clone_reader()?; + let reader = pair.master.try_clone_reader()?; let writer = pair.master.take_writer()?; + // slave 由父进程持有会让 PTY 永远看不到 hangup,spawn 后立刻释放。 + drop(pair.slave); - let (tx, rx) = mpsc::channel::(); let inner = Arc::new(SessionInner { - writer: Mutex::new(Some(writer)), + writer: Arc::new(Mutex::new(Some(writer))), master: Mutex::new(Some(pair.master)), killer: Mutex::new(Some(killer)), - child: Mutex::new(Some(child)), + monitor: Mutex::new(None), pgid, - reader: Mutex::new(None), stop_requested: AtomicBool::new(false), lifecycle: Mutex::new(SessionLifecycle::new()), finished: Mutex::new(false), @@ -1128,23 +572,26 @@ impl TerminalSession { lifecycle.apply(SessionEventKind::Spawned); } - let worker = { + let (tx, rx) = mpsc::channel::(); + let monitor = { let inner = inner.clone(); thread::Builder::new() - .name("lithe-terminal-pty".to_string()) + .name("lithe-terminal-wait".to_string()) .spawn(move || { - pump_output(&inner, &mut reader, &tx); - let exit = reap_child(&inner); + let exit = match child.wait() { + Ok(status) => exit_of(status), + Err(_) => TerminalExit::Unknown, + }; if let Ok(mut lifecycle) = inner.lifecycle.lock() { - lifecycle.apply(SessionEventKind::Reaped(exit.clone())); + lifecycle.apply(SessionEventKind::Reaped(exit)); } inner.mark_finished(); - let _ = tx.send(SessionEvent::Closed(exit)); + let _ = tx.send(SessionEvent::Closed); }) - .map_err(|error| anyhow::anyhow!("Could not start PTY reader: {error}"))? + .map_err(|error| anyhow::anyhow!("Could not start PTY waiter: {error}"))? }; - if let Ok(mut slot) = inner.reader.lock() { - *slot = Some(worker); + if let Ok(mut slot) = inner.monitor.lock() { + *slot = Some(monitor); } Ok(( @@ -1152,6 +599,7 @@ impl TerminalSession { inner, shell: shell.to_string(), }, + reader, rx, )) } @@ -1175,7 +623,18 @@ impl TerminalSession { &self.shell } - /// 写入终端输入(键盘直输、粘贴、鼠标上报共用这一条路径)。 + /// 供组件持有的共享写端;宿主也用它在程序化发送命令时写入同一 PTY。 + pub fn writer(&self) -> SharedWriter { + SharedWriter(self.inner.writer.clone()) + } + + /// 供组件注册的 resize 回调:组件测出真实行列数后同步 PTY。 + pub fn resize_callback(&self) -> Box { + let inner = self.inner.clone(); + Box::new(move |cols, rows| inner.resize(cols, rows)) + } + + /// 写入终端输入(程序化发送命令共用这一条路径)。 pub fn write_bytes(&self, bytes: &[u8]) -> anyhow::Result<()> { if bytes.is_empty() { return Ok(()); @@ -1196,18 +655,6 @@ impl TerminalSession { Ok(()) } - /// PTY 缩放:容器尺寸变化时调用,使 shell / 全屏 TUI 拿到最新行列数。 - pub fn resize(&self, dims: TermDims) { - if !self.is_attached() { - return; - } - if let Ok(guard) = self.inner.master.lock() { - if let Some(master) = guard.as_ref() { - let _ = master.resize(pty_size(dims)); - } - } - } - /// 请求关闭会话:立即返回,退出与回收由看门狗线程完成。 pub fn request_stop(&self) { if self.inner.stop_requested.swap(true, Ordering::AcqRel) { @@ -1224,7 +671,7 @@ impl TerminalSession { .name("lithe-terminal-stop".to_string()) .spawn(move || { stop_session(&inner, pgid); - if let Ok(mut slot) = inner.reader.lock() { + if let Ok(mut slot) = inner.monitor.lock() { if let Some(worker) = slot.take() { let _ = worker.join(); } @@ -1241,7 +688,7 @@ impl TerminalSession { .wait_finished(SESSION_STOP_GRACE + SESSION_KILL_GRACE) { // 看门狗线程也会尝试 join;`take()` 保证只有一个赢家。 - if let Ok(mut slot) = self.inner.reader.lock() { + if let Ok(mut slot) = self.inner.monitor.lock() { if let Some(worker) = slot.take() { let _ = worker.join(); } @@ -1285,10 +732,10 @@ fn stop_session(inner: &Arc, pgid: Option) { inner.mark_finished(); } -fn pty_size(dims: TermDims) -> PtySize { +fn pty_size(cols: usize, rows: usize) -> PtySize { PtySize { - rows: dims.rows.clamp(MIN_ROWS, MAX_ROWS) as u16, - cols: dims.cols.clamp(MIN_COLS, MAX_COLS) as u16, + rows: rows.clamp(MIN_ROWS, MAX_ROWS) as u16, + cols: cols.clamp(MIN_COLS, MAX_COLS) as u16, pixel_width: 0, pixel_height: 0, } @@ -1298,7 +745,7 @@ fn pty_size(dims: TermDims) -> PtySize { /// `setsid`,因此它就是组长),其次退回子进程 pid。 fn process_group_id( master: &Box, - child: &Box, + child: &Box, ) -> Option { #[cfg(unix)] if let Some(pgid) = master.process_group_leader() { @@ -1310,50 +757,6 @@ fn process_group_id( child.process_id().and_then(|pid| i32::try_from(pid).ok()) } -/// reader 线程:透传输出字节,读端结束后回收子进程。 -fn pump_output( - inner: &Arc, - reader: &mut Box, - tx: &mpsc::Sender, -) { - let mut buffer = vec![0u8; READER_CHUNK]; - loop { - match reader.read(&mut buffer) { - Ok(0) => break, - Ok(count) => { - if tx - .send(SessionEvent::Output(buffer[..count].to_vec())) - .is_err() - { - // 视图已释放:没人再消费输出,停止读;子进程由会话 Drop 拉起的 - // 看门狗线程终止。 - break; - } - } - Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, - Err(_) => break, - } - } - inner.release_writer(); -} - -/// 回收子进程:先非阻塞探测,再阻塞等待;看门狗线程负责兜底强杀。 -fn reap_child(inner: &Arc) -> TerminalExit { - let Ok(mut guard) = inner.child.lock() else { - return TerminalExit::Unknown; - }; - let Some(child) = guard.as_mut() else { - return TerminalExit::Unknown; - }; - if let Ok(Some(status)) = child.try_wait() { - return exit_of(status); - } - match child.wait() { - Ok(status) => exit_of(status), - Err(_) => TerminalExit::Unknown, - } -} - fn exit_of(status: portable_pty::ExitStatus) -> TerminalExit { match status.signal() { Some(signal) if !signal.is_empty() => TerminalExit::Signal(signal.to_string()), @@ -1362,121 +765,173 @@ fn exit_of(status: portable_pty::ExitStatus) -> TerminalExit { } // --------------------------------------------------------------------------- -// 视图层 +// 主题映射 // --------------------------------------------------------------------------- -/// 一个网格单元格的渲染数据(有序遍历 `display_iter` 时收集)。 -#[derive(Clone, Copy, PartialEq)] -struct TermCell { - /// 显示字符;`None` 表示宽字符占位格(只占宽度,不绘制)。 - c: Option, - fg: AlacColor, - bg: AlacColor, - flags: Flags, - selected: bool, +/// Rgba(0..1 浮点)→ 8 位 RGB,供 `ColorPaletteBuilder` 使用。 +fn rgb8(color: Rgba) -> (u8, u8, u8) { + ( + (color.r.clamp(0.0, 1.0) * 255.0).round() as u8, + (color.g.clamp(0.0, 1.0) * 255.0).round() as u8, + (color.b.clamp(0.0, 1.0) * 255.0).round() as u8, + ) } -/// 光标在一行中的落点。 -#[derive(Debug, Clone, Copy)] -struct CursorPlacement { - /// 该行是否就是光标行且光标可见(聚焦、未隐藏、视口在底部)。 - visible: bool, - /// 光标列(单元格下标)。 - column: usize, - style: CursorStyle, - /// 闪烁相位(`false` 表示处于熄灭阶段)。 - phase: bool, +/// 由工作台主题派生终端调色板:8 个基础色取 `terminal_*` token,亮色变体向前景 +/// 混合,前景/背景/光标取主题对应色。仓库里不再保留第二份硬编码 ANSI 调色板。 +fn terminal_color_palette() -> ColorPalette { + let p = theme::palette(); + let bright = |base: Rgba| rgb8(theme::mix(base, p.foreground, BRIGHT_MIX)); + ColorPalette::builder() + .background( + rgb8(p.background).0, + rgb8(p.background).1, + rgb8(p.background).2, + ) + .foreground( + rgb8(p.foreground).0, + rgb8(p.foreground).1, + rgb8(p.foreground).2, + ) + .cursor( + rgb8(p.foreground).0, + rgb8(p.foreground).1, + rgb8(p.foreground).2, + ) + .black( + rgb8(p.terminal_black).0, + rgb8(p.terminal_black).1, + rgb8(p.terminal_black).2, + ) + .red( + rgb8(p.terminal_red).0, + rgb8(p.terminal_red).1, + rgb8(p.terminal_red).2, + ) + .green( + rgb8(p.terminal_green).0, + rgb8(p.terminal_green).1, + rgb8(p.terminal_green).2, + ) + .yellow( + rgb8(p.terminal_yellow).0, + rgb8(p.terminal_yellow).1, + rgb8(p.terminal_yellow).2, + ) + .blue( + rgb8(p.terminal_blue).0, + rgb8(p.terminal_blue).1, + rgb8(p.terminal_blue).2, + ) + .magenta( + rgb8(p.terminal_magenta).0, + rgb8(p.terminal_magenta).1, + rgb8(p.terminal_magenta).2, + ) + .cyan( + rgb8(p.terminal_cyan).0, + rgb8(p.terminal_cyan).1, + rgb8(p.terminal_cyan).2, + ) + .white( + rgb8(p.terminal_white).0, + rgb8(p.terminal_white).1, + rgb8(p.terminal_white).2, + ) + .bright_black( + bright(p.terminal_black).0, + bright(p.terminal_black).1, + bright(p.terminal_black).2, + ) + .bright_red( + bright(p.terminal_red).0, + bright(p.terminal_red).1, + bright(p.terminal_red).2, + ) + .bright_green( + bright(p.terminal_green).0, + bright(p.terminal_green).1, + bright(p.terminal_green).2, + ) + .bright_yellow( + bright(p.terminal_yellow).0, + bright(p.terminal_yellow).1, + bright(p.terminal_yellow).2, + ) + .bright_blue( + bright(p.terminal_blue).0, + bright(p.terminal_blue).1, + bright(p.terminal_blue).2, + ) + .bright_magenta( + bright(p.terminal_magenta).0, + bright(p.terminal_magenta).1, + bright(p.terminal_magenta).2, + ) + .bright_cyan( + bright(p.terminal_cyan).0, + bright(p.terminal_cyan).1, + bright(p.terminal_cyan).2, + ) + .bright_white( + bright(p.terminal_white).0, + bright(p.terminal_white).1, + bright(p.terminal_white).2, + ) + .build() } -impl CursorPlacement { - /// 光标是否真的画在下标为 `column` 的单元格上。 - fn covers(&self, column: usize) -> bool { - self.visible && self.phase && column == self.column - } - - /// 行尾之后是否需要补一个光标块。 - fn trailing(&self, rendered: usize) -> bool { - self.visible && self.phase && self.column >= rendered - } +/// 判断一次按键是否是“打开终端内搜索”(Ctrl+F)。组件不认识这个快捷键,会把它 +/// 当控制字符写进 PTY,因此需要经由组件的按键钩子吞掉,再由宿主容器打开搜索栏。 +fn is_search_shortcut(event: &KeyDownEvent) -> bool { + let keystroke = &event.keystroke; + keystroke.modifiers.control + && !keystroke.modifiers.alt + && !keystroke.modifiers.platform + && keystroke.key.eq_ignore_ascii_case("f") } -/// 终端视图组件:单会话,键盘字符模式直输(IDEA 式交互)。 -/// -/// 网格引擎与交互能力全部来自上游 `alacritty_terminal`:本结构只负责调用 -/// 它的 resize/scroll/selection/search API,并把结果投影成 GPUI 元素。 +// --------------------------------------------------------------------------- +// 视图层 +// --------------------------------------------------------------------------- + +/// 终端视图组件:会话编排 + `gpui_xterm` 组件 + 宿主能力(搜索/清屏/发送命令)。 pub struct TerminalView { - /// 当前会话;`None` 表示尚未创建或已被显式关闭(渲染空态)。 + /// `gpui_xterm` 组件实体;`None` 表示无会话(渲染空态)。 + xterm: Option>, + /// 当前会话;`None` 表示尚未创建、创建失败或已被显式关闭。 session: Option, /// 会话工作目录(宿主项目切换时更新)。 working_dir: String, - /// 会话代号:每次新建/关闭自增,输出泵据此丢弃过期会话的字节。 + /// 会话代号:每次新建/关闭自增,事件泵据此丢弃过期会话的事件。 session_seq: u64, - /// 是否仍允许在首个 prepaint 自动创建会话。自动创建只发生一次:失败或 - /// 用户显式关闭后都不再自动重试(否则每帧重开会话会打满 CPU)。 - auto_spawn_pending: bool, - /// 单元格像素尺寸;每次 render 按当前字号实测写入。 - cell: CellMetrics, - /// 最近一次已知的网格尺寸(行列)。 - size: TermDims, - /// 网格内容区边界(窗口坐标);由 `on_children_prepainted` 写入。 - grid_bounds: Rc>>>, - /// 上游引擎:唯一的终端语义来源。 - term: Term, - /// ANSI 解析器(`advance` 需要独占借用,故暂存取出再放回)。 - processor: Processor, - focus_handle: FocusHandle, - /// 正在拖拽选择时的选区类型。 - dragging: Option, + /// 最近一次应用到组件的字号;设置变化时用于触发 `update_config`。 + applied_font_size: f32, + /// 最近一次应用到组件的字体族;编辑器字体变化时用于触发 `update_config`。 + applied_font_family: String, /// 终端内搜索状态。 search: SearchState, /// 搜索输入框(懒创建,复用工作台唯一输入实现)。 search_input: Option, /// 搜索输入框事件订阅(必须与 `search_input` 同生命周期)。 search_subscription: Option, - /// 本次鼠标手势是否已向终端应用上报过按下(决定后续拖动/抬起是否上报)。 - mouse_reported: bool, - /// 光标闪烁相位(`terminal_cursor_blink` 打开时由后台任务翻转)。 - cursor_phase: bool, - /// 光标闪烁任务是否已在跑,避免重复启动定时任务。 - blink_task_running: bool, } impl TerminalView { pub fn new(working_dir: String, cx: &mut Context) -> Self { - let scrollback = settings::get(cx).terminal_scrollback.max(1); - let size = TermDims { - cols: BOOTSTRAP_COLS, - rows: BOOTSTRAP_ROWS, - }; - Self { + let mut view = Self { + xterm: None, session: None, working_dir, session_seq: 0, - auto_spawn_pending: true, - cell: CellMetrics { - width: 8.0, - height: 14.0, - }, - size, - grid_bounds: Rc::new(StdCell::new(None)), - term: Term::new( - Config { - scrolling_history: scrollback, - ..Config::default() - }, - &size, - VoidListener, - ), - processor: Processor::new(), - focus_handle: cx.focus_handle(), - dragging: None, + applied_font_size: settings::get(cx).terminal_font_size, + applied_font_family: crate::fonts::mono_family(cx).to_string(), search: SearchState::new(), search_input: None, search_subscription: None, - mouse_reported: false, - cursor_phase: true, - blink_task_running: false, - } + }; + view.open_session(cx); + view } /// 当前会话是否可写。 @@ -1491,7 +946,7 @@ impl TerminalView { self.session.is_some() } - /// 已有会话时返回其状态。 + /// 已有会话时返回其生命周期状态。 pub fn session_state(&self) -> SessionState { self.session .as_ref() @@ -1499,7 +954,7 @@ impl TerminalView { .unwrap_or(SessionState::Closed) } - /// 会话已经退出时给出退出码/信号,供状态区直接展示;仍在跑返回 `None`。 + /// 会话已经退出时给出退出码/信号,供状态区展示;仍在跑或空态返回 `None`。 pub fn session_exit(&self) -> Option { match self.session_state() { SessionState::Exited(exit) => Some(exit), @@ -1516,54 +971,54 @@ impl TerminalView { } /// 视口是否停在底部(决定“回到底部”按钮是否可用)。 - pub fn is_at_bottom(&self) -> bool { - self.term.grid().display_offset() == 0 + pub fn is_at_bottom(&self, cx: &App) -> bool { + match &self.xterm { + Some(xterm) => xterm.read(cx).state().display_offset() == 0, + None => true, + } } - /// 打开 PTY 会话并起主线程输出泵。已有会话、或自动创建闸门已关闭时不动。 - /// - /// 自动路径由首帧 render 触发:PTY 先按引导尺寸启动,同一帧的 prepaint 立刻 - /// 用实测容器尺寸 resize 网格与 PTY,因此引导尺寸只存在一帧。 - fn ensure_session(&mut self, cx: &mut Context) { - if self.session.is_some() || !self.auto_spawn_pending { + /// 打开会话;已有会话时不动。 + fn open_session(&mut self, cx: &mut Context) { + if self.session.is_some() { return; } - self.auto_spawn_pending = false; + self.start_session(cx); + } + + /// 创建 PTY 会话与 `gpui_xterm` 组件,并起退出事件泵。 + fn start_session(&mut self, cx: &mut Context) { let shell = resolve_shell(&settings::get(cx).terminal_default_shell_id); - let size = self.size; - let working_dir = self.working_dir.clone(); self.session_seq += 1; let seq = self.session_seq; - match TerminalSession::new(size, &working_dir, &shell) { - Ok((session, rx)) => { + let working_dir = self.working_dir.clone(); + match TerminalSession::new(&working_dir, &shell) { + Ok((session, reader, rx)) => { + let writer = session.writer(); + let resize = session.resize_callback(); + let config = self.xterm_config(cx); + let xterm = cx.new(|cx| { + XtermView::new(writer, reader, config, cx) + .with_resize_callback(move |cols, rows| resize(cols, rows)) + .with_key_handler(is_search_shortcut) + }); + self.applied_font_size = settings::get(cx).terminal_font_size; + self.applied_font_family = crate::fonts::mono_family(cx).to_string(); + self.xterm = Some(xterm); self.session = Some(session); - let banner = - crate::i18n::menu_text(cx, "terminal.session").replace("{shell}", &shell); - // 首行提示直接画进网格(换行落行)。 - self.advance(format!("{banner}\r\n").as_bytes()); - Self::spawn_pump(rx, seq, cx); + Self::spawn_event_pump(rx, seq, cx); } Err(error) => { tracing::warn!("failed to open terminal pty session: {error}"); - let text = crate::i18n::menu_text(cx, "terminal.unavailable"); - self.advance(format!("{text}: {error}\r\n").as_bytes()); + self.xterm = None; + self.session = None; } } cx.notify(); } - /// 网格推进(含借用拆分:解析器暂存取出再放回)。 - fn advance(&mut self, bytes: &[u8]) { - let mut processor = std::mem::replace(&mut self.processor, Processor::new()); - processor.advance(&mut self.term, bytes); - self.processor = processor; - } - - /// 主线程输出泵:后台收字节块,回主线程喂网格解析器。 - /// - /// 阻塞的 `recv` 放在后台线程,UI 线程只做 `advance`;会话代号变化后 - /// 旧泵立即退出,旧会话的字节不会污染新网格。 - fn spawn_pump(rx: mpsc::Receiver, seq: u64, cx: &mut Context) { + /// 主线程事件泵:后台收会话事件,回主线程更新视图。 + fn spawn_event_pump(rx: mpsc::Receiver, seq: u64, cx: &mut Context) { let rx = Arc::new(Mutex::new(rx)); cx.spawn(async move |this, cx| loop { let slot = rx.clone(); @@ -1598,55 +1053,33 @@ impl TerminalView { return false; } match event { - SessionEvent::Output(bytes) => { - self.advance(&bytes); + SessionEvent::Closed => { + // 生命周期状态由会话层在等待线程里记录;这里只触发重绘, + // 让宿主状态区展示退出码/信号。 cx.notify(); - true - } - SessionEvent::Closed(exit) => { - self.report_exit(exit, cx); false } } } - /// 把子进程退出结果写进网格,让用户看到会话为什么停了。 - fn report_exit(&mut self, exit: TerminalExit, cx: &mut Context) { - let key = match &exit { - TerminalExit::Code(_) => "terminal.exitedCode", - TerminalExit::Signal(_) => "terminal.signalled", - TerminalExit::Unknown => "terminal.exitUnknown", - }; - let text = crate::i18n::menu_text(cx, key).replace("{detail}", &exit.detail()); - self.advance(format!("{text}\r\n").as_bytes()); - cx.notify(); - } - /// 新建会话:先关闭旧会话(回收子进程),再按当前尺寸与工作目录重开。 pub fn restart(&mut self, cx: &mut Context) { self.teardown_session(); - self.reset_grid(cx); self.open_session(cx); } - /// 显式新建会话(忽略自动创建闸门,供空态按钮与程序化命令使用)。 - fn open_session(&mut self, cx: &mut Context) { - self.auto_spawn_pending = true; - self.ensure_session(cx); - } - - /// 关闭会话:回收子进程并回到空态,且不再自动重建(面板是否折叠由宿主决定)。 + /// 关闭会话:回收子进程并回到空态,且不再自动重建。 pub fn close_session(&mut self, cx: &mut Context) { self.teardown_session(); - self.reset_grid(cx); - self.auto_spawn_pending = false; cx.notify(); } - /// 丢弃当前会话句柄(进程组终止与 reader 回收交给后台线程,见 + /// 丢弃当前会话与组件实体(进程组终止与回收交给后台线程,见 /// [`TerminalSession::stop_and_reap`] 的有界等待)。 fn teardown_session(&mut self) { self.session_seq += 1; + // 先丢组件实体:释放它持有的写端与读取线程。 + self.xterm = None; if let Some(session) = self.session.take() { // 句柄的 `Drop` 会有界等待子进程回收,不能占用 UI 线程;交给一次 // 性后台线程立即 drop,效果与原地 drop 相同。 @@ -1655,26 +1088,9 @@ impl TerminalView { .spawn(move || drop(session)) .ok(); } - self.dragging = None; - self.mouse_reported = false; - } - - /// 重建上游网格并清空搜索态(回滚缓冲、选择、命中列表都归零)。 - fn reset_grid(&mut self, cx: &mut Context) { - let scrollback = settings::get(cx).terminal_scrollback.max(1); - self.term = Term::new( - Config { - scrolling_history: scrollback, - ..Config::default() - }, - &self.size, - VoidListener, - ); - self.processor = Processor::new(); self.search.clear(); - self.dragging = None; - self.mouse_reported = false; - cx.notify(); + self.search_input = None; + self.search_subscription = None; } /// 切换工作目录。项目切换后旧 shell 的 cwd 与环境已过期,按 IDE 语义 @@ -1692,10 +1108,12 @@ impl TerminalView { } } - /// 清屏(等价于 shell `clear`:擦除可见区,保留回滚)。 + /// 清屏:向 shell 发送 Ctrl+L,由 shell 自身重画(等价于 IDEA 的 clear)。 pub fn clear(&mut self, cx: &mut Context) { - if self.has_session() { - self.advance(b"\x1b[2J\x1b[H"); + if self.is_running() { + if let Some(session) = &self.session { + let _ = session.write_bytes(&[0x0c]); + } } cx.notify(); } @@ -1712,95 +1130,39 @@ impl TerminalView { if let Some(session) = &self.session { let _ = session.write_bytes(full.as_bytes()); } - self.scroll_to_bottom(); - cx.notify(); - } - - /// 视口滚到底部(回到底部动作、键盘输入后自动跟随)。 - pub fn scroll_to_bottom(&mut self) { - self.term.scroll_display(Scroll::Bottom); + self.scroll_to_bottom(cx); } - /// 依据容器实测尺寸同步网格与 PTY(上游 reflow 由 `Term::resize` 完成)。 - /// 返回尺寸是否发生变化。 - fn sync_size(&mut self, bounds: Bounds, cx: &mut Context) -> bool { - let Some(dims) = self.cell.grid_dims(bounds.size) else { - return false; - }; - if dims == self.size { - return false; - } - self.size = dims; - self.term.resize(dims); - if let Some(session) = &self.session { - session.resize(dims); - } - cx.notify(); - true - } - - /// 窗口坐标 → 网格点(含回滚偏移);越界返回 `None`。 - fn grid_point_at(&self, position: Point) -> Option { - let bounds = self.grid_bounds.get()?; - viewport_to_grid_point( - f32::from(position.x) - f32::from(bounds.origin.x), - f32::from(position.y) - f32::from(bounds.origin.y), - self.cell, - self.size, - self.term.grid().display_offset(), - ) - } - - /// 把输入字节写入 PTY,并把视口带回底部(IDEA/xterm 行为:一旦开始 - /// 交互就该看到最新输出)。 - fn send_to_pty(&mut self, bytes: &[u8]) { - let bracketed = self.term.mode().contains(TermMode::BRACKETED_PASTE); - let payload = if bracketed && bytes.len() > 1 { - // 上游开启 bracketed paste 后必须成对包裹,否则程序会把粘贴 - // 当成逐字输入执行。 - let mut payload = vec![0x1b, b'[', b'2', b'0', b'0', b'~']; - payload.extend_from_slice(bytes); - payload.extend_from_slice(b"\x1b[201~"); - payload - } else { - bytes.to_vec() - }; - let written = self - .session - .as_ref() - .is_some_and(|session| session.write_bytes(&payload).is_ok()); - if written { - self.scroll_to_bottom(); - } - } - - /// 复制当前选择到系统剪贴板;无选择返回 false。 - fn copy_selection(&self, cx: &mut App) -> bool { - let Some(text) = self.term.selection_to_string() else { - return false; - }; - if text.is_empty() { - return false; + /// 视口滚到底部(回到底部动作、命令发送后自动跟随)。 + pub fn scroll_to_bottom(&self, cx: &mut Context) { + if let Some(xterm) = self.xterm.clone() { + xterm.update(cx, |view, cx| { + view.state().scroll_to_bottom(); + cx.notify(); + }); } - cx.write_to_clipboard(ClipboardItem::new_string(text)); - true } - /// 从系统剪贴板粘贴到 PTY(读不到或为空则忽略)。 - fn paste_clipboard(&mut self, cx: &mut App) { - let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else { - return; - }; - if text.is_empty() { - return; + /// 生成组件配置:字号、回滚、内边距与主题调色板。 + fn xterm_config(&self, cx: &App) -> TerminalConfig { + let s = settings::get(cx); + TerminalConfig { + cols: BOOTSTRAP_COLS, + rows: BOOTSTRAP_ROWS, + font_family: crate::fonts::mono_family(cx).to_string(), + font_size: px(s.terminal_font_size), + scrollback: s.terminal_scrollback.max(1), + line_height_multiplier: 1.0, + padding: Edges::all(px(GRID_PADDING)), + colors: terminal_color_palette(), } - // 剪贴板里的 CRLF 会让部分交互程序吞掉后续行,统一成 LF。 - let normalized = text.replace("\r\n", "\n"); - self.send_to_pty(normalized.as_bytes()); } /// 打开终端内搜索:复用工作台唯一输入实现并聚焦。 pub fn open_search(&mut self, window: &mut Window, cx: &mut Context) { + if self.xterm.is_none() { + return; + } if self.search_input.is_none() { let search = SearchInput::new( crate::i18n::menu_text(cx, "terminal.searchPlaceholder"), @@ -1818,7 +1180,6 @@ impl TerminalView { this.apply_search_query(value, cx); } InputEvent::PressEnter { shift, .. } => this.step_search(!shift, cx), - // 失焦不抢焦点:等用户点击网格或按关闭按钮,避免打字中途被夺走。 InputEvent::Focus | InputEvent::Blur => {} })); self.search_input = Some(search); @@ -1830,18 +1191,21 @@ impl TerminalView { // 打开即复位为空查询并聚焦一次;不要每帧抢焦点。 search.set_value("", window, cx); self.search.clear(); - self.term.selection = None; + self.clear_selection(cx); search.focus(window, cx); cx.notify(); } - /// 关闭搜索栏并清除搜索态高亮,焦点回到网格。 + /// 关闭搜索栏并清除搜索态高亮,焦点回到终端组件。 pub fn close_search(&mut self, window: &mut Window, cx: &mut Context) { self.search.clear(); - self.term.selection = None; + self.clear_selection(cx); self.search_subscription = None; self.search_input = None; - window.focus(&self.focus_handle, cx); + if let Some(xterm) = self.xterm.clone() { + let handle = xterm.read(cx).focus_handle().clone(); + window.focus(&handle, cx); + } cx.notify(); } @@ -1855,8 +1219,36 @@ impl TerminalView { if query == self.search.query { return; } - self.search.set_query(&self.term, query); - self.highlight_current_match(); + self.search.query = query; + self.search.matches.clear(); + self.search.current = 0; + self.search.truncated = false; + if self.search.query.is_empty() { + self.clear_selection(cx); + cx.notify(); + return; + } + let mut regex = match RegexSearch::new(&self.search.query) { + Ok(regex) => regex, + Err(error) => { + // 非法正则:当作无命中,并且不保留上一次的查询串。 + tracing::warn!("terminal search pattern rejected: {error}"); + self.search.clear(); + self.clear_selection(cx); + cx.notify(); + return; + } + }; + if let Some(xterm) = self.xterm.clone() { + let (matches, truncated) = xterm + .read(cx) + .state() + .with_term(|term| collect_matches(term, &mut regex, SEARCH_MATCH_CAP)); + self.search.matches = matches; + self.search.truncated = truncated; + self.search.current = usize::from(!self.search.matches.is_empty()); + } + self.highlight_current_match(cx); cx.notify(); } @@ -1867,333 +1259,89 @@ impl TerminalView { } if self.search.total() == 0 { // 上一次查询判定无命中,而输出可能已经变化,重扫一次。 - self.search.jump_to_first(&self.term); + let query = self.search.query.clone(); + self.search.query.clear(); + self.apply_search_query(query, cx); + return; } self.search.step(forward); - self.highlight_current_match(); + self.highlight_current_match(cx); cx.notify(); } - fn highlight_current_match(&mut self) { - let Some(found) = self.search.current_match() else { - self.term.selection = None; + /// 用上游 selection 高亮当前命中并滚动到它;无命中则清除高亮。 + fn highlight_current_match(&mut self, cx: &mut Context) { + let Some(xterm) = self.xterm.clone() else { return; }; - self.term.scroll_to_point(*found.start()); - let mut selection = Selection::new(SelectionType::Simple, *found.start(), Side::Left); - selection.update(*found.end(), Side::Right); - self.term.selection = Some(selection); - } - - /// 应用按键动作;返回是否已消费(未消费则不阻止全局快捷键)。 - fn apply_key_action( - &mut self, - action: KeyAction, - window: &mut Window, - cx: &mut Context, - ) -> bool { - match action { - KeyAction::Send(bytes) => self.send_to_pty(&bytes), - KeyAction::Copy => { - self.copy_selection(cx); - } - KeyAction::Paste => self.paste_clipboard(cx), - KeyAction::OpenSearch => self.open_search(window, cx), - KeyAction::Scroll(lines) => { - self.term.scroll_display(Scroll::Delta(lines as i32)); - } - KeyAction::ScrollToBottom => self.scroll_to_bottom(), - KeyAction::Passthrough => return false, - } - cx.notify(); - true - } - - /// 开始一次选择(单击 simple / 双击 semantic / 三击 lines)。 - fn begin_selection(&mut self, point: GridPoint, ty: SelectionType) { - self.term.selection = Some(Selection::new(ty, point, Side::Left)); - self.dragging = Some(ty); - } - - /// 更新拖拽中的选择终点。 - fn update_selection(&mut self, point: GridPoint) { - if let Some(selection) = self.term.selection.as_mut() { - selection.update(point, Side::Right); - } - } - - /// 鼠标事件是否应交给终端应用而不是本地选择。 - fn report_mouse(&mut self, report: MouseReport, position: Point) -> bool { - if !mouse_report_enabled(report, *self.term.mode()) { - return false; - } - let Some(point) = self.grid_point_at(position) else { - return false; - }; - let sgr = self.term.mode().contains(TermMode::SGR_MOUSE); - let bytes = encode_mouse_report(report, point, self.term.grid().display_offset(), sgr); - self.send_to_pty(&bytes); - true + let current = self.search.current_match(); + xterm.update(cx, |view, cx| { + view.state().with_term_mut(|term| match current { + Some(found) => { + term.scroll_to_point(*found.start()); + let mut selection = + Selection::new(SelectionType::Simple, *found.start(), Side::Left); + selection.update(*found.end(), Side::Right); + term.selection = Some(selection); + } + None => term.selection = None, + }); + cx.notify(); + }); } - /// 键盘输入入口。返回是否已消费(消费则阻止冒泡到全局快捷键)。 - fn handle_key( - &mut self, - event: &KeyDownEvent, - window: &mut Window, - cx: &mut Context, - ) -> bool { - if self.search_visible() { - // 搜索栏打开时字符归输入框(正常路径下焦点在输入框,这里走不到)。 - // 保留这道闸门:一旦焦点意外留在网格上,也不能把搜索期按键直输 PTY。 - return false; + /// 清除组件内的选择高亮。 + fn clear_selection(&self, cx: &mut Context) { + if let Some(xterm) = self.xterm.clone() { + xterm.update(cx, |view, cx| { + view.state().update_selection(None); + cx.notify(); + }); } - let action = map_key( - &event.keystroke.key, - event.keystroke.key_char.as_deref(), - KeyModifiers::from(&event.keystroke.modifiers), - *self.term.mode(), - self.size.rows, - ); - self.apply_key_action(action, window, cx) - } - - /// 光标闪烁任务:仅在设置打开且会话存在时翻转相位;条件不再满足时 - /// 自行退出,不会留下常驻定时任务。 - fn spawn_blink(cx: &mut Context) { - cx.spawn(async move |this, cx| loop { - cx.background_executor().timer(CURSOR_BLINK_INTERVAL).await; - let keep = this - .update(cx, |view, cx| { - if !view.blink_task_running || !view.has_session() { - view.blink_task_running = false; - return false; - } - view.cursor_phase = !view.cursor_phase; - cx.notify(); - true - }) - .unwrap_or(false); - if !keep { - break; - } - }) - .detach(); } } impl Render for TerminalView { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - // 单元格基准宽度:等宽字体下用上游 `em_layout_width` 实测(随字号变化)。 - let font_size = px(settings::get(cx).terminal_font_size); - self.cell = CellMetrics::measure(window, font_size); - let blink_enabled = settings::get(cx).terminal_cursor_blink; - if blink_enabled && !self.blink_task_running { - self.blink_task_running = true; - Self::spawn_blink(cx); - } else if !blink_enabled { - self.blink_task_running = false; - } - - // 没有会话就开一次(首帧用引导尺寸,本帧 prepaint 立刻按实测尺寸 - // resize 网格与 PTY;之后一律用已实测的尺寸)。自动创建只尝试一次, - // 失败或用户显式关闭后不会每帧重试。 - if !self.has_session() { - self.ensure_session(cx); - } - - let palette = TermPalette::from_theme(); - let focused = self.focus_handle.is_focused(window); - let display_offset = self.term.grid().display_offset(); - - // 可见区按行分组(`display_iter` 即当前视口,已含回滚偏移)。 - let content = self.term.renderable_content(); - let selection = content.selection; - let cursor_line = content.cursor.point.line.0; - let cursor_col = content.cursor.point.column.0; - let cursor_style = CursorStyle::from(content.cursor.shape); - // 上滚时不画光标:它已经不在视口里,画出来会指向错误的行。 - let cursor_visible = focused && cursor_style != CursorStyle::Hidden && display_offset == 0; - let mut rows: Vec<(i32, Vec)> = Vec::new(); - for indexed in content.display_iter { - let line = indexed.point.line.0; - if rows.last().map(|(number, _)| *number) != Some(line) { - rows.push((line, Vec::new())); + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + // 字号或编辑器字体变化时把新配置推给组件(回滚缓冲只在建会话时生效)。 + let font_size = settings::get(cx).terminal_font_size; + let font_family = crate::fonts::mono_family(cx).to_string(); + if let Some(xterm) = self.xterm.clone() { + let size_changed = (font_size - self.applied_font_size).abs() > f32::EPSILON; + let family_changed = font_family != self.applied_font_family; + if size_changed || family_changed { + let config = self.xterm_config(cx); + xterm.update(cx, |view, cx| view.update_config(config, cx)); + self.applied_font_size = font_size; + self.applied_font_family = font_family; } - let cell = indexed.cell; - let selected = selection - .as_ref() - .is_some_and(|range| range.contains(indexed.point)); - rows.last_mut() - .expect("rows non-empty after push") - .1 - .push(TermCell { - // 宽字符的占位格只占宽度,不绘制字符。 - c: if cell - .flags - .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) - { - None - } else { - Some(cell.c) - }, - fg: cell.fg, - bg: cell.bg, - flags: cell.flags, - selected, - }); } - let cursor_phase = self.cursor_phase; - - let cell = self.cell; - let rendered_rows: Vec = rows - .into_iter() - .map(|(line, cells)| { - render_term_row( - line, - &cells, - CursorPlacement { - visible: cursor_visible && line == cursor_line, - column: cursor_col, - style: cursor_style, - phase: cursor_phase, - }, - &palette, - cell.width, - cell.height, - ) - }) - .collect(); - let grid = div() - .flex_1() - .w_full() - .min_h_0() - .overflow_hidden() - .pt(px(GRID_PADDING)) - .pl(px(GRID_PADDING)) - .pr(px(GRID_PADDING)) - .text_size(font_size) - .font_family("monospace") - .children(rendered_rows); - - // 网格容器:唯一子元素,因此 prepaint 给出的首个子边界就是网格容器 - // 的实测边界。在这里直接驱动 resize,避免“先按引导尺寸渲染、下一帧才 - // resize”的滞后(折叠/展开、窗口缩放都当帧生效)。 - let bounds_slot = self.grid_bounds.clone(); - let weak = cx.weak_entity(); - let grid_with_bounds = div() - .flex_1() - .w_full() - .min_h_0() - .on_children_prepainted(move |bounds, _window, cx| { - let Some(first) = bounds.first().copied() else { - return; + let body: gpui_kit::AnyElement = match &self.xterm { + Some(xterm) => { + let search_bar = if self.search_visible() { + Some(self.render_search_bar(cx)) + } else { + None }; - bounds_slot.set(Some(first)); - let _ = weak.update(cx, |view, cx| { - view.sync_size(first, cx); - }); - }) - .child(grid); - - let body: gpui_kit::AnyElement = if self.has_session() { - let search_bar = if self.search_visible() { - Some(self.render_search_bar(cx)) - } else { - None - }; - div() - .flex_1() - .w_full() - .min_h_0() - .id("terminal-grid") - .track_focus(&self.focus_handle) - .cursor_text() - .on_click(cx.listener(|this, _event, window, cx| { - if this.search_visible() { - this.close_search(window, cx); - } - window.focus(&this.focus_handle, cx); - })) - .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { - if this.handle_key(event, window, cx) { - cx.stop_propagation(); - } - })) - .on_mouse_down( - MouseButton::Left, - cx.listener(|this, event: &MouseDownEvent, window, cx| { - if this.search_visible() { - this.close_search(window, cx); - } - window.focus(&this.focus_handle, cx); - if this.report_mouse(MouseReport::Press(0), event.position) { - this.mouse_reported = true; - return; - } - let Some(point) = this.grid_point_at(event.position) else { - return; - }; - let ty = match event.click_count { - 0 | 1 => SelectionType::Simple, - 2 => SelectionType::Semantic, - _ => SelectionType::Lines, - }; - this.begin_selection(point, ty); - cx.notify(); - }), - ) - .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| { - // 拖动上报只在“已上报按下”且按键仍按住时发,否则全屏 TUI - // 会被鼠标移动刷屏。 - if this.mouse_reported - && event.pressed_button.is_some() - && this.report_mouse(MouseReport::Drag(0), event.position) - { - return; - } - if this.dragging.is_none() { - return; - } - if let Some(point) = this.grid_point_at(event.position) { - this.update_selection(point); - cx.notify(); - } - })) - .on_mouse_up( - MouseButton::Left, - cx.listener(|this, event: &MouseUpEvent, _window, cx| { - let reported = this.mouse_reported; - this.mouse_reported = false; - this.dragging = None; - if reported { - this.report_mouse(MouseReport::Release(0), event.position); + div() + .relative() + .flex_1() + .w_full() + .min_h_0() + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + // 组件已用按键钩子吞掉 Ctrl+F,这里负责打开搜索栏并阻止 + // 冒泡到工作台全局快捷键。 + if is_search_shortcut(event) { + this.open_search(window, cx); + cx.stop_propagation(); } - cx.notify(); - }), - ) - .on_mouse_down( - MouseButton::Right, - cx.listener(|this, _event: &MouseDownEvent, window, cx| { - // 右键粘贴(IDEA 终端习惯),不落本地选择。 - cx.stop_propagation(); - this.paste_clipboard(cx); - window.focus(&this.focus_handle, cx); - }), - ) - .on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _window, cx| { - if this.scroll_wheel(event) { - cx.notify(); - } - })) - // 浮层搜索栏必须排在网格之后:GPUI 按绘制顺序逆序命中, - // 否则网格会盖住搜索条。 - .child(grid_with_bounds) - .when_some(search_bar, |el, bar| el.child(bar)) - .into_any_element() - } else { - self.render_closed_state(cx) + })) + .child(xterm.clone()) + .when_some(search_bar, |el, bar| el.child(bar)) + .into_any_element() + } + None => self.render_closed_state(cx), }; v_flex() @@ -2204,24 +1352,6 @@ impl Render for TerminalView { } impl TerminalView { - /// 滚轮 → 视口行数。Shift+滚轮在 Linux 上是横向手势,终端按行处理。 - fn scroll_wheel(&mut self, event: &ScrollWheelEvent) -> bool { - let shift = event.modifiers.shift; - let lines = match event.delta { - ScrollDelta::Lines(point) => wheel_axis(shift, f64::from(point.x), f64::from(point.y)), - ScrollDelta::Pixels(point) => { - let axis = wheel_axis(shift, f64::from(point.x), f64::from(point.y)); - axis / self.cell.height as f64 - } - }; - let delta = lines.round() as isize; - if delta == 0 { - return false; - } - self.term.scroll_display(Scroll::Delta(delta as i32)); - true - } - /// 无会话空态:给出明确的下一步(新建会话),不留空白面板。 fn render_closed_state(&self, cx: &mut Context) -> gpui_kit::AnyElement { v_flex() @@ -2250,10 +1380,8 @@ impl TerminalView { .into_any_element() } - /// 渲染搜索栏(终端内搜索,对齐 Windows 终端搜索条:浮层 + 计数 + - /// 上/下一个 + 关闭)。做成浮层而不是挤占一行高度,打开/关闭搜索不会触发 - /// 终端 resize 与全屏 TUI 重排。输入框复用 [`SearchInput`],不在终端里 - /// 另写一套。 + /// 渲染搜索栏(终端内搜索:浮层 + 计数 + 上/下一个 + 关闭)。做成浮层而不是 + /// 挤占一行高度,打开/关闭搜索不会触发终端 resize 与全屏 TUI 重排。 fn render_search_bar(&self, cx: &mut Context) -> gpui_kit::AnyElement { let search = self.search_input.as_ref().expect("search bar needs input"); let label = self.search.label(); @@ -2323,184 +1451,42 @@ impl TerminalView { } } -/// 渲染网格一行:同样式字符合并为一段;选中字符用选区底色;光标按上游 -/// `CursorShape` 呈现。每段用固定像素宽度(`字符数 × cell_width`)保证 -/// 等宽对齐,鼠标命中换算依赖同一套单元格尺寸。 -fn render_term_row( - line: i32, - cells: &[TermCell], - cursor: CursorPlacement, - palette: &TermPalette, - cell_width: f32, - line_height: f32, -) -> gpui_kit::AnyElement { - let mut row = h_flex() - .id(format!("term-row-{line}")) - .w_full() - .h(px(line_height)) - .flex_shrink_0() - .items_center() - .whitespace_nowrap() - .font_family("monospace"); - if cells.is_empty() { - if cursor.trailing(0) { - row = row.child(cursor_block( - palette, - " ".to_string(), - cell_width, - cursor.style, - )); - } - return row.into_any_element(); - } - - let mut index = 0usize; - while index < cells.len() { - let cell = cells[index]; - if cursor.covers(index) { - let (fg, bg) = resolve_cell_colors(cell.fg, cell.bg, cell.flags, palette); - row = row.child( - div() - .flex_shrink_0() - .w(px(cell_width)) - .text_color(bg) - .bg(fg) - .child(cell.c.map(|c| c.to_string()).unwrap_or_default()), - ); - index += 1; - continue; - } - - // 合并同 (fg,bg,flags,selected) 的后续字符。 - let mut text = String::new(); - let mut width = 0usize; - while index + width < cells.len() { - let next = cells[index + width]; - if cursor.covers(index + width) { - break; - } - if next.fg != cell.fg - || next.bg != cell.bg - || next.flags != cell.flags - || next.selected != cell.selected - { - break; - } - // 宽字符占位格也要占一格宽度,但不产生字符。 - if let Some(c) = next.c { - text.push(c); - } - width += 1; - } - let (fg, bg) = resolve_cell_colors(cell.fg, cell.bg, cell.flags, palette); - let mut segment = div() - .flex_shrink_0() - .w(px(cell_width * width as f32)) - .text_color(fg) - .when(cell.selected, |el| el.bg(palette.selection)) - .when( - !cell.selected && cell.bg != AlacColor::Named(NamedColor::Background), - |el| el.bg(bg), - ); - if cell.flags.contains(Flags::BOLD) { - segment = segment.font_weight(FontWeight::BOLD); - } - if cell.flags.contains(Flags::ITALIC) { - segment = segment.italic(); - } - if cell.flags.intersects(Flags::ALL_UNDERLINES) { - segment = segment.underline(); - } - if cell.flags.contains(Flags::STRIKEOUT) { - segment = segment.line_through(); - } - row = row.child(segment.child(text)); - index += width; - } - - // 光标超出已给出单元格(行尾之后):补一个光标块。 - if cursor.trailing(cells.len()) { - row = row.child(cursor_block( - palette, - " ".to_string(), - cell_width, - cursor.style, - )); - } - - row.into_any_element() -} - -/// 光标块:反色(前景当底、背景当字);下划线/竖线/空心三种形状按上游 -/// `CursorShape` 用边框表达。 -fn cursor_block( - palette: &TermPalette, - text: String, - cell_width: f32, - style: CursorStyle, -) -> gpui_kit::AnyElement { - let block = div() - .flex_shrink_0() - .w(px(cell_width)) - .h_full() - .text_color(palette.background) - .bg(palette.cursor) - .child(text); - match style { - CursorStyle::Block | CursorStyle::Hidden => block.into_any_element(), - CursorStyle::Underline => block - .border_b_1() - .border_color(palette.cursor) - .into_any_element(), - CursorStyle::Beam => block - .border_l_1() - .border_color(palette.cursor) - .into_any_element(), - CursorStyle::Hollow => block - .border_1() - .border_color(palette.cursor) - .text_color(palette.cursor) - .bg(palette.background) - .into_any_element(), - } -} - #[cfg(test)] mod tests { use super::*; + use alacritty_terminal::event::VoidListener; + use alacritty_terminal::grid::Dimensions; + use alacritty_terminal::term::Config; + use alacritty_terminal::vte::ansi::Processor; - fn dims(cols: usize, rows: usize) -> TermDims { - TermDims { cols, rows } + /// 测试用网格尺寸(生产渲染尺寸由组件持有)。 + struct TestDims { + cols: usize, + rows: usize, } - fn cell() -> CellMetrics { - CellMetrics { - width: 8.0, - height: 16.0, + impl Dimensions for TestDims { + fn total_lines(&self) -> usize { + self.rows } - } - fn mods(control: bool, alt: bool, shift: bool, platform: bool) -> KeyModifiers { - KeyModifiers { - control, - alt, - shift, - platform, + fn screen_lines(&self) -> usize { + self.rows } - } - fn plain() -> KeyModifiers { - mods(false, false, false, false) + fn columns(&self) -> usize { + self.cols + } } - /// 构造带输出的测试终端(复用与生产一致的解析路径)。 + /// 构造带输出的测试终端(复用与组件一致的解析路径)。 fn term_with_output(bytes: &[u8]) -> Term { let mut term = Term::new( Config { scrolling_history: 100, ..Config::default() }, - &dims(40, 10), + &TestDims { cols: 40, rows: 10 }, VoidListener, ); let mut processor: Processor = Processor::new(); @@ -2508,110 +1494,6 @@ mod tests { term } - // ---- 坐标换算 ---- - - /// 网格左上角第一格:局部坐标需扣除内边距。 - #[test] - fn maps_top_left_corner_to_origin_cell() { - let point = viewport_to_grid_point(GRID_PADDING, GRID_PADDING, cell(), dims(80, 24), 0) - .expect("top-left must hit a cell"); - assert_eq!(point.line.0, 0); - assert_eq!(point.column.0, 0); - } - - /// 内边距内的坐标不算命中,避免选中出现偏移半格。 - #[test] - fn rejects_point_inside_padding() { - assert!( - viewport_to_grid_point(GRID_PADDING - 0.5, GRID_PADDING, cell(), dims(80, 24), 0) - .is_none() - ); - assert!( - viewport_to_grid_point(GRID_PADDING, GRID_PADDING - 0.5, cell(), dims(80, 24), 0) - .is_none() - ); - } - - /// 列/行按单元格尺寸取整:第 n 格覆盖 [n*w, (n+1)*w)。 - #[test] - fn rounds_down_to_containing_cell() { - let point = viewport_to_grid_point( - GRID_PADDING + cell().width * 3.0 + 1.0, - GRID_PADDING + cell().height * 2.0 + 1.0, - cell(), - dims(80, 24), - 0, - ) - .expect("inside grid"); - assert_eq!(point.column.0, 3); - assert_eq!(point.line.0, 2); - } - - /// 越出网格列/行的坐标不命中。 - #[test] - fn rejects_out_of_bounds() { - let cols = 10; - let rows = 5; - let beyond_cols = viewport_to_grid_point( - GRID_PADDING + cell().width * cols as f32, - GRID_PADDING, - cell(), - dims(cols, rows), - 0, - ); - assert!(beyond_cols.is_none()); - let beyond_rows = viewport_to_grid_point( - GRID_PADDING, - GRID_PADDING + cell().height * rows as f32, - cell(), - dims(cols, rows), - 0, - ); - assert!(beyond_rows.is_none()); - } - - /// 回滚偏移把视口行还原成网格行:上滚 3 行后,视口首行是网格 -3 行。 - #[test] - fn applies_scrollback_offset_to_viewport_line() { - let point = viewport_to_grid_point(GRID_PADDING, GRID_PADDING, cell(), dims(80, 24), 3) - .expect("top-left with scrollback"); - assert_eq!(point.line.0, -3); - assert_eq!(point.column.0, 0); - } - - /// 容器尺寸 → 网格行列数;容器未布局完成时不产出尺寸,极小容器不退化。 - #[test] - fn grid_dims_follow_measured_container() { - let metrics = cell(); - let size = Size { - width: px(GRID_PADDING * 2.0 + metrics.width * 40.0), - height: px(GRID_PADDING * 2.0 + metrics.height * 12.0), - }; - assert_eq!(metrics.grid_dims(size), Some(dims(40, 12))); - assert_eq!( - metrics.grid_dims(Size { - width: px(0.0), - height: px(0.0) - }), - None - ); - assert_eq!( - metrics.grid_dims(Size { - width: px(GRID_PADDING * 2.0 + 1.0), - height: px(GRID_PADDING * 2.0 + 1.0) - }), - Some(dims(MIN_COLS, MIN_ROWS)) - ); - // 超大容器必须被钳住,避免给 PTY 报一个荒谬的行列数。 - assert_eq!( - metrics.grid_dims(Size { - width: px(100_000.0), - height: px(100_000.0) - }), - Some(dims(MAX_COLS, MAX_ROWS)) - ); - } - // ---- 生命周期状态机 ---- /// 正常路径:Closed → Running → Stopping → Exited(码)。 @@ -2667,7 +1549,6 @@ mod tests { lifecycle.apply(SessionEventKind::Spawned), SessionState::Running ); - // Running 状态下重复 Spawned 不得改变状态。 assert_eq!( lifecycle.apply(SessionEventKind::Spawned), SessionState::Running @@ -2683,201 +1564,29 @@ mod tests { assert!(!SessionState::Exited(TerminalExit::Code(0)).is_attached()); } - // ---- 输入映射 ---- - - /// 可打印字符原样直输;Ctrl+字母转控制字符。 - #[test] - fn maps_printable_and_control_characters() { - let default = TermMode::default(); - assert_eq!( - map_key("a", Some("a"), plain(), default, 24), - KeyAction::Send(b"a".to_vec()) - ); - assert_eq!( - map_key("c", Some("c"), mods(true, false, false, false), default, 24), - KeyAction::Send(vec![3]) - ); - assert_eq!( - map_key("d", Some("d"), mods(true, false, false, false), default, 24), - KeyAction::Send(vec![4]) - ); - } - - /// 非 macOS 平台约定:Ctrl+Shift+C/V 复制粘贴,Ctrl+V 直接粘贴。 - #[test] - fn maps_copy_paste_and_search_shortcuts() { - let default = TermMode::default(); - assert_eq!( - map_key("c", None, mods(true, false, true, false), default, 24), - KeyAction::Copy - ); - assert_eq!( - map_key("v", None, mods(true, false, true, false), default, 24), - KeyAction::Paste - ); - assert_eq!( - map_key("v", Some("v"), mods(true, false, false, false), default, 24), - KeyAction::Paste - ); - assert_eq!( - map_key("f", Some("f"), mods(true, false, false, false), default, 24), - KeyAction::OpenSearch - ); - } - - /// Shift+翻页滚视口,Ctrl+End 回到底部,两者都不写 PTY。 - #[test] - fn maps_viewport_scrolling_keys() { - let default = TermMode::default(); - assert_eq!( - map_key("pageup", None, mods(false, false, true, false), default, 30), - KeyAction::Scroll(30) - ); - assert_eq!( - map_key( - "pagedown", - None, - mods(false, false, true, false), - default, - 30 - ), - KeyAction::Scroll(-30) - ); - assert_eq!( - map_key("end", None, mods(true, false, false, false), default, 30), - KeyAction::ScrollToBottom - ); - } - - /// 方向键按上游 `APP_CURSOR` 模式选序列。 - #[test] - fn arrow_keys_follow_application_cursor_mode() { - assert_eq!( - map_key("up", None, plain(), TermMode::default(), 24), - KeyAction::Send(b"\x1b[A".to_vec()) - ); - assert_eq!( - map_key( - "up", - None, - plain(), - TermMode::APP_CURSOR | TermMode::default(), - 24 - ), - KeyAction::Send(b"\x1bOA".to_vec()) - ); - } - - /// Alt+字符加 ESC 前缀,Alt+方向键是 readline 词移动。 - #[test] - fn maps_alt_meta_sequences() { - let default = TermMode::default(); - assert_eq!( - map_key("b", Some("b"), mods(false, true, false, false), default, 24), - KeyAction::Send(b"\x1bb".to_vec()) - ); - assert_eq!( - map_key("left", None, mods(false, true, false, false), default, 24), - KeyAction::Send(b"\x1b[b".to_vec()) - ); - } - - /// 平台键与未映射组合键放行,工作台快捷键不能被终端吞掉。 - #[test] - fn unhandled_combinations_pass_through() { - let default = TermMode::default(); - // Ctrl+功能键没有终端语义,归工作台。 - assert_eq!( - map_key("home", None, mods(true, false, false, false), default, 24), - KeyAction::Passthrough - ); - assert_eq!( - map_key("t", Some("t"), mods(false, false, false, true), default, 24), - KeyAction::Passthrough - ); - // AltGr(Ctrl+Alt)用于输入字符,不能被当成控制字符。 - assert_eq!( - map_key("q", Some("q"), mods(true, true, false, false), default, 24), - KeyAction::Passthrough - ); - } - - /// Ctrl+字母仍是控制字符(readline 依赖它),不能因为“IDE 快捷键”就放行。 - #[test] - fn control_letters_still_reach_the_shell() { - let default = TermMode::default(); - assert_eq!( - map_key("w", Some("w"), mods(true, false, false, false), default, 24), - KeyAction::Send(vec![0x17]) - ); - } - - // ---- 鼠标上报 ---- + // ---- PTY 环境 ---- - /// 上游模式未开启鼠标上报时不编码(走本地选择)。 - #[test] - fn mouse_report_requires_upstream_mode() { - assert!(!mouse_report_enabled( - MouseReport::Press(0), - TermMode::default() - )); - assert!(mouse_report_enabled( - MouseReport::Press(0), - TermMode::MOUSE_REPORT_CLICK | TermMode::default() - )); - assert!(!mouse_report_enabled( - MouseReport::Drag(0), - TermMode::MOUSE_REPORT_CLICK | TermMode::default() - )); - assert!(mouse_report_enabled( - MouseReport::Drag(0), - TermMode::MOUSE_MOTION | TermMode::default() - )); - } - - /// SGR 编码:行列从 1 开始,回滚偏移叠加进上报行号。 + /// 宿主终端变量被清理,能力变量被覆盖,`TMUX` 之类嵌套标识保留。 #[test] - fn encodes_sgr_mouse_report_with_offset() { - assert_eq!( - encode_mouse_report( - MouseReport::Press(0), - GridPoint::new(Line(-2), Column(3)), - 5, - true - ), - b"\x1b[<0;4;4M".to_vec() - ); - assert_eq!( - encode_mouse_report( - MouseReport::Release(0), - GridPoint::new(Line(0), Column(0)), - 0, - true - ), - b"\x1b[<3;1;1m".to_vec() - ); + fn pty_environment_clears_host_terminal_variables() { + let mut base = BTreeMap::new(); + base.insert("PATH".to_string(), "/usr/bin".to_string()); + base.insert("TERM".to_string(), "dumb".to_string()); + base.insert("COLUMNS".to_string(), "40".to_string()); + base.insert("WT_SESSION".to_string(), "abc".to_string()); + base.insert("TMUX".to_string(), "/tmp/tmux-0/default,1,0".to_string()); + let env = pty_environment(&base); + assert!(!env.contains_key("COLUMNS")); + assert!(!env.contains_key("WT_SESSION")); + assert_eq!(env.get("PATH").map(String::as_str), Some("/usr/bin")); assert_eq!( - encode_mouse_report( - MouseReport::Drag(2), - GridPoint::new(Line(0), Column(0)), - 0, - true - ), - b"\x1b[<34;1;1M".to_vec() + env.get("TMUX").map(String::as_str), + Some("/tmp/tmux-0/default,1,0") ); - } - - /// 非 SGR 模式回退 X10 编码(坐标加 32 偏移)。 - #[test] - fn encodes_x10_mouse_report_fallback() { + assert_eq!(env.get("TERM").map(String::as_str), Some(PTY_TERM)); assert_eq!( - encode_mouse_report( - MouseReport::Press(1), - GridPoint::new(Line(0), Column(1)), - 0, - false - ), - vec![0x1b, b'[', b'M', 33, 34, 33] + env.get("COLORTERM").map(String::as_str), + Some(PTY_COLORTERM) ); } @@ -2900,43 +1609,29 @@ mod tests { assert_eq!(match_label(2, 7, true), "2/7+"); } - /// 查询串变化后重新收集命中,序号复位到首个命中,next 走到第二个。 + /// 查询串变化后重新收集命中,序号复位到首个命中,step 走到第二个。 #[test] fn search_collects_matches_and_selects_first() { let term = term_with_output(b"alpha beta\r\nalpha gamma\r\n"); - let mut state = SearchState::new(); - state.set_query(&term, "alpha".to_string()); - assert_eq!(state.total(), 2); - assert_eq!(state.current, 1); - let first = state.current_match().expect("first match"); - assert_eq!(first.start().line.0, 0); - assert_eq!(first.start().column.0, 0); - state.step(true); - let second = state.current_match().expect("second match"); - assert_eq!(second.start().line.0, 1); - // 环绕回第一个。 - state.step(true); - assert_eq!(state.current, 1); - } - - /// 非法正则按无命中处理,且不保留上一次结果。 - #[test] - fn invalid_regex_clears_previous_matches() { - let term = term_with_output(b"alpha\r\n"); - let mut state = SearchState::new(); - state.set_query(&term, "alpha".to_string()); - assert_eq!(state.total(), 1); - state.set_query(&term, "([".to_string()); - assert_eq!(state.total(), 0); - assert!(state.is_empty()); + let mut regex = RegexSearch::new("alpha").expect("valid regex"); + let (matches, truncated) = collect_matches(&term, &mut regex, SEARCH_MATCH_CAP); + assert_eq!(matches.len(), 2); + assert!(!truncated); + assert_eq!(matches[0].start().line.0, 0); + assert_eq!(matches[0].start().column.0, 0); + assert_eq!(matches[1].start().line.0, 1); } /// 无命中时不产生高亮范围,计数显示 0/0。 #[test] fn search_without_match_has_no_highlight() { let term = term_with_output(b"alpha\r\n"); + let mut regex = RegexSearch::new("omega").expect("valid regex"); + let (matches, _) = collect_matches(&term, &mut regex, SEARCH_MATCH_CAP); + assert!(matches.is_empty()); let mut state = SearchState::new(); - state.set_query(&term, "omega".to_string()); + state.query = "omega".to_string(); + state.matches = matches; assert_eq!(state.total(), 0); assert_eq!(state.current, 0); assert!(state.current_match().is_none()); @@ -2951,7 +1646,7 @@ mod tests { scrolling_history: 100, ..Config::default() }, - &dims(20, 2), + &TestDims { cols: 20, rows: 2 }, VoidListener, ); let mut processor: Processor = Processor::new(); @@ -2960,212 +1655,33 @@ mod tests { b"needle one\r\nneedle two\r\nneedle three\r\nneedle four", ); assert!(term.grid().history_size() > 0, "history must exist"); - let mut state = SearchState::new(); - state.set_query(&term, "needle".to_string()); - assert_eq!(state.total(), 4); - assert_eq!(state.label(), "1/4"); - } - - // ---- PTY 环境 ---- - - /// 宿主终端变量被清理,能力变量被覆盖,`TMUX` 之类嵌套标识保留。 - #[test] - fn pty_environment_clears_host_terminal_variables() { - let mut base = BTreeMap::new(); - base.insert("PATH".to_string(), "/usr/bin".to_string()); - base.insert("TERM".to_string(), "dumb".to_string()); - base.insert("COLUMNS".to_string(), "40".to_string()); - base.insert("WT_SESSION".to_string(), "abc".to_string()); - base.insert("TMUX".to_string(), "/tmp/tmux-0/default,1,0".to_string()); - let env = pty_environment(&base); - assert!(!env.contains_key("COLUMNS")); - assert!(!env.contains_key("WT_SESSION")); - assert_eq!(env.get("PATH").map(String::as_str), Some("/usr/bin")); - assert_eq!( - env.get("TMUX").map(String::as_str), - Some("/tmp/tmux-0/default,1,0") - ); - assert_eq!(env.get("TERM").map(String::as_str), Some(PTY_TERM)); - assert_eq!( - env.get("COLORTERM").map(String::as_str), - Some(PTY_COLORTERM) - ); - } - - // ---- 调色板 ---- - - /// 256 色:0-15 走主题 token,16 起为立方体,232 起为灰阶。 - #[test] - fn indexed_colors_cover_base_cube_and_grayscale() { - let palette = TermPalette::from_theme(); - let current = theme::palette(); - assert_eq!(palette.indexed(0), current.terminal_black); - assert_eq!(palette.indexed(7), current.terminal_white); - assert_eq!( - palette.indexed(8), - theme::mix(current.terminal_black, current.foreground, BRIGHT_MIX) - ); - assert_eq!(palette.indexed(16), rgba_of((0, 0, 0))); - assert_eq!(palette.indexed(231), rgba_of((255, 255, 255))); - assert_eq!(palette.indexed(232), rgba_of((8, 8, 8))); - assert_eq!(palette.indexed(255), rgba_of((238, 238, 238))); - } - - /// 命名色不走 `as u8` 折叠:`Foreground`/`Background` 走主题, - /// `DimRed` 走暗色变体而不是被折成 `Yellow`。 - #[test] - fn named_colors_map_to_theme_tokens() { - let palette = TermPalette::from_theme(); - let current = theme::palette(); - assert_eq!(palette.named(NamedColor::Foreground), current.foreground); - assert_eq!(palette.named(NamedColor::Background), current.background); - assert_eq!(palette.named(NamedColor::Cursor), current.foreground); - assert_eq!( - palette.named(NamedColor::DimRed), - theme::mix(current.terminal_red, current.background, DIM_MIX) - ); - assert_ne!(palette.named(NamedColor::DimRed), current.terminal_yellow); + let mut regex = RegexSearch::new("needle").expect("valid regex"); + let (matches, _) = collect_matches(&term, &mut regex, SEARCH_MATCH_CAP); + assert_eq!(matches.len(), 4); } - /// 加粗时 0-7 基础色提升到 8-15 高亮 variant(标准终端行为)。 - #[test] - fn bold_promotes_base_colors_to_bright_variants() { - let palette = TermPalette::from_theme(); - let bold = palette.resolve(AlacColor::Named(NamedColor::Red), true); - let normal = palette.resolve(AlacColor::Named(NamedColor::Red), false); - assert_eq!(bold, palette.bright(1)); - assert_eq!(normal, palette.base[1]); - assert_ne!(bold, normal); - } + // ---- 主题映射 ---- - /// `INVERSE` 交换前景底色;`HIDDEN` 让前景等于底色。 + /// Rgba 浮点转 8 位整数:边界钳住,四舍五入。 #[test] - fn cell_colors_handle_inverse_and_hidden() { - let palette = TermPalette::from_theme(); - let fg = AlacColor::Indexed(1); - let bg = AlacColor::Indexed(4); - let (plain_fg, plain_bg) = resolve_cell_colors(fg, bg, Flags::empty(), &palette); - let (inverse_fg, inverse_bg) = resolve_cell_colors(fg, bg, Flags::INVERSE, &palette); - assert_eq!((inverse_fg, inverse_bg), (plain_bg, plain_fg)); - let (hidden_fg, hidden_bg) = resolve_cell_colors(fg, bg, Flags::HIDDEN, &palette); - assert_eq!(hidden_fg, hidden_bg); - } - - /// 光标形状按上游 `CursorShape` 映射。 - #[test] - fn cursor_style_follows_upstream_shape() { - assert_eq!(CursorStyle::from(CursorShape::Block), CursorStyle::Block); + fn rgb8_clamps_and_rounds() { assert_eq!( - CursorStyle::from(CursorShape::Underline), - CursorStyle::Underline + rgb8(Rgba { + r: 0.0, + g: 0.5, + b: 1.0, + a: 1.0 + }), + (0, 128, 255) ); - assert_eq!(CursorStyle::from(CursorShape::Beam), CursorStyle::Beam); assert_eq!( - CursorStyle::from(CursorShape::HollowBlock), - CursorStyle::Hollow - ); - assert_eq!(CursorStyle::from(CursorShape::Hidden), CursorStyle::Hidden); - } - - /// 光标落在哪一格、是否补尾格、熄灭相位是否隐藏,全部由上游光标列号决定。 - #[test] - fn cursor_placement_uses_cursor_column() { - let cursor = CursorPlacement { - visible: true, - column: 3, - style: CursorStyle::Block, - phase: true, - }; - assert!(cursor.covers(3)); - assert!(!cursor.covers(2)); - assert!(!cursor.trailing(4)); - assert!(cursor.trailing(2)); - let dark_phase = CursorPlacement { - phase: false, - ..cursor - }; - assert!(!dark_phase.covers(3)); - assert!(!dark_phase.trailing(2)); - } - - // ---- 引擎联通性 ---- - - /// 引擎联通性:喂入带 ANSI 换行的字节后,可见区出现对应文本。 - /// 这条守住 render 路径依赖的上游解析/网格行为。 - #[test] - fn engine_parses_output_into_grid() { - let term = term_with_output(b"hello\r\nworld"); - let content = term.renderable_content(); - let text: String = content.display_iter.map(|indexed| indexed.cell.c).collect(); - assert!( - text.contains("hello"), - "first line must contain hello: {text:?}" - ); - assert!( - text.contains("world"), - "second line must contain world: {text:?}" - ); - } - - /// 引擎联通性:写入超过屏高的行后可用上游 API 滚出显示偏移。 - #[test] - fn engine_scrollback_exposes_display_offset() { - let mut term = Term::new( - Config { - scrolling_history: 100, - ..Config::default() - }, - &dims(20, 2), - VoidListener, - ); - let mut processor: Processor = Processor::new(); - processor.advance(&mut term, b"one\r\ntwo\r\nthree\r\nfour"); - assert_eq!(term.grid().display_offset(), 0); - term.scroll_display(Scroll::Delta(2)); - assert_eq!(term.grid().display_offset(), 2); - term.scroll_display(Scroll::Bottom); - assert_eq!(term.grid().display_offset(), 0); - } - - /// 引擎联通性:选择一块文本后可经上游导出字符串(复制链路)。 - #[test] - fn engine_selection_to_string_roundtrips() { - let mut term = term_with_output(b"abcdef"); - let mut selection = Selection::new( - SelectionType::Simple, - GridPoint::new(Line(0), Column(0)), - Side::Left, + rgb8(Rgba { + r: 2.0, + g: -1.0, + b: 0.999, + a: 1.0 + }), + (255, 0, 255) ); - selection.update(GridPoint::new(Line(0), Column(2)), Side::Right); - term.selection = Some(selection); - let text = term.selection_to_string().expect("selection must export"); - assert!(text.starts_with("abc"), "selection text: {text:?}"); - } - - /// 引擎联通性:resize 改变列数(随窗缩放依赖上游 reflow)。 - #[test] - fn engine_resize_updates_columns() { - let mut term = Term::new(Config::default(), &dims(80, 24), VoidListener); - assert_eq!(term.columns(), 80); - term.resize(dims(100, 30)); - assert_eq!(term.columns(), 100); - assert_eq!(term.screen_lines(), 30); - } - - /// 引擎联通性:宽字符在网格里占两格,第二格带占位标记,渲染必须跳过它。 - #[test] - fn engine_marks_wide_char_spacer_cells() { - let term = term_with_output("中".as_bytes()); - let content = term.renderable_content(); - let spacers = content - .display_iter - .filter(|indexed| { - indexed - .cell - .flags - .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) - }) - .count(); - assert_eq!(spacers, 1, "wide char must occupy a spacer cell"); } } diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index b909f65d7..2b43b3141 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -1060,6 +1060,7 @@ impl WorkbenchView { gpui_kit::component::ThemeMode::Dark }; gpui_kit::component::Theme::change(mode, None, cx); + crate::fonts::apply_theme(cx); cx.notify(); } @@ -2145,6 +2146,7 @@ impl WorkbenchView { gpui_kit::component::ThemeMode::Dark }; gpui_kit::component::Theme::change(mode, None, cx); + crate::fonts::apply_theme(cx); cx.notify(); return; } diff --git a/third_party/gpui_xterm/Cargo.toml b/third_party/gpui_xterm/Cargo.toml new file mode 100644 index 000000000..e66b60073 --- /dev/null +++ b/third_party/gpui_xterm/Cargo.toml @@ -0,0 +1,35 @@ +# Lithe vendored copy of Modolet/gpui_xterm. +# +# Upstream: https://github.com/Modolet/gpui_xterm +# Revision: 4bdcdbbeab4211f7938f31919819b06d755a1733 (2026-06-05, "chore: 适配 git 版本 GPUI") +# License: MIT (see LICENSE) +# +# The upstream manifest depends on `gpui` from the Zed git repository and on +# `gpui-component` from the longbridge git repository. Lithe renders through the +# published `gpui-pre`/`gpui-component` crate family (re-exported by `gpui-kit`), +# which Cargo treats as different package identities. This manifest re-points the +# same source at Lithe's crate family and pins `alacritty_terminal` to the version +# already used by the Linux product, so both share one terminal engine version. +# Keep this file as the single place that records the dependency adaptation. + +[package] +name = "gpui_xterm" +version = "0.1.1" +edition = "2024" +description = "Reusable xterm-like terminal component for GPUI (Lithe vendored copy)" +license = "MIT" +publish = false + +[lib] +name = "gpui_xterm" +path = "src/lib.rs" +doctest = false + +[dependencies] +alacritty_terminal = "0.26" +anyhow = "1" +arboard = { version = "3", features = ["wayland-data-control"] } +flume = "0.12" +gpui = { package = "gpui-pre", version = "0.3.6" } +gpui-component = "0.6.6" +parking_lot = "0.12" diff --git a/third_party/gpui_xterm/LICENSE b/third_party/gpui_xterm/LICENSE new file mode 100644 index 000000000..62e29e377 --- /dev/null +++ b/third_party/gpui_xterm/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Modolet + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/gpui_xterm/README.md b/third_party/gpui_xterm/README.md new file mode 100644 index 000000000..2c4c3cf16 --- /dev/null +++ b/third_party/gpui_xterm/README.md @@ -0,0 +1,38 @@ +# Vendored `gpui_xterm` + +Pinned copy of [Modolet/gpui_xterm](https://github.com/Modolet/gpui_xterm) used by +the Linux GPUI product for the embedded terminal. + +- Upstream revision: `4bdcdbbeab4211f7938f31919819b06d755a1733` (2026-06-05) +- License: MIT (see `LICENSE`) + +## Why it is vendored + +Upstream depends on `gpui` from the Zed git repository and `gpui-component` from +the longbridge git repository. Lithe renders through the published +`gpui-pre`/`gpui-component` crate family re-exported by `gpui-kit`. Cargo treats a +git dependency and a registry dependency as different package identities, so the +upstream manifest cannot be consumed as-is without a compile-time type split. +`Cargo.toml` re-points the same source at Lithe's crate family and pins +`alacritty_terminal` to the version the Linux product already uses. + +## Local patches + +Keep this list in sync with the source. Each patch is marked with a `Lithe patch` +comment at the change site. + +1. `src/terminal.rs`: `TerminalState::with_scrollback` — upstream always built + `Config::default()` and ignored `TerminalConfig::scrollback`. +2. `src/view.rs`: `TerminalState` construction now passes `config.scrollback`. +3. `src/view.rs`: `TerminalView::state()` — exposes the engine state so the host + product can implement search, scroll-to-bottom and display-offset queries while + the component keeps ownership of rendering and input. +4. `src/render.rs`: `TerminalRenderer::measure_cell` measures the cell width from + the half-width ASCII advance (`'m'`) only. Upstream took the maximum over + `["M","W","█","▀","▄"]`; GPUI resolves fonts through its own fontdb, and on the + target Linux systems the block-element samples fall back to a full-width (1em) + CJK font, inflating every column to ~1.7× and making the whole grid much wider + than a normal terminal. + +When pulling a newer upstream revision, re-apply these patches and re-run +`cargo check -p gpui_xterm`. diff --git a/third_party/gpui_xterm/src/clipboard.rs b/third_party/gpui_xterm/src/clipboard.rs new file mode 100644 index 000000000..acac77716 --- /dev/null +++ b/third_party/gpui_xterm/src/clipboard.rs @@ -0,0 +1,87 @@ +use anyhow::Result; + +pub struct Clipboard { + clipboard: arboard::Clipboard, +} + +impl Clipboard { + pub fn new() -> Result { + Ok(Self { + clipboard: arboard::Clipboard::new()?, + }) + } + + pub fn copy(&mut self, text: &str) -> Result<()> { + self.clipboard.set_text(text)?; + Ok(()) + } + + pub fn paste(&mut self) -> Result { + Ok(self.clipboard.get_text()?) + } + + pub fn clear(&mut self) -> Result<()> { + self.clipboard.clear()?; + Ok(()) + } +} + +impl Default for Clipboard { + fn default() -> Self { + Self::new().expect("Failed to initialize clipboard") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clipboard_creation() { + let result = Clipboard::new(); + if result.is_err() { + eprintln!("Clipboard not available (headless environment)"); + } + } + + #[test] + fn test_clipboard_copy_paste() { + let Ok(mut clipboard) = Clipboard::new() else { + eprintln!("Clipboard not available (headless environment)"); + return; + }; + + let test_text = "Test clipboard content"; + + if let Err(e) = clipboard.copy(test_text) { + eprintln!("Copy failed (headless environment): {}", e); + return; + } + + match clipboard.paste() { + Ok(pasted) => { + if pasted == test_text { + } else { + eprintln!("Clipboard content mismatch (environment issue)"); + } + } + Err(e) => { + eprintln!("Paste failed (headless environment): {}", e); + } + } + } + + #[test] + fn test_clipboard_clear() { + let Ok(mut clipboard) = Clipboard::new() else { + eprintln!("Clipboard not available (headless environment)"); + return; + }; + + let test_text = "Content to be cleared"; + + if clipboard.copy(test_text).is_ok() { + let _ = clipboard.clear(); + } + } +} diff --git a/third_party/gpui_xterm/src/colors.rs b/third_party/gpui_xterm/src/colors.rs new file mode 100644 index 000000000..32cf386c3 --- /dev/null +++ b/third_party/gpui_xterm/src/colors.rs @@ -0,0 +1,472 @@ +use alacritty_terminal::term::color::Colors; +use alacritty_terminal::vte::ansi::{Color, NamedColor, Rgb}; +use gpui::Hsla; + +#[derive(Debug, Clone)] +pub struct ColorPalette { + ansi_colors: [Hsla; 16], + + extended_colors: [Hsla; 256], + + foreground: Hsla, + + background: Hsla, + + cursor: Hsla, +} + +impl Default for ColorPalette { + fn default() -> Self { + let ansi_colors = [ + rgb_to_hsla(Rgb { + r: 0x00, + g: 0x00, + b: 0x00, + }), + rgb_to_hsla(Rgb { + r: 0xcc, + g: 0x00, + b: 0x00, + }), + rgb_to_hsla(Rgb { + r: 0x4e, + g: 0x9a, + b: 0x06, + }), + rgb_to_hsla(Rgb { + r: 0xc4, + g: 0xa0, + b: 0x00, + }), + rgb_to_hsla(Rgb { + r: 0x34, + g: 0x65, + b: 0xa4, + }), + rgb_to_hsla(Rgb { + r: 0x75, + g: 0x50, + b: 0x7b, + }), + rgb_to_hsla(Rgb { + r: 0x06, + g: 0x98, + b: 0x9a, + }), + rgb_to_hsla(Rgb { + r: 0xd3, + g: 0xd7, + b: 0xcf, + }), + rgb_to_hsla(Rgb { + r: 0x55, + g: 0x57, + b: 0x53, + }), + rgb_to_hsla(Rgb { + r: 0xef, + g: 0x29, + b: 0x29, + }), + rgb_to_hsla(Rgb { + r: 0x8a, + g: 0xe2, + b: 0x34, + }), + rgb_to_hsla(Rgb { + r: 0xfc, + g: 0xe9, + b: 0x4f, + }), + rgb_to_hsla(Rgb { + r: 0x72, + g: 0x9f, + b: 0xcf, + }), + rgb_to_hsla(Rgb { + r: 0xad, + g: 0x7f, + b: 0xa8, + }), + rgb_to_hsla(Rgb { + r: 0x34, + g: 0xe2, + b: 0xe2, + }), + rgb_to_hsla(Rgb { + r: 0xee, + g: 0xee, + b: 0xec, + }), + ]; + + let mut extended_colors = [Hsla::default(); 256]; + + extended_colors[0..16].copy_from_slice(&ansi_colors); + + let mut idx = 16; + for r in 0..6 { + for g in 0..6 { + for b in 0..6 { + let rgb = Rgb { + r: if r == 0 { 0 } else { 55 + r * 40 }, + g: if g == 0 { 0 } else { 55 + g * 40 }, + b: if b == 0 { 0 } else { 55 + b * 40 }, + }; + extended_colors[idx] = rgb_to_hsla(rgb); + idx += 1; + } + } + } + + for i in 0..24 { + let gray = (8 + i * 10) as u8; + extended_colors[232 + i] = rgb_to_hsla(Rgb { + r: gray, + g: gray, + b: gray, + }); + } + + let foreground = rgb_to_hsla(Rgb { + r: 0xd4, + g: 0xd4, + b: 0xd4, + }); + let background = rgb_to_hsla(Rgb { + r: 0x1e, + g: 0x1e, + b: 0x1e, + }); + let cursor = rgb_to_hsla(Rgb { + r: 0xff, + g: 0xff, + b: 0xff, + }); + + Self { + ansi_colors, + extended_colors, + foreground, + background, + cursor, + } + } +} + +impl ColorPalette { + pub fn new() -> Self { + Self::default() + } + + pub fn builder() -> ColorPaletteBuilder { + ColorPaletteBuilder::new() + } + + pub fn resolve(&self, color: Color, colors: &Colors) -> Hsla { + match color { + Color::Named(named) => { + if let Some(rgb) = colors[named] { + return rgb_to_hsla(rgb); + } + + let idx = named as usize; + if idx < 16 { + self.ansi_colors[idx] + } else { + match named { + NamedColor::Foreground => self.foreground, + NamedColor::Background => self.background, + NamedColor::Cursor => self.cursor, + NamedColor::DimForeground => { + let mut dim = self.foreground; + dim.l *= 0.7; + dim + } + NamedColor::BrightForeground => { + let mut bright = self.foreground; + bright.l = (bright.l * 1.2).min(1.0); + bright + } + NamedColor::DimBlack + | NamedColor::DimRed + | NamedColor::DimGreen + | NamedColor::DimYellow + | NamedColor::DimBlue + | NamedColor::DimMagenta + | NamedColor::DimCyan + | NamedColor::DimWhite => { + let base_idx = match named { + NamedColor::DimBlack => 0, + NamedColor::DimRed => 1, + NamedColor::DimGreen => 2, + NamedColor::DimYellow => 3, + NamedColor::DimBlue => 4, + NamedColor::DimMagenta => 5, + NamedColor::DimCyan => 6, + NamedColor::DimWhite => 7, + _ => 7, + }; + let mut dim = self.ansi_colors[base_idx]; + dim.l *= 0.7; + dim + } + _ => self.foreground, + } + } + } + Color::Spec(rgb) => rgb_to_hsla(rgb), + Color::Indexed(idx) => self.extended_colors[idx as usize], + } + } + + pub fn ansi_colors(&self) -> &[Hsla; 16] { + &self.ansi_colors + } + + pub fn extended_colors(&self) -> &[Hsla; 256] { + &self.extended_colors + } + + pub fn foreground(&self) -> Hsla { + self.foreground + } + + pub fn background(&self) -> Hsla { + self.background + } + + pub fn cursor(&self) -> Hsla { + self.cursor + } +} + +fn rgb_to_hsla(rgb: Rgb) -> Hsla { + let r = rgb.r as f32 / 255.0; + let g = rgb.g as f32 / 255.0; + let b = rgb.b as f32 / 255.0; + + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let delta = max - min; + + let l = (max + min) / 2.0; + + let s = if delta == 0.0 { + 0.0 + } else { + delta / (1.0 - (2.0 * l - 1.0).abs()) + }; + + let h = if delta == 0.0 { + 0.0 + } else if max == r { + 60.0 * (((g - b) / delta) % 6.0) + } else if max == g { + 60.0 * (((b - r) / delta) + 2.0) + } else { + 60.0 * (((r - g) / delta) + 4.0) + }; + + let h = if h < 0.0 { h + 360.0 } else { h } / 360.0; + + Hsla { h, s, l, a: 1.0 } +} + +#[derive(Debug, Clone)] +pub struct ColorPaletteBuilder { + palette: ColorPalette, +} + +impl Default for ColorPaletteBuilder { + fn default() -> Self { + Self::new() + } +} + +impl ColorPaletteBuilder { + pub fn new() -> Self { + Self { + palette: ColorPalette::default(), + } + } + + pub fn background(mut self, r: u8, g: u8, b: u8) -> Self { + self.palette.background = rgb_to_hsla(Rgb { r, g, b }); + self + } + + pub fn foreground(mut self, r: u8, g: u8, b: u8) -> Self { + self.palette.foreground = rgb_to_hsla(Rgb { r, g, b }); + self + } + + pub fn cursor(mut self, r: u8, g: u8, b: u8) -> Self { + self.palette.cursor = rgb_to_hsla(Rgb { r, g, b }); + self + } + + pub fn black(mut self, r: u8, g: u8, b: u8) -> Self { + self.set_ansi_color(0, r, g, b); + self + } + + pub fn red(mut self, r: u8, g: u8, b: u8) -> Self { + self.set_ansi_color(1, r, g, b); + self + } + + pub fn green(mut self, r: u8, g: u8, b: u8) -> Self { + self.set_ansi_color(2, r, g, b); + self + } + + pub fn yellow(mut self, r: u8, g: u8, b: u8) -> Self { + self.set_ansi_color(3, r, g, b); + self + } + + pub fn blue(mut self, r: u8, g: u8, b: u8) -> Self { + self.set_ansi_color(4, r, g, b); + self + } + + pub fn magenta(mut self, r: u8, g: u8, b: u8) -> Self { + self.set_ansi_color(5, r, g, b); + self + } + + pub fn cyan(mut self, r: u8, g: u8, b: u8) -> Self { + self.set_ansi_color(6, r, g, b); + self + } + + pub fn white(mut self, r: u8, g: u8, b: u8) -> Self { + self.set_ansi_color(7, r, g, b); + self + } + + pub fn bright_black(mut self, r: u8, g: u8, b: u8) -> Self { + self.set_ansi_color(8, r, g, b); + self + } + + pub fn bright_red(mut self, r: u8, g: u8, b: u8) -> Self { + self.set_ansi_color(9, r, g, b); + self + } + + pub fn bright_green(mut self, r: u8, g: u8, b: u8) -> Self { + self.set_ansi_color(10, r, g, b); + self + } + + pub fn bright_yellow(mut self, r: u8, g: u8, b: u8) -> Self { + self.set_ansi_color(11, r, g, b); + self + } + + pub fn bright_blue(mut self, r: u8, g: u8, b: u8) -> Self { + self.set_ansi_color(12, r, g, b); + self + } + + pub fn bright_magenta(mut self, r: u8, g: u8, b: u8) -> Self { + self.set_ansi_color(13, r, g, b); + self + } + + pub fn bright_cyan(mut self, r: u8, g: u8, b: u8) -> Self { + self.set_ansi_color(14, r, g, b); + self + } + + pub fn bright_white(mut self, r: u8, g: u8, b: u8) -> Self { + self.set_ansi_color(15, r, g, b); + self + } + + fn set_ansi_color(&mut self, idx: usize, r: u8, g: u8, b: u8) { + let color = rgb_to_hsla(Rgb { r, g, b }); + self.palette.ansi_colors[idx] = color; + self.palette.extended_colors[idx] = color; + } + + pub fn build(self) -> ColorPalette { + self.palette + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rgb_to_hsla_black() { + let rgb = Rgb { r: 0, g: 0, b: 0 }; + let hsla = rgb_to_hsla(rgb); + assert_eq!(hsla.l, 0.0); + assert_eq!(hsla.s, 0.0); + assert_eq!(hsla.a, 1.0); + } + + #[test] + fn test_rgb_to_hsla_white() { + let rgb = Rgb { + r: 255, + g: 255, + b: 255, + }; + let hsla = rgb_to_hsla(rgb); + assert_eq!(hsla.l, 1.0); + assert_eq!(hsla.s, 0.0); + assert_eq!(hsla.a, 1.0); + } + + #[test] + fn test_rgb_to_hsla_red() { + let rgb = Rgb { r: 255, g: 0, b: 0 }; + let hsla = rgb_to_hsla(rgb); + assert_eq!(hsla.h, 0.0); + assert_eq!(hsla.s, 1.0); + assert_eq!(hsla.a, 1.0); + } + + #[test] + fn test_color_palette_default() { + let palette = ColorPalette::default(); + assert_eq!(palette.ansi_colors.len(), 16); + assert_eq!(palette.extended_colors.len(), 256); + } + + #[test] + fn test_resolve_named_color() { + use alacritty_terminal::vte::ansi::NamedColor; + + let palette = ColorPalette::new(); + let colors = Colors::default(); + let hsla = palette.resolve(Color::Named(NamedColor::Red), &colors); + assert!(hsla.a > 0.0); + } + + #[test] + fn test_resolve_indexed_color() { + let palette = ColorPalette::new(); + let colors = Colors::default(); + let hsla = palette.resolve(Color::Indexed(42), &colors); + assert_eq!(hsla.a, 1.0); + } + + #[test] + fn test_resolve_spec_color() { + let palette = ColorPalette::new(); + let colors = Colors::default(); + let rgb = Rgb { + r: 128, + g: 64, + b: 192, + }; + let hsla = palette.resolve(Color::Spec(rgb), &colors); + assert_eq!(hsla.a, 1.0); + } +} diff --git a/third_party/gpui_xterm/src/event.rs b/third_party/gpui_xterm/src/event.rs new file mode 100644 index 000000000..33ac0b28c --- /dev/null +++ b/third_party/gpui_xterm/src/event.rs @@ -0,0 +1,195 @@ +use alacritty_terminal::event::{Event, EventListener}; +use std::sync::mpsc::Sender; + +#[derive(Debug, Clone)] +pub enum TerminalEvent { + Wakeup, + + Bell, + + Title(String), + + ClipboardStore(String), + + ClipboardLoad, + + Exit, +} + +pub struct GpuiEventProxy { + tx: Sender, +} + +impl GpuiEventProxy { + pub fn new(tx: Sender) -> Self { + Self { tx } + } + + fn send(&self, event: TerminalEvent) { + let _ = self.tx.send(event); + } +} + +impl EventListener for GpuiEventProxy { + fn send_event(&self, event: Event) { + match event { + Event::Wakeup => { + self.send(TerminalEvent::Wakeup); + } + Event::Bell => { + self.send(TerminalEvent::Bell); + } + Event::Title(title) => { + self.send(TerminalEvent::Title(title)); + } + Event::ClipboardStore(_clipboard_type, data) => { + self.send(TerminalEvent::ClipboardStore(data)); + } + Event::ClipboardLoad(_clipboard_type, _format) => { + self.send(TerminalEvent::ClipboardLoad); + } + Event::Exit => { + self.send(TerminalEvent::Exit); + } + Event::MouseCursorDirty => {} + Event::PtyWrite(ref _data) => {} + Event::ColorRequest(ref _index, ref _format) => {} + Event::TextAreaSizeRequest(ref _format) => {} + Event::CursorBlinkingChange => {} + Event::ResetTitle => { + self.send(TerminalEvent::Title(String::new())); + } + Event::ChildExit(_exit_code) => { + self.send(TerminalEvent::Exit); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc::channel; + + #[test] + fn test_event_proxy_creation() { + let (tx, _rx) = channel(); + let _proxy = GpuiEventProxy::new(tx); + } + + #[test] + fn test_wakeup_event() { + let (tx, rx) = channel(); + let proxy = GpuiEventProxy::new(tx); + + proxy.send_event(Event::Wakeup); + + let event = rx.recv().unwrap(); + assert!(matches!(event, TerminalEvent::Wakeup)); + } + + #[test] + fn test_bell_event() { + let (tx, rx) = channel(); + let proxy = GpuiEventProxy::new(tx); + + proxy.send_event(Event::Bell); + + let event = rx.recv().unwrap(); + assert!(matches!(event, TerminalEvent::Bell)); + } + + #[test] + fn test_title_event() { + let (tx, rx) = channel(); + let proxy = GpuiEventProxy::new(tx); + + proxy.send_event(Event::Title("Test Title".to_string())); + + let event = rx.recv().unwrap(); + match event { + TerminalEvent::Title(title) => assert_eq!(title, "Test Title"), + _ => panic!("Expected Title event"), + } + } + + #[test] + fn test_clipboard_store_event() { + use alacritty_terminal::term::ClipboardType; + + let (tx, rx) = channel(); + let proxy = GpuiEventProxy::new(tx); + + proxy.send_event(Event::ClipboardStore( + ClipboardType::Clipboard, + "clipboard data".to_string(), + )); + + let event = rx.recv().unwrap(); + match event { + TerminalEvent::ClipboardStore(data) => assert_eq!(data, "clipboard data"), + _ => panic!("Expected ClipboardStore event"), + } + } + + #[test] + fn test_clipboard_load_event() { + use alacritty_terminal::term::ClipboardType; + use std::sync::Arc; + + let (tx, rx) = channel(); + let proxy = GpuiEventProxy::new(tx); + + let callback = Arc::new(|s: &str| s.to_string()); + proxy.send_event(Event::ClipboardLoad(ClipboardType::Clipboard, callback)); + + let event = rx.recv().unwrap(); + assert!(matches!(event, TerminalEvent::ClipboardLoad)); + } + + #[test] + fn test_exit_event() { + let (tx, rx) = channel(); + let proxy = GpuiEventProxy::new(tx); + + proxy.send_event(Event::Exit); + + let event = rx.recv().unwrap(); + assert!(matches!(event, TerminalEvent::Exit)); + } + + #[test] + fn test_reset_title_event() { + let (tx, rx) = channel(); + let proxy = GpuiEventProxy::new(tx); + + proxy.send_event(Event::ResetTitle); + + let event = rx.recv().unwrap(); + match event { + TerminalEvent::Title(title) => assert!(title.is_empty()), + _ => panic!("Expected Title event"), + } + } + + #[test] + fn test_ignored_events() { + let (tx, rx) = channel(); + let proxy = GpuiEventProxy::new(tx); + + proxy.send_event(Event::MouseCursorDirty); + proxy.send_event(Event::CursorBlinkingChange); + + assert!(rx.try_recv().is_err()); + } + + #[test] + fn test_disconnected_channel() { + let (tx, rx) = channel(); + let proxy = GpuiEventProxy::new(tx); + + drop(rx); + + proxy.send_event(Event::Wakeup); + } +} diff --git a/third_party/gpui_xterm/src/input.rs b/third_party/gpui_xterm/src/input.rs new file mode 100644 index 000000000..ca8b227e5 --- /dev/null +++ b/third_party/gpui_xterm/src/input.rs @@ -0,0 +1,296 @@ +use alacritty_terminal::term::TermMode; +use gpui::Keystroke; + +pub fn keystroke_to_bytes(keystroke: &Keystroke, mode: TermMode) -> Option> { + match keystroke.key.as_str() { + "space" => { + if keystroke.modifiers.control { + return Some(b"\x00".to_vec()); + } + return Some(b" ".to_vec()); + } + "enter" => return Some(b"\r".to_vec()), + "escape" => return Some(b"\x1b".to_vec()), + "backspace" => return Some(b"\x7f".to_vec()), + "tab" => { + if keystroke.modifiers.shift { + return Some(b"\x1b[Z".to_vec()); + } + return Some(b"\t".to_vec()); + } + + "up" => { + if mode.contains(TermMode::APP_CURSOR) { + return Some(b"\x1bOA".to_vec()); + } + return Some(b"\x1b[A".to_vec()); + } + "down" => { + if mode.contains(TermMode::APP_CURSOR) { + return Some(b"\x1bOB".to_vec()); + } + return Some(b"\x1b[B".to_vec()); + } + "right" => { + if mode.contains(TermMode::APP_CURSOR) { + return Some(b"\x1bOC".to_vec()); + } + return Some(b"\x1b[C".to_vec()); + } + "left" => { + if mode.contains(TermMode::APP_CURSOR) { + return Some(b"\x1bOD".to_vec()); + } + return Some(b"\x1b[D".to_vec()); + } + + "home" => return Some(b"\x1b[H".to_vec()), + "end" => return Some(b"\x1b[F".to_vec()), + "pageup" => return Some(b"\x1b[5~".to_vec()), + "pagedown" => return Some(b"\x1b[6~".to_vec()), + "insert" => return Some(b"\x1b[2~".to_vec()), + "delete" => return Some(b"\x1b[3~".to_vec()), + + "f1" => return Some(b"\x1bOP".to_vec()), + "f2" => return Some(b"\x1bOQ".to_vec()), + "f3" => return Some(b"\x1bOR".to_vec()), + "f4" => return Some(b"\x1bOS".to_vec()), + "f5" => return Some(b"\x1b[15~".to_vec()), + "f6" => return Some(b"\x1b[17~".to_vec()), + "f7" => return Some(b"\x1b[18~".to_vec()), + "f8" => return Some(b"\x1b[19~".to_vec()), + "f9" => return Some(b"\x1b[20~".to_vec()), + "f10" => return Some(b"\x1b[21~".to_vec()), + "f11" => return Some(b"\x1b[23~".to_vec()), + "f12" => return Some(b"\x1b[24~".to_vec()), + + _ => {} + } + + if keystroke.modifiers.control { + let key = keystroke.key.as_str(); + + if key.len() == 1 { + let ch = key.chars().next().unwrap(); + if ch.is_ascii_alphabetic() { + let upper = ch.to_ascii_uppercase(); + let ctrl_char = (upper as u8) - b'@'; + return Some(vec![ctrl_char]); + } + + match ch { + '[' => return Some(b"\x1b".to_vec()), + '\\' => return Some(b"\x1c".to_vec()), + ']' => return Some(b"\x1d".to_vec()), + '^' => return Some(b"\x1e".to_vec()), + '_' => return Some(b"\x1f".to_vec()), + '?' => return Some(b"\x7f".to_vec()), + _ => {} + } + } + } + + if keystroke.modifiers.alt { + let key = keystroke.key.as_str(); + if key.len() == 1 { + let ch = key.chars().next().unwrap(); + if ch.is_ascii() { + let mut bytes = vec![b'\x1b']; + bytes.push(ch as u8); + return Some(bytes); + } + } + } + + if let Some(key_char) = &keystroke.key_char + && !keystroke.modifiers.control + && !keystroke.modifiers.alt + { + return Some(key_char.as_bytes().to_vec()); + } + + let key = keystroke.key.as_str(); + if key.len() == 1 { + let ch = key.chars().next().unwrap(); + if ch.is_ascii() && !keystroke.modifiers.control { + let ch = if keystroke.modifiers.shift { + ch.to_ascii_uppercase() + } else { + ch + }; + return Some(vec![ch as u8]); + } + if !keystroke.modifiers.control && !keystroke.modifiers.alt { + return Some(key.as_bytes().to_vec()); + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_enter_key() { + let keystroke = Keystroke::parse("enter").unwrap(); + let bytes = keystroke_to_bytes(&keystroke, TermMode::empty()); + assert_eq!(bytes, Some(b"\r".to_vec())); + } + + #[test] + fn test_escape_key() { + let keystroke = Keystroke::parse("escape").unwrap(); + let bytes = keystroke_to_bytes(&keystroke, TermMode::empty()); + assert_eq!(bytes, Some(b"\x1b".to_vec())); + } + + #[test] + fn test_backspace_key() { + let keystroke = Keystroke::parse("backspace").unwrap(); + let bytes = keystroke_to_bytes(&keystroke, TermMode::empty()); + assert_eq!(bytes, Some(b"\x7f".to_vec())); + } + + #[test] + fn test_tab_key() { + let keystroke = Keystroke::parse("tab").unwrap(); + let bytes = keystroke_to_bytes(&keystroke, TermMode::empty()); + assert_eq!(bytes, Some(b"\t".to_vec())); + } + + #[test] + fn test_shift_tab() { + let keystroke = Keystroke::parse("shift-tab").unwrap(); + let bytes = keystroke_to_bytes(&keystroke, TermMode::empty()); + assert_eq!(bytes, Some(b"\x1b[Z".to_vec())); + } + + #[test] + fn test_arrow_keys_normal_mode() { + let mode = TermMode::empty(); + + let up = Keystroke::parse("up").unwrap(); + assert_eq!(keystroke_to_bytes(&up, mode), Some(b"\x1b[A".to_vec())); + + let down = Keystroke::parse("down").unwrap(); + assert_eq!(keystroke_to_bytes(&down, mode), Some(b"\x1b[B".to_vec())); + + let right = Keystroke::parse("right").unwrap(); + assert_eq!(keystroke_to_bytes(&right, mode), Some(b"\x1b[C".to_vec())); + + let left = Keystroke::parse("left").unwrap(); + assert_eq!(keystroke_to_bytes(&left, mode), Some(b"\x1b[D".to_vec())); + } + + #[test] + fn test_arrow_keys_app_cursor_mode() { + let mode = TermMode::APP_CURSOR; + + let up = Keystroke::parse("up").unwrap(); + assert_eq!(keystroke_to_bytes(&up, mode), Some(b"\x1bOA".to_vec())); + + let down = Keystroke::parse("down").unwrap(); + assert_eq!(keystroke_to_bytes(&down, mode), Some(b"\x1bOB".to_vec())); + + let right = Keystroke::parse("right").unwrap(); + assert_eq!(keystroke_to_bytes(&right, mode), Some(b"\x1bOC".to_vec())); + + let left = Keystroke::parse("left").unwrap(); + assert_eq!(keystroke_to_bytes(&left, mode), Some(b"\x1bOD".to_vec())); + } + + #[test] + fn test_navigation_keys() { + let mode = TermMode::empty(); + + let home = Keystroke::parse("home").unwrap(); + assert_eq!(keystroke_to_bytes(&home, mode), Some(b"\x1b[H".to_vec())); + + let end = Keystroke::parse("end").unwrap(); + assert_eq!(keystroke_to_bytes(&end, mode), Some(b"\x1b[F".to_vec())); + + let pageup = Keystroke::parse("pageup").unwrap(); + assert_eq!(keystroke_to_bytes(&pageup, mode), Some(b"\x1b[5~".to_vec())); + + let pagedown = Keystroke::parse("pagedown").unwrap(); + assert_eq!( + keystroke_to_bytes(&pagedown, mode), + Some(b"\x1b[6~".to_vec()) + ); + + let insert = Keystroke::parse("insert").unwrap(); + assert_eq!(keystroke_to_bytes(&insert, mode), Some(b"\x1b[2~".to_vec())); + + let delete = Keystroke::parse("delete").unwrap(); + assert_eq!(keystroke_to_bytes(&delete, mode), Some(b"\x1b[3~".to_vec())); + } + + #[test] + fn test_function_keys() { + let mode = TermMode::empty(); + + let f1 = Keystroke::parse("f1").unwrap(); + assert_eq!(keystroke_to_bytes(&f1, mode), Some(b"\x1bOP".to_vec())); + + let f2 = Keystroke::parse("f2").unwrap(); + assert_eq!(keystroke_to_bytes(&f2, mode), Some(b"\x1bOQ".to_vec())); + + let f5 = Keystroke::parse("f5").unwrap(); + assert_eq!(keystroke_to_bytes(&f5, mode), Some(b"\x1b[15~".to_vec())); + + let f12 = Keystroke::parse("f12").unwrap(); + assert_eq!(keystroke_to_bytes(&f12, mode), Some(b"\x1b[24~".to_vec())); + } + + #[test] + fn test_ctrl_combinations() { + let mode = TermMode::empty(); + + let ctrl_a = Keystroke::parse("ctrl-a").unwrap(); + assert_eq!(keystroke_to_bytes(&ctrl_a, mode), Some(vec![0x01])); + + let ctrl_c = Keystroke::parse("ctrl-c").unwrap(); + assert_eq!(keystroke_to_bytes(&ctrl_c, mode), Some(vec![0x03])); + + let ctrl_z = Keystroke::parse("ctrl-z").unwrap(); + assert_eq!(keystroke_to_bytes(&ctrl_z, mode), Some(vec![0x1a])); + + let ctrl_space = Keystroke::parse("ctrl-space").unwrap(); + assert_eq!(keystroke_to_bytes(&ctrl_space, mode), Some(vec![0x00])); + } + + #[test] + fn test_alt_combinations() { + let mode = TermMode::empty(); + + let alt_a = Keystroke::parse("alt-a").unwrap(); + assert_eq!(keystroke_to_bytes(&alt_a, mode), Some(b"\x1ba".to_vec())); + + let alt_x = Keystroke::parse("alt-x").unwrap(); + assert_eq!(keystroke_to_bytes(&alt_x, mode), Some(b"\x1bx".to_vec())); + } + + #[test] + fn test_regular_characters() { + let mode = TermMode::empty(); + + let a = Keystroke::parse("a").unwrap(); + assert_eq!(keystroke_to_bytes(&a, mode), Some(b"a".to_vec())); + + let z = Keystroke::parse("z").unwrap(); + assert_eq!(keystroke_to_bytes(&z, mode), Some(b"z".to_vec())); + + let zero = Keystroke::parse("0").unwrap(); + assert_eq!(keystroke_to_bytes(&zero, mode), Some(b"0".to_vec())); + } + + #[test] + fn test_space_key() { + let mode = TermMode::empty(); + + let space = Keystroke::parse("space").unwrap(); + assert_eq!(keystroke_to_bytes(&space, mode), Some(b" ".to_vec())); + } +} diff --git a/third_party/gpui_xterm/src/lib.rs b/third_party/gpui_xterm/src/lib.rs new file mode 100644 index 000000000..9b0762ffb --- /dev/null +++ b/third_party/gpui_xterm/src/lib.rs @@ -0,0 +1,18 @@ +pub mod clipboard; +pub mod colors; +pub mod event; +pub mod input; +pub mod mouse; +pub mod render; +pub mod terminal; +pub mod view; + +pub use clipboard::Clipboard; +pub use colors::{ColorPalette, ColorPaletteBuilder}; +pub use event::{GpuiEventProxy, TerminalEvent}; +pub use render::TerminalRenderer; +pub use terminal::TerminalState; +pub use view::{ + BellCallback, ClipboardStoreCallback, ExitCallback, KeyHandler, ResizeCallback, TerminalConfig, + TerminalView, TitleCallback, +}; diff --git a/third_party/gpui_xterm/src/mouse.rs b/third_party/gpui_xterm/src/mouse.rs new file mode 100644 index 000000000..d5c7b7a41 --- /dev/null +++ b/third_party/gpui_xterm/src/mouse.rs @@ -0,0 +1,371 @@ +use alacritty_terminal::index::{Column, Line, Point as AlacPoint}; +use alacritty_terminal::term::TermMode; +use gpui::{MouseButton, Pixels, Point}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SelectionType { + Simple, + Word, + Line, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Selection { + pub start: AlacPoint, + pub end: AlacPoint, + pub selection_type: SelectionType, +} + +impl Selection { + pub fn new(start: AlacPoint, end: AlacPoint, selection_type: SelectionType) -> Self { + Self { + start, + end, + selection_type, + } + } + + pub fn contains(&self, point: AlacPoint) -> bool { + let (start, end) = if self.start < self.end { + (self.start, self.end) + } else { + (self.end, self.start) + }; + + point >= start && point <= end + } +} + +pub fn pixel_to_cell( + position: Point, + origin: Point, + cell_width: Pixels, + cell_height: Pixels, +) -> AlacPoint { + let col = ((position.x - origin.x) / cell_width).floor(); + let col = col.max(0.0) as usize; + + let row = ((position.y - origin.y) / cell_height).floor(); + let row = row.max(0.0) as i32; + + AlacPoint::new(Line(row), Column(col)) +} + +pub fn selection_type_from_clicks(click_count: usize) -> SelectionType { + match click_count { + 1 => SelectionType::Simple, + 2 => SelectionType::Word, + _ => SelectionType::Line, + } +} + +pub fn mouse_button_report( + button: MouseButton, + pressed: bool, + point: AlacPoint, + modifiers: u8, + mode: TermMode, +) -> Option> { + if !mode + .intersects(TermMode::MOUSE_REPORT_CLICK | TermMode::MOUSE_MOTION | TermMode::MOUSE_DRAG) + { + return None; + } + + let button_code = match button { + MouseButton::Left => 0, + MouseButton::Middle => 1, + MouseButton::Right => 2, + _ => return None, + }; + + let button_value = button_code | modifiers; + + let col = point.column.0 + 1; + let row = point.line.0 + 1; + + let action = if pressed { b'M' } else { b'm' }; + + let sequence = format!("\x1b[<{};{};{}{}", button_value, col, row, action as char); + Some(sequence.into_bytes()) +} + +pub fn scroll_report( + delta: i32, + point: AlacPoint, + modifiers: u8, + mode: TermMode, +) -> Option> { + if mode.intersects(TermMode::MOUSE_REPORT_CLICK | TermMode::MOUSE_MOTION | TermMode::MOUSE_DRAG) + { + let button_code = if delta > 0 { 64 } else { 65 }; + let button_value = button_code | modifiers; + + let col = point.column.0 + 1; + let row = point.line.0 + 1; + + let sequence = format!("\x1b[<{};{};{}M", button_value, col, row); + return Some(sequence.into_bytes()); + } + + if mode.contains(TermMode::ALT_SCREEN) { + return Some(scroll_to_arrow_keys(delta, mode)); + } + + None +} + +fn scroll_to_arrow_keys(delta: i32, mode: TermMode) -> Vec { + let count = delta.abs().min(5) as usize; + + let arrow_seq = if delta > 0 { + if mode.contains(TermMode::APP_CURSOR) { + b"\x1bOA" + } else { + b"\x1b[A" + } + } else { + if mode.contains(TermMode::APP_CURSOR) { + b"\x1bOB" + } else { + b"\x1b[B" + } + }; + + let mut result = Vec::with_capacity(arrow_seq.len() * count); + for _ in 0..count { + result.extend_from_slice(arrow_seq); + } + result +} + +pub fn encode_modifiers(shift: bool, alt: bool, control: bool) -> u8 { + let mut modifiers = 0; + if shift { + modifiers |= 4; + } + if alt { + modifiers |= 8; + } + if control { + modifiers |= 16; + } + modifiers +} + +pub fn pixels_to_scroll_lines(pixel_delta: Pixels, cell_height: Pixels) -> i32 { + let lines = (pixel_delta / cell_height).round(); + lines.clamp(-10.0, 10.0) as i32 +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{point, px}; + + #[test] + fn test_pixel_to_cell() { + let position = point(px(100.0), px(50.0)); + let origin = point(px(10.0), px(10.0)); + let cell_width = px(10.0); + let cell_height = px(20.0); + + let point = pixel_to_cell(position, origin, cell_width, cell_height); + assert_eq!(point.column.0, 9); + assert_eq!(point.line.0, 2); + } + + #[test] + fn test_pixel_to_cell_at_origin() { + let position = point(px(10.0), px(10.0)); + let origin = point(px(10.0), px(10.0)); + let cell_width = px(10.0); + let cell_height = px(20.0); + + let point = pixel_to_cell(position, origin, cell_width, cell_height); + assert_eq!(point.column.0, 0); + assert_eq!(point.line.0, 0); + } + + #[test] + fn test_pixel_to_cell_negative_coordinates() { + let position = point(px(5.0), px(5.0)); + let origin = point(px(10.0), px(10.0)); + let cell_width = px(10.0); + let cell_height = px(20.0); + + let point = pixel_to_cell(position, origin, cell_width, cell_height); + assert_eq!(point.column.0, 0); + assert_eq!(point.line.0, 0); + } + + #[test] + fn test_selection_type_from_clicks() { + assert_eq!(selection_type_from_clicks(1), SelectionType::Simple); + assert_eq!(selection_type_from_clicks(2), SelectionType::Word); + assert_eq!(selection_type_from_clicks(3), SelectionType::Line); + assert_eq!(selection_type_from_clicks(4), SelectionType::Line); + assert_eq!(selection_type_from_clicks(10), SelectionType::Line); + } + + #[test] + fn test_selection_contains() { + let selection = Selection::new( + AlacPoint::new(Line(5), Column(10)), + AlacPoint::new(Line(7), Column(20)), + SelectionType::Simple, + ); + + assert!(selection.contains(AlacPoint::new(Line(6), Column(15)))); + + assert!(selection.contains(AlacPoint::new(Line(5), Column(10)))); + assert!(selection.contains(AlacPoint::new(Line(7), Column(20)))); + + assert!(!selection.contains(AlacPoint::new(Line(4), Column(15)))); + assert!(!selection.contains(AlacPoint::new(Line(8), Column(15)))); + } + + #[test] + fn test_selection_contains_reverse() { + let selection = Selection::new( + AlacPoint::new(Line(7), Column(20)), + AlacPoint::new(Line(5), Column(10)), + SelectionType::Simple, + ); + + assert!(selection.contains(AlacPoint::new(Line(6), Column(15)))); + assert!(selection.contains(AlacPoint::new(Line(5), Column(10)))); + assert!(selection.contains(AlacPoint::new(Line(7), Column(20)))); + } + + #[test] + fn test_mouse_button_report_left_click() { + let point = AlacPoint::new(Line(5), Column(10)); + let mode = TermMode::MOUSE_REPORT_CLICK; + + let bytes = mouse_button_report(MouseButton::Left, true, point, 0, mode); + assert!(bytes.is_some()); + + let sequence = String::from_utf8(bytes.unwrap()).unwrap(); + assert_eq!(sequence, "\x1b[<0;11;6M"); + } + + #[test] + fn test_mouse_button_report_right_release() { + let point = AlacPoint::new(Line(0), Column(0)); + let mode = TermMode::MOUSE_REPORT_CLICK; + + let bytes = mouse_button_report(MouseButton::Right, false, point, 0, mode); + assert!(bytes.is_some()); + + let sequence = String::from_utf8(bytes.unwrap()).unwrap(); + assert_eq!(sequence, "\x1b[<2;1;1m"); + } + + #[test] + fn test_mouse_button_report_with_modifiers() { + let point = AlacPoint::new(Line(0), Column(0)); + let mode = TermMode::MOUSE_REPORT_CLICK; + let modifiers = encode_modifiers(true, true, true); + + let bytes = mouse_button_report(MouseButton::Left, true, point, modifiers, mode); + assert!(bytes.is_some()); + + let sequence = String::from_utf8(bytes.unwrap()).unwrap(); + assert_eq!(sequence, "\x1b[<28;1;1M"); + } + + #[test] + fn test_mouse_button_report_disabled() { + let point = AlacPoint::new(Line(0), Column(0)); + let mode = TermMode::empty(); + + let bytes = mouse_button_report(MouseButton::Left, true, point, 0, mode); + assert!(bytes.is_none()); + } + + #[test] + fn test_scroll_report_mouse_mode() { + let point = AlacPoint::new(Line(5), Column(10)); + let mode = TermMode::MOUSE_REPORT_CLICK; + + let bytes = scroll_report(3, point, 0, mode); + assert!(bytes.is_some()); + let sequence = String::from_utf8(bytes.unwrap()).unwrap(); + assert_eq!(sequence, "\x1b[<64;11;6M"); + + let bytes = scroll_report(-2, point, 0, mode); + assert!(bytes.is_some()); + let sequence = String::from_utf8(bytes.unwrap()).unwrap(); + assert_eq!(sequence, "\x1b[<65;11;6M"); + } + + #[test] + fn test_scroll_report_alternate_screen() { + let point = AlacPoint::new(Line(0), Column(0)); + let mode = TermMode::ALT_SCREEN; + + let bytes = scroll_report(3, point, 0, mode); + assert!(bytes.is_some()); + let sequence = bytes.unwrap(); + assert_eq!(sequence, b"\x1b[A\x1b[A\x1b[A"); + } + + #[test] + fn test_scroll_report_alternate_screen_app_cursor() { + let point = AlacPoint::new(Line(0), Column(0)); + let mode = TermMode::ALT_SCREEN | TermMode::APP_CURSOR; + + let bytes = scroll_report(-2, point, 0, mode); + assert!(bytes.is_some()); + let sequence = bytes.unwrap(); + assert_eq!(sequence, b"\x1bOB\x1bOB"); + } + + #[test] + fn test_scroll_report_normal_screen() { + let point = AlacPoint::new(Line(0), Column(0)); + let mode = TermMode::empty(); + + let bytes = scroll_report(3, point, 0, mode); + assert!(bytes.is_none()); + } + + #[test] + fn test_encode_modifiers() { + assert_eq!(encode_modifiers(false, false, false), 0); + assert_eq!(encode_modifiers(true, false, false), 4); + assert_eq!(encode_modifiers(false, true, false), 8); + assert_eq!(encode_modifiers(false, false, true), 16); + assert_eq!(encode_modifiers(true, true, false), 12); + assert_eq!(encode_modifiers(true, false, true), 20); + assert_eq!(encode_modifiers(false, true, true), 24); + assert_eq!(encode_modifiers(true, true, true), 28); + } + + #[test] + fn test_pixels_to_scroll_lines() { + let cell_height = px(20.0); + + assert_eq!(pixels_to_scroll_lines(px(60.0), cell_height), 3); + assert_eq!(pixels_to_scroll_lines(px(-40.0), cell_height), -2); + assert_eq!(pixels_to_scroll_lines(px(10.0), cell_height), 1); + assert_eq!(pixels_to_scroll_lines(px(-10.0), cell_height), -1); + + assert_eq!(pixels_to_scroll_lines(px(300.0), cell_height), 10); + assert_eq!(pixels_to_scroll_lines(px(-300.0), cell_height), -10); + } + + #[test] + fn test_scroll_to_arrow_keys_limit() { + let mode = TermMode::empty(); + + let bytes = scroll_to_arrow_keys(100, mode); + let expected = b"\x1b[A\x1b[A\x1b[A\x1b[A\x1b[A"; + assert_eq!(bytes, expected); + + let bytes = scroll_to_arrow_keys(-100, mode); + let expected = b"\x1b[B\x1b[B\x1b[B\x1b[B\x1b[B"; + assert_eq!(bytes, expected); + } +} diff --git a/third_party/gpui_xterm/src/render.rs b/third_party/gpui_xterm/src/render.rs new file mode 100644 index 000000000..06c241297 --- /dev/null +++ b/third_party/gpui_xterm/src/render.rs @@ -0,0 +1,753 @@ +use crate::colors::ColorPalette; +use crate::event::GpuiEventProxy; +use alacritty_terminal::grid::Dimensions; +use alacritty_terminal::index::{Column, Line, Point as AlacPoint}; +use alacritty_terminal::selection::SelectionRange; +use alacritty_terminal::term::Term; +use alacritty_terminal::term::cell::{Cell, Flags}; +use alacritty_terminal::term::color::Colors; +use alacritty_terminal::vte::ansi::Color; +use gpui::{ + App, Bounds, Edges, Font, FontFeatures, FontStyle, FontWeight, Hsla, Pixels, Point, + SharedString, Size, TextAlign, TextRun, UnderlineStyle, Window, px, quad, transparent_black, +}; +use std::ops::Range; + +#[derive(Debug, Clone)] +pub struct BatchedTextRun { + pub text: String, + + pub start_col: usize, + + pub row: usize, + + pub fg_color: Hsla, + + pub bg_color: Hsla, + + pub bold: bool, + + pub italic: bool, + + pub underline: bool, +} + +#[derive(Debug, Clone)] +pub struct BackgroundRect { + pub start_col: usize, + + pub end_col: usize, + + pub row: usize, + + pub color: Hsla, +} + +impl BackgroundRect { + fn can_merge_with(&self, other: &Self) -> bool { + self.row == other.row && self.color == other.color && self.end_col == other.start_col + } +} + +#[derive(Clone)] +pub struct TerminalRenderer { + pub font_family: String, + + pub font_size: Pixels, + + pub cell_width: Pixels, + + pub cell_height: Pixels, + + pub line_height_multiplier: f32, + + pub palette: ColorPalette, +} + +impl TerminalRenderer { + pub fn new( + font_family: String, + font_size: Pixels, + line_height_multiplier: f32, + palette: ColorPalette, + ) -> Self { + let cell_width = font_size * 0.6; + let cell_height = font_size * 1.4; + + Self { + font_family, + font_size, + cell_width, + cell_height, + line_height_multiplier, + palette, + } + } + + pub fn measure_cell(&mut self, window: &mut Window) { + // Lithe patch: 单元格宽度只按半角 ASCII 参考字测量。 + // + // 上游用 ["M","W","█","▀","▄"] 取最大值:GPUI 的字体回退走自己的 + // fontdb,块元素(█▀▄)在部分系统上会落到全角 CJK 字体,步进变成 1em, + // 于是整个网格的列宽被撑到约 1.6 倍,终端比常规终端明显偏宽。 + // 半角 ASCII 的步进(≈0.6em)才是正确的单元格宽度,块元素与全角字符 + // 按 1 格 / 2 格绘制。 + let font = Font { + family: self.font_family.clone().into(), + features: FontFeatures::default(), + fallbacks: None, + weight: FontWeight::NORMAL, + style: FontStyle::Normal, + }; + + let text_run = TextRun { + len: "m".len(), + font, + color: gpui::black(), + background_color: None, + underline: None, + strikethrough: None, + }; + + let shaped = window + .text_system() + .shape_line("m".into(), self.font_size, &[text_run], None); + + if shaped.width > px(0.0) { + self.cell_width = shaped.width; + } + + let line_height = shaped.ascent + shaped.descent; + if line_height > px(0.0) { + self.cell_height = self + .cell_height + .max(line_height * self.line_height_multiplier); + } + } + + pub fn layout_row( + &self, + row: usize, + cells: impl Iterator, + colors: &Colors, + ) -> (Vec, Vec) { + let mut backgrounds = Vec::new(); + let mut text_runs = Vec::new(); + + let mut current_run: Option = None; + let mut current_bg: Option = None; + + for (col, cell) in cells { + if cell.flags.contains(Flags::WIDE_CHAR_SPACER) { + continue; + } + + let fg_color = self.palette.resolve(cell.fg, colors); + let bg_color = self.palette.resolve(cell.bg, colors); + let bold = cell.flags.contains(Flags::BOLD); + let italic = cell.flags.contains(Flags::ITALIC); + let underline = cell.flags.contains(Flags::UNDERLINE); + + let ch = if cell.c == ' ' || cell.c == '\0' { + ' ' + } else { + cell.c + }; + + if let Some(ref mut bg_rect) = current_bg { + if bg_rect.color == bg_color && bg_rect.end_col == col { + bg_rect.end_col = col + 1; + } else { + backgrounds.push(bg_rect.clone()); + current_bg = Some(BackgroundRect { + start_col: col, + end_col: col + 1, + row, + color: bg_color, + }); + } + } else { + current_bg = Some(BackgroundRect { + start_col: col, + end_col: col + 1, + row, + color: bg_color, + }); + } + + if let Some(ref mut run) = current_run { + if run.fg_color == fg_color + && run.bg_color == bg_color + && run.bold == bold + && run.italic == italic + && run.underline == underline + { + run.text.push(ch); + } else { + text_runs.push(run.clone()); + current_run = Some(BatchedTextRun { + text: ch.to_string(), + start_col: col, + row, + fg_color, + bg_color, + bold, + italic, + underline, + }); + } + } else { + current_run = Some(BatchedTextRun { + text: ch.to_string(), + start_col: col, + row, + fg_color, + bg_color, + bold, + italic, + underline, + }); + } + } + + if let Some(run) = current_run { + text_runs.push(run); + } + if let Some(bg) = current_bg { + backgrounds.push(bg); + } + + let merged_backgrounds = self.merge_backgrounds(backgrounds); + + (merged_backgrounds, text_runs) + } + + fn merge_backgrounds(&self, mut rects: Vec) -> Vec { + if rects.is_empty() { + return rects; + } + + let mut merged = Vec::new(); + let mut current = rects.remove(0); + + for rect in rects { + if current.can_merge_with(&rect) { + current.end_col = rect.end_col; + } else { + merged.push(current); + current = rect; + } + } + + merged.push(current); + merged + } + + pub fn paint( + &self, + bounds: Bounds, + padding: Edges, + term: &Term, + window: &mut Window, + cx: &mut App, + ime_preedit: Option<(&str, Option<&Range>)>, + ) { + let grid = term.grid(); + let num_lines = grid.screen_lines(); + let num_cols = grid.columns(); + let colors = term.colors(); + + let default_bg = self.palette.resolve( + Color::Named(alacritty_terminal::vte::ansi::NamedColor::Background), + colors, + ); + + window.paint_quad(quad( + bounds, + px(0.0), + default_bg, + Edges::::default(), + transparent_black(), + Default::default(), + )); + + let origin = Point { + x: bounds.origin.x + padding.left, + y: bounds.origin.y + padding.top, + }; + + let display_offset = grid.display_offset() as i32; + let selection_range = term + .selection + .as_ref() + .and_then(|selection| selection.to_range(term)); + + for line_idx in 0..num_lines { + let line = Line(line_idx as i32 - display_offset); + + let cells: Vec<(usize, Cell)> = (0..num_cols) + .map(|col_idx| { + let col = Column(col_idx); + let point = AlacPoint::new(line, col); + let cell = grid[point].clone(); + (col_idx, cell) + }) + .collect(); + + let (backgrounds, _) = self.layout_row(line_idx, cells.iter().cloned(), colors); + + for bg_rect in backgrounds { + if bg_rect.color == default_bg { + continue; + } + + let x = origin.x + self.cell_width * (bg_rect.start_col as f32); + let y = origin.y + self.cell_height * (bg_rect.row as f32); + let width = self.cell_width * ((bg_rect.end_col - bg_rect.start_col) as f32); + let height = self.cell_height; + + let rect_bounds = Bounds { + origin: Point { x, y }, + size: Size { width, height }, + }; + + window.paint_quad(quad( + rect_bounds, + px(0.0), + bg_rect.color, + Edges::::default(), + transparent_black(), + Default::default(), + )); + } + + for (start_col, end_col) in selection_segments_for_row(&selection_range, line, num_cols) + { + let x = origin.x + self.cell_width * (start_col as f32); + let y = origin.y + self.cell_height * (line_idx as f32); + let width = self.cell_width * ((end_col - start_col) as f32); + let rect_bounds = Bounds { + origin: Point { x, y }, + size: Size { + width, + height: self.cell_height, + }, + }; + window.paint_quad(quad( + rect_bounds, + px(0.0), + selection_highlight(self.palette.cursor()), + Edges::::default(), + transparent_black(), + Default::default(), + )); + } + + let base_height = self.cell_height / self.line_height_multiplier; + let vertical_offset = (self.cell_height - base_height) / 2.0; + + for (col_idx, cell) in cells { + let ch = cell.c; + + if ch == ' ' || ch == '\0' { + continue; + } + + let x = origin.x + self.cell_width * (col_idx as f32); + let y = origin.y + self.cell_height * (line_idx as f32) + vertical_offset; + + let point = AlacPoint::new(line, Column(col_idx)); + let selected = selection_contains(&selection_range, point); + let mut fg_color = self.palette.resolve(cell.fg, colors); + if selected { + fg_color = self.palette.foreground(); + } + + let flags = cell.flags; + let bold = flags.contains(alacritty_terminal::term::cell::Flags::BOLD); + let italic = flags.contains(alacritty_terminal::term::cell::Flags::ITALIC); + let underline = flags.contains(alacritty_terminal::term::cell::Flags::UNDERLINE); + + let font = Font { + family: self.font_family.clone().into(), + features: FontFeatures::default(), + fallbacks: None, + weight: if bold { + FontWeight::BOLD + } else { + FontWeight::NORMAL + }, + style: if italic { + FontStyle::Italic + } else { + FontStyle::Normal + }, + }; + + let char_str = ch.to_string(); + let text_run = TextRun { + len: char_str.len(), + font, + color: fg_color, + background_color: None, + underline: if underline { + Some(UnderlineStyle { + thickness: px(1.0), + color: Some(fg_color), + wavy: false, + }) + } else { + None + }, + strikethrough: None, + }; + + let text: SharedString = char_str.into(); + let shaped_line = + window + .text_system() + .shape_line(text, self.font_size, &[text_run], None); + + let _ = shaped_line.paint( + Point { x, y }, + self.cell_height, + TextAlign::Left, + None, + window, + cx, + ); + } + } + + if let Some((ime_text, marked_range)) = ime_preedit.filter(|(text, _)| !text.is_empty()) { + self.paint_ime_preedit( + origin, + grid.cursor.point, + display_offset, + ime_text, + marked_range, + window, + cx, + ); + } + + let cursor_point = grid.cursor.point; + let visible_cursor_row = cursor_point.line.0 + display_offset; + if ime_preedit.is_none() && (0..num_lines as i32).contains(&visible_cursor_row) { + let cursor_x = origin.x + self.cell_width * (cursor_point.column.0 as f32); + let cursor_y = origin.y + self.cell_height * (visible_cursor_row as f32); + + let cursor_color = self.palette.resolve( + Color::Named(alacritty_terminal::vte::ansi::NamedColor::Cursor), + colors, + ); + + let cursor_bounds = Bounds { + origin: Point { + x: cursor_x, + y: cursor_y, + }, + size: Size { + width: self.cell_width, + height: self.cell_height, + }, + }; + + window.paint_quad(quad( + cursor_bounds, + px(0.0), + cursor_color, + Edges::::default(), + transparent_black(), + Default::default(), + )); + } + } + + fn paint_ime_preedit( + &self, + origin: Point, + cursor_point: AlacPoint, + display_offset: i32, + ime_text: &str, + marked_range: Option<&Range>, + window: &mut Window, + cx: &mut App, + ) { + let visible_row = cursor_point.line.0 + display_offset; + if visible_row < 0 { + return; + } + + let cursor_x = origin.x + self.cell_width * (cursor_point.column.0 as f32); + let cursor_y = origin.y + self.cell_height * (visible_row as f32); + let highlight = selection_highlight(self.palette.cursor()); + + for (index, ch) in ime_text.chars().enumerate() { + let x = cursor_x + self.cell_width * (index as f32); + let rect_bounds = Bounds { + origin: Point { x, y: cursor_y }, + size: Size { + width: self.cell_width, + height: self.cell_height, + }, + }; + + window.paint_quad(quad( + rect_bounds, + px(0.0), + highlight, + Edges::::default(), + transparent_black(), + Default::default(), + )); + + let underline = marked_range + .and_then(|range| utf16_range_to_char_range(ime_text, range)) + .is_some_and(|range| range.contains(&index)); + + let font = Font { + family: self.font_family.clone().into(), + features: FontFeatures::default(), + fallbacks: None, + weight: FontWeight::NORMAL, + style: FontStyle::Normal, + }; + + let text_run = TextRun { + len: ch.len_utf8(), + font, + color: self.palette.foreground(), + background_color: None, + underline: underline.then_some(UnderlineStyle { + thickness: px(1.0), + color: Some(self.palette.foreground()), + wavy: false, + }), + strikethrough: None, + }; + + let shaped_line = window.text_system().shape_line( + ch.to_string().into(), + self.font_size, + &[text_run], + None, + ); + let base_height = self.cell_height / self.line_height_multiplier; + let vertical_offset = (self.cell_height - base_height) / 2.0; + let _ = shaped_line.paint( + Point { + x, + y: cursor_y + vertical_offset, + }, + self.cell_height, + TextAlign::Left, + None, + window, + cx, + ); + } + } +} + +fn selection_contains(selection: &Option, point: AlacPoint) -> bool { + selection + .as_ref() + .is_some_and(|selection| selection.contains(point)) +} + +fn selection_segments_for_row( + selection: &Option, + row: Line, + num_cols: usize, +) -> Vec<(usize, usize)> { + let Some(selection) = selection else { + return Vec::new(); + }; + if row < selection.start.line || row > selection.end.line { + return Vec::new(); + } + + let last_col = num_cols.saturating_sub(1); + let start = if selection.is_block || row == selection.start.line { + selection.start.column.0.min(last_col) + } else { + 0 + }; + let end_inclusive = if selection.is_block || row == selection.end.line { + selection.end.column.0.min(last_col) + } else { + last_col + }; + + if start > end_inclusive { + return Vec::new(); + } + + vec![(start, end_inclusive + 1)] +} + +fn selection_highlight(cursor: Hsla) -> Hsla { + Hsla { a: 0.35, ..cursor } +} + +fn utf16_range_to_char_range(text: &str, range: &Range) -> Option> { + let mut utf16_offset = 0; + let mut start = None; + let mut end = None; + + for (char_idx, ch) in text.chars().enumerate() { + if utf16_offset == range.start { + start = Some(char_idx); + } + if utf16_offset == range.end { + end = Some(char_idx); + break; + } + utf16_offset += ch.len_utf16(); + } + + let char_count = text.chars().count(); + if utf16_offset == range.start { + start = Some(char_count); + } + if utf16_offset == range.end { + end = Some(char_count); + } + + match (start, end) { + (Some(start), Some(end)) if start <= end => Some(start..end), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_renderer_creation() { + let renderer = TerminalRenderer::new( + "Fira Code".to_string(), + px(14.0), + 1.2, + ColorPalette::default(), + ); + assert_eq!(renderer.font_family, "Fira Code"); + assert_eq!(renderer.font_size, px(14.0)); + assert_eq!(renderer.line_height_multiplier, 1.2); + } + + #[test] + fn test_background_rect_merge() { + let black = Hsla::black(); + + let rect1 = BackgroundRect { + start_col: 0, + end_col: 5, + row: 0, + color: black, + }; + + let rect2 = BackgroundRect { + start_col: 5, + end_col: 10, + row: 0, + color: black, + }; + + assert!(rect1.can_merge_with(&rect2)); + + let rect3 = BackgroundRect { + start_col: 5, + end_col: 10, + row: 1, + color: black, + }; + + assert!(!rect1.can_merge_with(&rect3)); + } + + #[test] + fn selection_segments_cover_spaces_contiguously() { + let selection = SelectionRange::new( + AlacPoint::new(Line(1), Column(2)), + AlacPoint::new(Line(1), Column(6)), + false, + ); + assert_eq!( + selection_segments_for_row(&Some(selection), Line(1), 10), + vec![(2, 7)] + ); + } + + #[test] + fn test_merge_backgrounds() { + let renderer = TerminalRenderer::new( + "monospace".to_string(), + px(14.0), + 1.2, + ColorPalette::default(), + ); + let black = Hsla::black(); + + let rects = vec![ + BackgroundRect { + start_col: 0, + end_col: 5, + row: 0, + color: black, + }, + BackgroundRect { + start_col: 5, + end_col: 10, + row: 0, + color: black, + }, + ]; + + let merged = renderer.merge_backgrounds(rects); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].start_col, 0); + assert_eq!(merged[0].end_col, 10); + } + + #[test] + fn layout_row_keeps_same_style_text_in_one_run() { + use alacritty_terminal::term::cell::Cell; + use alacritty_terminal::vte::ansi::NamedColor; + + let renderer = TerminalRenderer::new( + "monospace".to_string(), + px(14.0), + 1.0, + ColorPalette::default(), + ); + + let mut first = Cell::default(); + first.c = 'S'; + first.fg = Color::Named(NamedColor::Foreground); + + let mut second = Cell::default(); + second.c = 'I'; + second.fg = Color::Named(NamedColor::Foreground); + + let mut third = Cell::default(); + third.c = 'O'; + third.fg = Color::Named(NamedColor::Foreground); + + let mut fourth = Cell::default(); + fourth.c = 'S'; + fourth.fg = Color::Named(NamedColor::Foreground); + + let (_backgrounds, runs) = renderer.layout_row( + 0, + vec![(0, first), (1, second), (2, third), (3, fourth)].into_iter(), + &Colors::default(), + ); + + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].start_col, 0); + assert_eq!(runs[0].text, "SIOS"); + } +} diff --git a/third_party/gpui_xterm/src/terminal.rs b/third_party/gpui_xterm/src/terminal.rs new file mode 100644 index 000000000..892b18500 --- /dev/null +++ b/third_party/gpui_xterm/src/terminal.rs @@ -0,0 +1,303 @@ +use crate::event::GpuiEventProxy; +use alacritty_terminal::grid::{Dimensions, Scroll}; +use alacritty_terminal::selection::Selection; +use alacritty_terminal::term::{Config, Term, TermMode}; +use alacritty_terminal::vte::ansi::Processor; +use parking_lot::Mutex; +use std::sync::Arc; + +struct TermDimensions { + columns: usize, + screen_lines: usize, +} + +impl TermDimensions { + fn new(columns: usize, screen_lines: usize) -> Self { + Self { + columns, + screen_lines, + } + } +} + +impl Dimensions for TermDimensions { + fn total_lines(&self) -> usize { + self.screen_lines + } + + fn screen_lines(&self) -> usize { + self.screen_lines + } + + fn columns(&self) -> usize { + self.columns + } + + fn last_column(&self) -> alacritty_terminal::index::Column { + alacritty_terminal::index::Column(self.columns.saturating_sub(1)) + } +} + +pub struct TerminalState { + term: Arc>>, + + parser: Processor, + + cols: usize, + + rows: usize, +} + +impl TerminalState { + pub fn new(cols: usize, rows: usize, event_proxy: GpuiEventProxy) -> Self { + Self::with_scrollback(cols, rows, Config::default().scrolling_history, event_proxy) + } + + /// Lithe patch: the upstream constructor ignored `TerminalConfig::scrollback` + /// because it always built `Config::default()`. This constructor lets the view + /// honor the configured scrollback size. + pub fn with_scrollback( + cols: usize, + rows: usize, + scrolling_history: usize, + event_proxy: GpuiEventProxy, + ) -> Self { + let config = Config { + scrolling_history, + ..Config::default() + }; + + let dimensions = TermDimensions::new(cols, rows); + + let term = Term::new(config, &dimensions, event_proxy); + + let parser = Processor::new(); + + Self { + term: Arc::new(Mutex::new(term)), + parser, + cols, + rows, + } + } + + pub fn process_bytes(&mut self, bytes: &[u8]) { + let mut term = self.term.lock(); + self.parser.advance(&mut *term, bytes); + } + + pub fn resize(&mut self, cols: usize, rows: usize) { + self.cols = cols; + self.rows = rows; + + let mut term = self.term.lock(); + + let dimensions = TermDimensions::new(cols, rows); + + term.resize(dimensions); + } + + pub fn mode(&self) -> TermMode { + let term = self.term.lock(); + *term.mode() + } + + pub fn with_term(&self, f: F) -> R + where + F: FnOnce(&Term) -> R, + { + let term = self.term.lock(); + f(&term) + } + + pub fn with_term_mut(&self, f: F) -> R + where + F: FnOnce(&mut Term) -> R, + { + let mut term = self.term.lock(); + f(&mut term) + } + + pub fn cols(&self) -> usize { + self.cols + } + + pub fn rows(&self) -> usize { + self.rows + } + + pub fn scroll_display(&self, scroll: Scroll) { + let mut term = self.term.lock(); + term.scroll_display(scroll); + } + + pub fn scroll_to_bottom(&self) { + self.scroll_display(Scroll::Bottom); + } + + pub fn clear_selection(&self) { + let mut term = self.term.lock(); + term.selection = None; + } + + pub fn update_selection(&self, selection: Option) { + let mut term = self.term.lock(); + term.selection = selection; + } + + pub fn selection_text(&self) -> Option { + let term = self.term.lock(); + term.selection_to_string() + } + + pub fn clear_screen(&mut self) { + self.process_bytes(b""); + let mut term = self.term.lock(); + term.selection = None; + } + + pub fn display_offset(&self) -> usize { + let term = self.term.lock(); + term.grid().display_offset() + } + + pub fn term_arc(&self) -> Arc>> { + Arc::clone(&self.term) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc::channel; + + #[test] + fn test_terminal_creation() { + let (tx, _rx) = channel(); + let event_proxy = GpuiEventProxy::new(tx); + let terminal = TerminalState::new(80, 24, event_proxy); + + assert_eq!(terminal.cols(), 80); + assert_eq!(terminal.rows(), 24); + } + + #[test] + fn test_process_bytes() { + let (tx, _rx) = channel(); + let event_proxy = GpuiEventProxy::new(tx); + let mut terminal = TerminalState::new(80, 24, event_proxy); + + terminal.process_bytes(b"Hello, world!"); + + terminal.with_term(|term| { + let grid = term.grid(); + assert!(grid.columns() == 80); + }); + } + + #[test] + fn test_clear_screen_resets_cursor_and_display_offset() { + let (tx, _rx) = channel(); + let event_proxy = GpuiEventProxy::new(tx); + let mut terminal = TerminalState::new(10, 3, event_proxy); + + terminal.process_bytes( + b"line1 +line2 +line3 +line4 +", + ); + terminal.scroll_display(Scroll::Top); + terminal.clear_screen(); + + terminal.with_term(|term| { + assert_eq!(term.grid().display_offset(), 0); + assert_eq!(term.grid().cursor.point.line.0, 0); + assert_eq!(term.grid().cursor.point.column.0, 0); + }); + } + + #[test] + fn test_scroll_to_bottom_resets_display_offset() { + let (tx, _rx) = channel(); + let event_proxy = GpuiEventProxy::new(tx); + let mut terminal = TerminalState::new(10, 3, event_proxy); + + terminal.process_bytes( + b"line1 +line2 +line3 +line4 +", + ); + terminal.scroll_display(Scroll::Top); + + assert!(terminal.display_offset() > 0); + + terminal.scroll_to_bottom(); + + assert_eq!(terminal.display_offset(), 0); + } + + #[test] + fn test_resize() { + let (tx, _rx) = channel(); + let event_proxy = GpuiEventProxy::new(tx); + let mut terminal = TerminalState::new(80, 24, event_proxy); + + terminal.resize(120, 30); + + assert_eq!(terminal.cols(), 120); + assert_eq!(terminal.rows(), 30); + + terminal.with_term(|term| { + let grid = term.grid(); + assert_eq!(grid.columns(), 120); + assert_eq!(grid.screen_lines(), 30); + }); + } + + #[test] + fn test_mode() { + let (tx, _rx) = channel(); + let event_proxy = GpuiEventProxy::new(tx); + let terminal = TerminalState::new(80, 24, event_proxy); + + let mode = terminal.mode(); + let _bits = mode.bits(); + } + + #[test] + fn test_with_term() { + let (tx, _rx) = channel(); + let event_proxy = GpuiEventProxy::new(tx); + let terminal = TerminalState::new(80, 24, event_proxy); + + let cols = terminal.with_term(|term| term.grid().columns()); + assert_eq!(cols, 80); + } + + #[test] + fn test_with_term_mut() { + let (tx, _rx) = channel(); + let event_proxy = GpuiEventProxy::new(tx); + let terminal = TerminalState::new(80, 24, event_proxy); + + terminal.with_term_mut(|term| { + let _grid = term.grid_mut(); + }); + } + + #[test] + fn test_term_arc() { + let (tx, _rx) = channel(); + let event_proxy = GpuiEventProxy::new(tx); + let terminal = TerminalState::new(80, 24, event_proxy); + + let arc1 = terminal.term_arc(); + let arc2 = terminal.term_arc(); + + assert!(Arc::ptr_eq(&arc1, &arc2)); + } +} diff --git a/third_party/gpui_xterm/src/view.rs b/third_party/gpui_xterm/src/view.rs new file mode 100644 index 000000000..2bc9b0df3 --- /dev/null +++ b/third_party/gpui_xterm/src/view.rs @@ -0,0 +1,842 @@ +use crate::colors::ColorPalette; +use crate::event::{GpuiEventProxy, TerminalEvent}; +use crate::input::keystroke_to_bytes; +use crate::mouse::pixel_to_cell; +use crate::render::TerminalRenderer; +use crate::terminal::TerminalState; +use alacritty_terminal::grid::Scroll; +use alacritty_terminal::index::Side; +use alacritty_terminal::selection::{Selection, SelectionType}; +use gpui::{Edges, ElementInputHandler, EntityInputHandler, UTF16Selection, *}; +use gpui_component::menu::{ContextMenuExt, PopupMenu, PopupMenuItem}; +use std::io::{Cursor, Read, Write}; +use std::sync::Arc; +use std::sync::mpsc; +use std::thread; + +const CONTEXT: &str = "GpuiXterm"; + +actions!(gpui_xterm, [SendTab, SendBacktab]); + +#[derive(Clone, Debug)] +pub struct TerminalConfig { + pub cols: usize, + + pub rows: usize, + + pub font_family: String, + + pub font_size: Pixels, + + pub scrollback: usize, + + pub line_height_multiplier: f32, + + pub padding: Edges, + + pub colors: ColorPalette, +} + +impl Default for TerminalConfig { + fn default() -> Self { + Self { + cols: 80, + rows: 24, + font_family: "monospace".into(), + font_size: px(14.0), + scrollback: 10000, + line_height_multiplier: 1.0, + padding: Edges::all(px(0.0)), + colors: ColorPalette::default(), + } + } +} + +pub type ResizeCallback = Box; + +pub type KeyHandler = Box bool + Send + Sync>; + +pub type BellCallback = Box)>; + +pub type TitleCallback = Box, &str)>; + +pub type ClipboardStoreCallback = Box, &str)>; + +pub type ExitCallback = Box)>; + +#[derive(Clone, Copy, Debug, Default)] +struct LayoutMetrics { + origin: Point, + cell_width: Pixels, + cell_height: Pixels, + cols: usize, +} + +pub struct TerminalView { + state: TerminalState, + + renderer: TerminalRenderer, + + focus_handle: FocusHandle, + + stdin_writer: Arc>>, + + event_rx: mpsc::Receiver, + + config: TerminalConfig, + + #[allow(dead_code)] + _reader_task: Task<()>, + + resize_callback: Option>, + + key_handler: Option>, + + bell_callback: Option, + + title_callback: Option, + + clipboard_store_callback: Option, + + exit_callback: Option, + + layout_metrics: Arc>, + + dragging_selection: bool, + + ime_text: String, + ime_marked_range: Option>, +} + +fn should_defer_text_input_to_ime(event: &KeyDownEvent) -> bool { + event.keystroke.key_char.is_some() + && !event.keystroke.modifiers.control + && !event.keystroke.modifiers.alt + && !event.keystroke.modifiers.platform + && !event.keystroke.modifiers.function +} + +impl TerminalView { + pub fn new( + stdin_writer: W, + stdout_reader: R, + config: TerminalConfig, + cx: &mut Context, + ) -> Self + where + W: Write + Send + 'static, + R: Read + Send + 'static, + { + cx.bind_keys([ + KeyBinding::new("tab", SendTab, Some(CONTEXT)), + KeyBinding::new("shift-tab", SendBacktab, Some(CONTEXT)), + ]); + + let (event_tx, event_rx) = mpsc::channel(); + + let exit_event_tx = event_tx.clone(); + + let event_proxy = GpuiEventProxy::new(event_tx); + + let state = TerminalState::with_scrollback( + config.cols, + config.rows, + config.scrollback, + event_proxy, + ); + + let renderer = TerminalRenderer::new( + config.font_family.clone(), + config.font_size, + config.line_height_multiplier, + config.colors.clone(), + ); + + let focus_handle = cx.focus_handle().tab_stop(false); + + let stdin_writer = Arc::new(parking_lot::Mutex::new( + Box::new(stdin_writer) as Box + )); + + let (bytes_tx, bytes_rx) = flume::unbounded::>(); + + thread::spawn(move || { + Self::read_stdout_blocking(stdout_reader, bytes_tx); + }); + + let reader_task = cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + loop { + match bytes_rx.recv_async().await { + Ok(bytes) => { + let result = this.update(cx, |view: &mut Self, cx: &mut Context| { + view.state.process_bytes(&bytes); + cx.notify(); + }); + if result.is_err() { + break; + } + } + Err(_) => { + let _ = exit_event_tx.send(TerminalEvent::Exit); + let _ = this.update(cx, |_view, cx: &mut Context| { + cx.notify(); + }); + break; + } + } + } + }); + + Self { + state, + renderer, + focus_handle, + stdin_writer, + event_rx, + config, + _reader_task: reader_task, + resize_callback: None, + key_handler: None, + bell_callback: None, + title_callback: None, + clipboard_store_callback: None, + exit_callback: None, + layout_metrics: Arc::new(parking_lot::Mutex::new(LayoutMetrics::default())), + dragging_selection: false, + ime_text: String::new(), + ime_marked_range: None, + } + } + + pub fn new_virtual(config: TerminalConfig, cx: &mut Context) -> Self { + Self::new(std::io::sink(), Cursor::new(Vec::::new()), config, cx) + } + + pub fn with_resize_callback( + mut self, + callback: impl Fn(usize, usize) + Send + Sync + 'static, + ) -> Self { + self.resize_callback = Some(Arc::new(Box::new(callback))); + self + } + + pub fn with_key_handler( + mut self, + handler: impl Fn(&KeyDownEvent) -> bool + Send + Sync + 'static, + ) -> Self { + self.key_handler = Some(Arc::new(Box::new(handler))); + self + } + + pub fn with_bell_callback( + mut self, + callback: impl Fn(&mut Window, &mut Context) + 'static, + ) -> Self { + self.bell_callback = Some(Box::new(callback)); + self + } + + pub fn with_title_callback( + mut self, + callback: impl Fn(&mut Window, &mut Context, &str) + 'static, + ) -> Self { + self.title_callback = Some(Box::new(callback)); + self + } + + pub fn with_clipboard_store_callback( + mut self, + callback: impl Fn(&mut Window, &mut Context, &str) + 'static, + ) -> Self { + self.clipboard_store_callback = Some(Box::new(callback)); + self + } + + pub fn with_exit_callback( + mut self, + callback: impl Fn(&mut Window, &mut Context) + 'static, + ) -> Self { + self.exit_callback = Some(Box::new(callback)); + self + } + + fn read_stdout_blocking( + mut stdout_reader: R, + bytes_tx: flume::Sender>, + ) { + let mut buffer = [0u8; 4096]; + + loop { + match stdout_reader.read(&mut buffer) { + Ok(0) => { + break; + } + Ok(n) => { + let bytes = buffer[..n].to_vec(); + if bytes_tx.send(bytes).is_err() { + break; + } + } + Err(_) => { + break; + } + } + } + } + + fn on_action_send_tab(&mut self, _: &SendTab, _window: &mut Window, _cx: &mut Context) { + if self.write_input_bytes(b"\t") { + _cx.notify(); + } + } + + fn on_action_send_backtab( + &mut self, + _: &SendBacktab, + _window: &mut Window, + _cx: &mut Context, + ) { + if self.write_input_bytes(b"\x1b[Z") { + _cx.notify(); + } + } + + fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, _cx: &mut Context) { + if let Some(ref handler) = self.key_handler + && handler(event) + { + return; + } + + let key = event.keystroke.key.to_lowercase(); + if event.keystroke.modifiers.control && key == "c" && self.copy_selection() { + return; + } + + if event.keystroke.modifiers.control && key == "v" && self.paste_from_clipboard() { + _cx.notify(); + return; + } + + if should_defer_text_input_to_ime(event) { + return; + } + + if key == "tab" { + let wrote = self.write_input_bytes(if event.keystroke.modifiers.shift { + b"\x1b[Z" + } else { + b"\t" + }); + if wrote { + _cx.notify(); + } + return; + } + + if let Some(bytes) = keystroke_to_bytes(&event.keystroke, self.state.mode()) { + if self.write_input_bytes(&bytes) { + _cx.notify(); + } + } + } + + fn on_mouse_down( + &mut self, + event: &MouseDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + window.focus(&self.focus_handle, cx); + let selection_type = match event.click_count { + 2 => SelectionType::Semantic, + 3.. => SelectionType::Lines, + _ => SelectionType::Simple, + }; + if let Some(point) = self.hit_test_cell(event.position) { + self.state + .update_selection(Some(Selection::new(selection_type, point, Side::Left))); + self.dragging_selection = true; + cx.notify(); + } + } + + fn on_mouse_up(&mut self, event: &MouseUpEvent, _window: &mut Window, cx: &mut Context) { + if let Some(point) = self.hit_test_cell(event.position) { + self.update_selection_endpoint(point); + } + self.dragging_selection = false; + let _ = self.copy_selection(); + cx.notify(); + } + + fn on_mouse_move( + &mut self, + event: &MouseMoveEvent, + _window: &mut Window, + cx: &mut Context, + ) { + if !self.dragging_selection || !event.dragging() { + return; + } + if let Some(point) = self.hit_test_cell(event.position) { + self.update_selection_endpoint(point); + cx.notify(); + } + } + + fn on_scroll( + &mut self, + event: &ScrollWheelEvent, + _window: &mut Window, + cx: &mut Context, + ) { + let delta = match event.delta { + ScrollDelta::Lines(delta) => delta.y.round() as i32, + ScrollDelta::Pixels(delta) => { + let line_height: f32 = self.renderer.cell_height.into(); + if line_height <= 0.0 { + 0 + } else { + (f32::from(delta.y) / line_height).round() as i32 + } + } + }; + if delta != 0 { + self.state.scroll_display(Scroll::Delta(delta)); + cx.notify(); + } + } + + fn hit_test_cell(&self, position: Point) -> Option { + let metrics = *self.layout_metrics.lock(); + if metrics.cell_width <= px(0.0) || metrics.cell_height <= px(0.0) { + return None; + } + if position.x < metrics.origin.x || position.y < metrics.origin.y { + return None; + } + let mut point = pixel_to_cell( + position, + metrics.origin, + metrics.cell_width, + metrics.cell_height, + ); + if point.column.0 >= metrics.cols { + point.column = alacritty_terminal::index::Column(metrics.cols.saturating_sub(1)); + } + let offset = self.state.display_offset() as i32; + point.line = alacritty_terminal::index::Line(point.line.0 - offset); + Some(point) + } + + fn update_selection_endpoint(&mut self, point: alacritty_terminal::index::Point) { + self.state.with_term_mut(|term| { + if let Some(selection) = term.selection.as_mut() { + selection.update(point, Side::Right); + } + }); + } + + fn process_events(&mut self, window: &mut Window, cx: &mut Context) { + while let Ok(event) = self.event_rx.try_recv() { + match event { + TerminalEvent::Wakeup => {} + TerminalEvent::Bell => { + if let Some(ref callback) = self.bell_callback { + callback(window, cx); + } + } + TerminalEvent::Title(title) => { + if let Some(ref callback) = self.title_callback { + callback(window, cx, &title); + } + } + TerminalEvent::ClipboardStore(text) => { + if let Some(ref callback) = self.clipboard_store_callback { + callback(window, cx, &text); + } + } + TerminalEvent::ClipboardLoad => {} + TerminalEvent::Exit => { + if let Some(ref callback) = self.exit_callback { + callback(window, cx); + } + } + } + } + } + + pub fn dimensions(&self) -> (usize, usize) { + (self.state.cols(), self.state.rows()) + } + + /// Lithe patch: expose the underlying engine state so the host product can + /// implement capability the component does not own, such as in-terminal + /// search, scroll-to-bottom and reading the current display offset. The + /// component keeps ownership of rendering and input. + pub fn state(&self) -> &TerminalState { + &self.state + } + + pub fn resize(&mut self, cols: usize, rows: usize) { + self.state.resize(cols, rows); + } + + pub fn config(&self) -> &TerminalConfig { + &self.config + } + + pub fn focus_handle(&self) -> &FocusHandle { + &self.focus_handle + } + + pub fn update_config(&mut self, config: TerminalConfig, cx: &mut Context) { + self.renderer.font_family = config.font_family.clone(); + self.renderer.font_size = config.font_size; + self.renderer.line_height_multiplier = config.line_height_multiplier; + self.renderer.palette = config.colors.clone(); + + self.config = config; + + cx.notify(); + } + + pub fn clear(&mut self, cx: &mut Context) { + self.state.clear_screen(); + self.ime_text.clear(); + self.ime_marked_range = None; + cx.notify(); + } + + pub fn has_selection(&self) -> bool { + self.state + .selection_text() + .is_some_and(|text| !text.is_empty()) + } + + pub fn copy_selection(&self) -> bool { + if let Some(text) = self.state.selection_text() + && !text.is_empty() + && let Ok(mut clipboard) = arboard::Clipboard::new() + { + let _ = clipboard.set_text(text); + return true; + } + false + } + + pub fn paste_from_clipboard(&self) -> bool { + if let Ok(mut clipboard) = arboard::Clipboard::new() + && let Ok(text) = clipboard.get_text() + { + return self.write_input_bytes(text.as_bytes()); + } + false + } + + fn write_input_bytes(&self, bytes: &[u8]) -> bool { + let mut writer = self.stdin_writer.lock(); + if writer.write_all(bytes).is_err() { + return false; + } + if writer.flush().is_err() { + return false; + } + drop(writer); + + self.state.scroll_to_bottom(); + true + } + + #[allow(dead_code)] + fn calculate_dimensions(&self, bounds: Bounds) -> (usize, usize) { + let width_f32: f32 = bounds.size.width.into(); + let height_f32: f32 = bounds.size.height.into(); + let cell_width_f32: f32 = self.renderer.cell_width.into(); + let cell_height_f32: f32 = self.renderer.cell_height.into(); + + let cols = ((width_f32 / cell_width_f32) as usize).max(1); + let rows = ((height_f32 / cell_height_f32) as usize).max(1); + (cols, rows) + } + + fn build_context_menu( + view: WeakEntity, + has_selection: bool, + menu: PopupMenu, + _window: &mut Window, + _cx: &mut Context, + ) -> PopupMenu { + menu.item( + PopupMenuItem::new("Copy") + .disabled(!has_selection) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + let _ = view.update(cx, |this, _| { + let _ = this.copy_selection(); + }); + } + }), + ) + .item(PopupMenuItem::new("Paste").on_click({ + let view = view.clone(); + move |_, _, cx| { + let _ = view.update(cx, |this, cx| { + let _ = this.paste_from_clipboard(); + cx.notify(); + }); + } + })) + .item(PopupMenuItem::separator()) + .item(PopupMenuItem::new("Clear").on_click({ + move |_, _, cx| { + let _ = view.update(cx, |this, cx| { + this.clear(cx); + }); + } + })) + } +} + +impl EntityInputHandler for TerminalView { + fn text_for_range( + &mut self, + _range_utf16: std::ops::Range, + adjusted_range: &mut Option>, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + let range = self + .ime_marked_range + .clone() + .unwrap_or(0..self.ime_text.encode_utf16().count()); + *adjusted_range = Some(range); + Some(self.ime_text.clone()) + } + + fn selected_text_range( + &mut self, + _ignore_disabled_input: bool, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + Some(UTF16Selection { + range: 0..0, + reversed: false, + }) + } + + fn marked_text_range( + &self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + self.ime_marked_range.clone() + } + + fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) { + self.ime_marked_range = None; + self.ime_text.clear(); + _cx.notify(); + } + + fn replace_text_in_range( + &mut self, + _range: Option>, + text: &str, + _window: &mut Window, + cx: &mut Context, + ) { + self.ime_marked_range = None; + self.ime_text.clear(); + if !text.is_empty() { + let _ = self.write_input_bytes(text.as_bytes()); + } + cx.notify(); + } + + fn replace_and_mark_text_in_range( + &mut self, + _range: Option>, + new_text: &str, + new_selected_range: Option>, + _window: &mut Window, + cx: &mut Context, + ) { + self.ime_text = new_text.to_string(); + self.ime_marked_range = if new_text.is_empty() { + None + } else { + new_selected_range.or_else(|| Some(0..new_text.encode_utf16().count())) + }; + cx.notify(); + } + + fn bounds_for_range( + &mut self, + _range_utf16: std::ops::Range, + _element_bounds: Bounds, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + let metrics = *self.layout_metrics.lock(); + self.state.with_term(|term| { + let cursor = term.grid().cursor.point; + let offset = term.grid().display_offset() as i32; + let visible_row = cursor.line.0 + offset; + Some(Bounds { + origin: Point { + x: metrics.origin.x + metrics.cell_width * (cursor.column.0 as f32), + y: metrics.origin.y + metrics.cell_height * (visible_row as f32), + }, + size: Size { + width: metrics.cell_width.max(px(1.0)), + height: metrics.cell_height.max(px(1.0)), + }, + }) + }) + } + + fn character_index_for_point( + &mut self, + _point: Point, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + Some(0) + } +} + +impl Render for TerminalView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.process_events(window, cx); + + let state_arc = self.state.term_arc(); + let renderer = self.renderer.clone(); + let resize_callback = self.resize_callback.clone(); + let padding = self.config.padding; + let layout_metrics = self.layout_metrics.clone(); + let focus_handle = self.focus_handle.clone(); + let entity = cx.entity(); + let ime_text = self.ime_text.clone(); + let ime_marked_range = self.ime_marked_range.clone(); + + div() + .size_full() + .bg(self.config.colors.background()) + .track_focus(&self.focus_handle) + .key_context(CONTEXT) + .on_action(cx.listener(Self::on_action_send_tab)) + .on_action(cx.listener(Self::on_action_send_backtab)) + .on_key_down(cx.listener(Self::on_key_down)) + .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down)) + .on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up)) + .on_mouse_move(cx.listener(Self::on_mouse_move)) + .on_scroll_wheel(cx.listener(Self::on_scroll)) + .context_menu({ + let view = cx.entity().downgrade(); + let has_selection = self.has_selection(); + move |menu, window, cx| { + Self::build_context_menu(view.clone(), has_selection, menu, window, cx) + } + }) + .child( + canvas( + move |bounds, _window, _cx| bounds, + move |bounds, _, window, cx| { + use alacritty_terminal::grid::Dimensions; + + window.handle_input( + &focus_handle, + ElementInputHandler::new(bounds, entity.clone()), + cx, + ); + + let mut measured_renderer = renderer.clone(); + measured_renderer.measure_cell(window); + + let available_width: f32 = + (bounds.size.width - padding.left - padding.right).into(); + let available_height: f32 = + (bounds.size.height - padding.top - padding.bottom).into(); + let cell_width_f32: f32 = measured_renderer.cell_width.into(); + let cell_height_f32: f32 = measured_renderer.cell_height.into(); + + let cols = ((available_width / cell_width_f32) as usize).max(1); + let rows = ((available_height / cell_height_f32) as usize).max(1); + + struct TermSize { + cols: usize, + rows: usize, + } + impl Dimensions for TermSize { + fn total_lines(&self) -> usize { + self.rows + } + fn screen_lines(&self) -> usize { + self.rows + } + fn columns(&self) -> usize { + self.cols + } + fn last_column(&self) -> alacritty_terminal::index::Column { + alacritty_terminal::index::Column(self.cols.saturating_sub(1)) + } + fn bottommost_line(&self) -> alacritty_terminal::index::Line { + alacritty_terminal::index::Line(self.rows as i32 - 1) + } + fn topmost_line(&self) -> alacritty_terminal::index::Line { + alacritty_terminal::index::Line(0) + } + } + + let mut term = state_arc.lock(); + let current_cols = term.columns(); + let current_rows = term.screen_lines(); + if cols != current_cols || rows != current_rows { + if let Some(ref callback) = resize_callback { + callback(cols, rows); + } + term.resize(TermSize { cols, rows }); + } + + let origin = Point { + x: bounds.origin.x + padding.left, + y: bounds.origin.y + padding.top, + }; + *layout_metrics.lock() = LayoutMetrics { + origin, + cell_width: measured_renderer.cell_width, + cell_height: measured_renderer.cell_height, + cols, + }; + + measured_renderer.paint( + bounds, + padding, + &term, + window, + cx, + (!ime_text.is_empty()) + .then_some((ime_text.as_str(), ime_marked_range.as_ref())), + ); + }, + ) + .size_full(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::TerminalConfig; + + #[::core::prelude::v1::test] + fn test_terminal_config_default_uses_compact_line_height() { + let config = TerminalConfig::default(); + + assert_eq!(config.line_height_multiplier, 1.0); + } +} From 363ff1d372cff02df2f0ed630b5dfada8da698a3 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Sat, 26 Sep 2026 01:59:36 +0800 Subject: [PATCH 080/109] =?UTF-8?q?feat(linux):=20Run/Maven=20=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E6=94=B9=E7=94=A8=20PTY=20+=20=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E6=8E=A7=E5=88=B6=E5=8F=B0=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 受管步骤改用 portable-pty 启动:子进程看到真正的 TTY,Maven/JDK 的彩色输出与 \r 进度条恢复;退出码取 ExitStatus::exit_code(),进程组停止契约不变 - ProcessEvent::Output 改为携带原始字节(去掉 OutputStream/text 字段); stdout/stderr 由 PTY 合并为一路 - 新增 workbench/console.rs 的 OutputConsole:只读 gpui_xterm 终端视图 + mpsc 字节通道, 提供 write_bytes / write_heading / write_muted / write_error / clear / scroll_to_bottom,并上报实测行列数作为子进程 PTY 尺寸 - bottom_panel:run_output/maven_output 由 Vec 换成 OutputConsole,渲染直接挂载 终端视图;跟随末尾改为滚动控制台;删除 MAX_RUN_OUTPUT、800 行截断与 render_output_line/preserve_leading_whitespace 等重复文本渲染 - 配色复用 terminal_color_palette(),字体复用 fonts::mono_family(cx),不新增第二份调色板 - run/process.rs 测试适配 PTY(补 PtySize 参数、按字节解析输出) - 笔记:新增 2026-09-26-linux-run-console-pty-terminal 验证:bash scripts/build-linux.sh 通过;cargo test -p lithe-linux --lib 98 passed; node scripts/verify-agent-notes.mjs 通过 --- ...26-09-26-linux-run-console-pty-terminal.md | 129 +++++++++ linux/src/workbench/bottom_panel.rs | 266 ++++++------------ linux/src/workbench/console.rs | 188 +++++++++++++ linux/src/workbench/mod.rs | 1 + linux/src/workbench/run/mod.rs | 2 +- linux/src/workbench/run/process.rs | 190 +++++++------ linux/src/workbench/terminal.rs | 4 +- 7 files changed, 507 insertions(+), 273 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-26-linux-run-console-pty-terminal.md create mode 100644 linux/src/workbench/console.rs diff --git a/.agents/notes/implemented/feature/2026-09-26-linux-run-console-pty-terminal.md b/.agents/notes/implemented/feature/2026-09-26-linux-run-console-pty-terminal.md new file mode 100644 index 000000000..ccb11e73a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-26-linux-run-console-pty-terminal.md @@ -0,0 +1,129 @@ +# Agent 笔记:Run/Maven 输出改用 PTY + 终端组件控制台 + +状态:已实现 + +## 先说结论 + +Run 与 Maven 面板的输出不再逐行拼纯文本,而是**让受管进程跑在 PTY 上**,把 PTY 的 +原始字节交给 `gpui_xterm` 终端组件渲染。开发者要记住三件事:**受管步骤必须经 +`portable-pty` 启动、输出必须是原始字节**(否则 Maven/JDK 不会输出颜色); +**宿主自己的提示(命令回显、错误、状态)统一走 +`OutputConsole::write_heading/write_muted/write_error`,不要直接拼 ANSI;** +**不要退回“读管道按行存 `Vec` 再画 div”的老做法**。 + +## 问题 + +Run/Maven 面板此前用 `std::process` + 管道启动受管进程,`BufReader::lines()` 逐行 +读取,存进 `run_output: Vec` / `maven_output: Vec`,再用纯文本 +`div` 渲染(`render_output_line`,无任何 ANSI 解析)。带来三个问题: + +1. **子进程不是 TTY**,Maven/JDK/Spring 等会主动关闭彩色输出,进度条 `\r` 也被 + 当成普通字符;面板永远是单色纯文本。 +2. 逐行渲染没有终端排版:宽字符/换行对齐、`\r` 覆盖、光标控制全部丢失。 +3. 面板自行截断输出(`MAX_RUN_OUTPUT`/最后 800 行),与终端组件已有的回滚缓冲 + 重复且行为不同。 + +## 决策 + +### 受管进程跑在 PTY 上 + +`workbench/run/process.rs` 的每个步骤改用 `portable-pty` 启动: +`native_pty_system().openpty(size)` → `slave.spawn_command(cmd)`。因此: + +- 子进程看到真正的 TTY,彩色输出、`\r` 进度条、宽度感知都恢复; +- stdout/stderr 由 PTY 合并为一路,`ProcessEvent::Output` 直接携带**原始字节** + (不再有 `OutputStream`/`text` 字段); +- `portable-pty` 让子进程 `setsid`,子进程 pid 即进程组 id,停止仍复用既有的 + “先温和后强制、有界等待”契约(`send_signal(-pgid)` + `wait_for_child`); +- 退出码取 `ExitStatus::exit_code()`;被信号终止时给出显式错误。 + +PTY 行列数由宿主的输出控制台实测尺寸决定(见下),保证子进程按真实宽度排版。 + +### 只读输出控制台 + +新增 `workbench/console.rs` 的 `OutputConsole`: + +- 一个**只读** `gpui_xterm::TerminalView`:写端是 `io::sink()`,读端是 + `ChannelReader`(把 `mpsc` 字节块适配成 `Read`); +- 宿主在 UI 线程通过 `write_bytes`(受管进程 PTY 原始字节)与 + `write_text/write_heading/write_muted/write_error`(自己的提示,带 ANSI 配色) + 喂数据;`clear` 发 `ESC[2J ESC[H`; +- 通过 resize 回调记录终端实测行列数,作为受管进程 PTY 的初始尺寸 + (每次运行启动时快照一次); +- 配色复用 `terminal::terminal_color_palette()`,字体复用 `fonts::mono_family(cx)`, + 与集成终端同一套,不新增第二份 ANSI 调色板。 + +Run 与 Maven 面板各持有一个 `OutputConsole`,`run_output`/`maven_output` 字段由 +`Vec` 换成 `OutputConsole`(仍保留 `is_empty()`/`clear()` 语义供门控使用), +渲染直接挂载终端视图;滚动交给终端组件自己处理。 + +### “跟随末尾” + +不再用 `ScrollHandle` + 行数比较。开着跟随时,每次输出事件后调用 +`OutputConsole::scroll_to_bottom`;打开跟随时立即滚到底。用户手滚上去后输出不会 +强行拉回(与终端行为一致)。 + +### 正确做法 + +- 受管步骤启动一律走 `ProcessManager::start(..., terminal_size)`,不要回到 + `std::process::Command` + 管道。 +- 输出一律把原始字节交给 `OutputConsole`,不要在 `bottom_panel.rs` 里解析 ANSI 或 + 按行拆分。 +- 宿主提示用 `write_heading`(命令行/表头)、`write_muted`(状态)、`write_error` + (错误)三类,配色集中在这三个方法里。 +- 新增需要“终端观感”的只读输出,复用 `OutputConsole`,不要再写一套文本渲染。 + +### 不要这样做 + +- 不要给受管进程用管道后“手动补 `-Dstyle.color=always`”之类的开关来凑颜色; + 让子进程看到 TTY 才是根因修复。 +- 不要在 `bottom_panel.rs` 里再引入 `Vec` 行缓冲或行数上限。 +- 不要把 `OutputConsole` 做成可交互控制台(向子进程写键盘输入);Run 控制台 + 只需要渲染与选择/复制。 + +## 考虑过的备选方案 + +### 备选方案一:保留管道,把原始字节喂给虚拟终端 + +改动最小(只把 `spawn_reader` 改成读字节 + 给组件加 `feed_bytes`),但子进程不是 +TTY,Maven/JDK 依旧不输出颜色,`\r` 进度条虽能覆盖但整体观感提升有限。用户明确 +选择了“真终端”,故不采用。 + +### 备选方案二:只美化现有文本渲染 + +不动进程层,仅做关键词着色/行距/对齐。治标不治本,且与终端组件能力重复。不采用。 + +### 备选方案三:每个步骤新建一个终端视图 + +会丢失跨步骤的连续输出与回滚历史,且重复创建视图成本高。选择“一个控制台 + +channel 持续喂数据”。 + +## 后果 + +- 收益:Run/Maven 输出获得彩色、`\r` 进度、宽字符与换行对齐,与 IDEA 控制台观感 + 一致;宿主提示有统一的强调/灰/红配色。 +- 收益:输出滚动/回滚复用终端组件,`MAX_RUN_OUTPUT` 与 800 行截断等重复逻辑删除。 +- 代价:stdout/stderr 合并,失去原先 `[stderr]` 前缀区分(PTY 的固有代价)。 +- 代价:控制台是真实终端视图,会显示光标、可获焦点(键盘输入写到 `sink` 无副作用); + 只读化属于组件能力,暂未实现。 +- 注意:PTY 尺寸在每次运行启动时快照,运行中途改变面板宽度不会 resize 正在跑的 + PTY(下次运行生效)。 + +## 验证 + +- `bash scripts/build-linux.sh` 通过,产物 `target/debug/lithe-linux`。 +- `cargo test --manifest-path linux/Cargo.toml -p lithe-linux --lib` 通过;其中 + `workbench::run::process::tests` 覆盖 PTY 下的步骤失败短路与整进程组停止。 +- 运行 Maven/Spring 配置,输出为彩色,`\r` 进度条正常覆盖。 +- `./scripts/verify-agent-notes.sh` 通过。 + +## 适用范围 + +- `linux/src/workbench/run/process.rs`:受管步骤的 PTY 启动、原始字节输出、停止与 + 回收契约。 +- `linux/src/workbench/console.rs`:只读输出控制台(终端视图 + 字节通道 + 配色提示)。 +- `linux/src/workbench/bottom_panel.rs`:Run/Maven 面板挂载控制台、状态与门控。 +- `linux/src/workbench/terminal.rs`:`terminal_color_palette` 被控制台复用的唯一调色板。 +- `linux/src/fonts.rs`:控制台等宽字体来源。 +- `third_party/gpui_xterm/`:提供终端视图与 `state()` 补丁。 +- 不适用于 macOS/Windows 产品;两端的 Run 控制台由各自前端承担。 diff --git a/linux/src/workbench/bottom_panel.rs b/linux/src/workbench/bottom_panel.rs index 38931916a..f9f13d0e1 100644 --- a/linux/src/workbench/bottom_panel.rs +++ b/linux/src/workbench/bottom_panel.rs @@ -9,19 +9,20 @@ use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::EventEmitter; use gpui_kit::{ div, px, AnyElement, AppContext as _, Context, Entity, FontWeight, InteractiveElement as _, - IntoElement, ParentElement as _, Render, ScrollHandle, StatefulInteractiveElement as _, - Styled as _, WeakEntity, Window, + IntoElement, ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, + WeakEntity, Window, }; use crate::core::CoreClient; use crate::lsp; use crate::theme::ThemeColors; +use crate::workbench::console::OutputConsole; use crate::workbench::editor::EditorView; use crate::workbench::run::{ create_launch_plan_request, default_generated_configuration_id, list_java_sources, maven_context_for_configuration, parse_resolved_configurations, read_toolchain_paths, - sequence_is_current, toolchain_candidates, write_generated_documents, LaunchPlan, OutputStream, - ProcessEvent, ProcessManager, RunConfigItem, + sequence_is_current, toolchain_candidates, write_generated_documents, LaunchPlan, ProcessEvent, + ProcessManager, RunConfigItem, }; use crate::workbench::terminal::TerminalView; use crate::workbench::view::WorkbenchView; @@ -29,9 +30,6 @@ use crate::workbench::view::WorkbenchView; /// 运行历史上限。 const MAX_RUN_HISTORY: usize = 50; -/// Run 输出保留上限(行),超出丢弃最旧。 -const MAX_RUN_OUTPUT: usize = 2000; - /// Run 面板工程状态,对齐 Tauri RunPane 的 missing/ready/invalid 三态 /// (Linux 可用子集:invalid 统一为 `Failed` 并携带 core 错误文案)。 #[derive(Debug, Clone, PartialEq, Eq)] @@ -125,22 +123,18 @@ pub struct BottomPanelView { run_on_ready: bool, /// Run/配置加载诊断,按 Core 返回顺序展示。 pub(crate) run_diagnostics: Vec, - /// 运行输出行(后台线程经 channel 推送,`cx.spawn` 泵入)。 - pub(crate) run_output: Vec, + /// 运行输出控制台(PTY 原始字节经 channel 泵入,终端组件渲染)。 + pub(crate) run_console: OutputConsole, /// 是否有 Run execution 在准备或运行。 pub(crate) run_running: bool, /// 最近一次 Run 主进程退出码。 pub(crate) run_exit_code: Option, /// 输出跟随末尾(对齐 Tauri `scrollOutputToEnd`,默认开,落盘持久化)。 pub(crate) run_follow_end: bool, - /// 运行输出滚动句柄(跟随末尾用)。 - run_scroll: ScrollHandle, - /// 上次跟随到的输出行数(只在新增时滚动,避免无关重绘抢夺滚动条)。 - run_followed_len: usize, /// Maven 任务标题(对齐 Tauri `taskTitle`,如 `compile · pom.xml`)。 pub(crate) maven_title: Option, - /// Maven 任务输出行(`$ mvn …` 开头,对齐 Tauri Maven 页)。 - pub(crate) maven_output: Vec, + /// Maven 任务输出控制台(同 [`Self::run_console`])。 + pub(crate) maven_console: OutputConsole, /// Maven 任务是否在跑。 pub(crate) maven_running: bool, /// core 客户端(`reload_run_project` / `createLaunchPlan` 经它走 core JSON 命令)。 @@ -450,14 +444,12 @@ impl BottomPanelView { pending_run_id: None, run_on_ready: false, run_diagnostics: Vec::new(), - run_output: Vec::new(), + run_console: OutputConsole::new(cx), run_running: false, run_exit_code: None, run_follow_end: crate::settings::get(cx).run_scroll_to_end, - run_scroll: ScrollHandle::new(), - run_followed_len: 0, maven_title: None, - maven_output: Vec::new(), + maven_console: OutputConsole::new(cx), maven_running: false, client: CoreClient::new(), workbench: None, @@ -979,23 +971,19 @@ impl BottomPanelView { let execution_seq = self.run_execution_seq; self.run_running = true; self.run_exit_code = None; - push_run_line( - &mut self.run_output, - format!( - "{}:{}", - crate::i18n::menu_text(cx, "run.starting"), - item.name - ), - ); + // 子进程 PTY 的初始尺寸取输出控制台当前实测行列数。 + let terminal_size = self.run_console.size(); + self.run_console.write_heading(&format!( + "{}:{}", + crate::i18n::menu_text(cx, "run.starting"), + item.name + )); if Self::needs_java_project_preparation(&item) { - push_run_line( - &mut self.run_output, - run_ui_text( - cx, - "等待 Java 语言服务完成项目准备…", - "Waiting for Java language service preparation…", - ), - ); + self.run_console.write_muted(&run_ui_text( + cx, + "等待 Java 语言服务完成项目准备…", + "Waiting for Java language service preparation…", + )); } cx.notify(); let launch_seq = self.run_seq; @@ -1013,7 +1001,7 @@ impl BottomPanelView { let _ = this.update(cx, |view, cx| { if view.is_current_run_execution(launch_seq, launch_execution_seq) { view.run_running = false; - push_run_line(&mut view.run_output, error); + view.run_console.write_error(&error); cx.notify(); } }); @@ -1076,7 +1064,7 @@ impl BottomPanelView { view.run_exit_code = Some(1); view.run_diagnostics.push(error.clone()); view.run_diagnostics.dedup(); - push_run_line(&mut view.run_output, error); + view.run_console.write_error(&error); cx.notify(); } }); @@ -1096,7 +1084,7 @@ impl BottomPanelView { view.run_running = false; view.run_exit_code = Some(1); view.run_diagnostics.push(error.to_string()); - push_run_line(&mut view.run_output, error.to_string()); + view.run_console.write_error(&error.to_string()); cx.notify(); } }); @@ -1131,7 +1119,7 @@ impl BottomPanelView { let _ = this.update(cx, |view, cx| { if view.is_current_run_execution(launch_seq, launch_execution_seq) { view.run_running = false; - push_run_line(&mut view.run_output, error); + view.run_console.write_error(&error); cx.notify(); } }); @@ -1144,7 +1132,7 @@ impl BottomPanelView { let _ = this.update(cx, |view, cx| { if view.is_current_run_execution(launch_seq, launch_execution_seq) { view.run_running = false; - push_run_line(&mut view.run_output, error); + view.run_console.write_error(&error); cx.notify(); } }); @@ -1158,7 +1146,7 @@ impl BottomPanelView { let _ = this.update(cx, |view, cx| { if view.is_current_run_execution(launch_seq, launch_execution_seq) { view.run_running = false; - push_run_line(&mut view.run_output, error); + view.run_console.write_error(&error); cx.notify(); } }); @@ -1175,13 +1163,13 @@ impl BottomPanelView { } let execution_id = uuid::Uuid::new_v4().to_string(); let (sender, receiver) = mpsc::channel::(); - let handle = match processes.start("run", &execution_id, steps, sender) { + let handle = match processes.start("run", &execution_id, steps, sender, terminal_size) { Ok(handle) => handle, Err(error) => { let _ = this.update(cx, |view, cx| { if view.is_current_run_execution(launch_seq, launch_execution_seq) { view.run_running = false; - push_run_line(&mut view.run_output, error); + view.run_console.write_error(&error); cx.notify(); } }); @@ -1223,17 +1211,15 @@ impl BottomPanelView { index: _index, label, } => { - push_run_line(&mut view.run_output, label.clone()); + view.run_console.write_heading(&label); view.record_run(&label, cx); cx.notify(); } - ProcessEvent::Output { stream, text } => { - let text = if stream == OutputStream::Stderr { - format!("[stderr] {text}") - } else { - text - }; - push_run_line(&mut view.run_output, text); + ProcessEvent::Output { bytes } => { + view.run_console.write_bytes(&bytes); + if view.run_follow_end { + view.run_console.scroll_to_bottom(cx); + } cx.notify(); } ProcessEvent::Finished { @@ -1242,23 +1228,18 @@ impl BottomPanelView { error, } => { if let Some(error) = error { - push_run_line(&mut view.run_output, error); + view.run_console.write_error(&error); } let exit_code = exit_code.or_else(|| Some(1)); if cancelled { - push_run_line( - &mut view.run_output, - crate::i18n::menu_text(cx, "run.finished").to_string(), - ); + view.run_console + .write_muted(crate::i18n::menu_text(cx, "run.finished")); } else { - push_run_line( - &mut view.run_output, - format!( - "{}:{}", - crate::i18n::menu_text(cx, "run.exited"), - exit_code.unwrap_or(1) - ), - ); + view.run_console.write_muted(&format!( + "{}:{}", + crate::i18n::menu_text(cx, "run.exited"), + exit_code.unwrap_or(1) + )); } view.run_exit_code = exit_code; view.run_running = false; @@ -1280,20 +1261,16 @@ impl BottomPanelView { if self.run_running { self.run_execution_seq += 1; let _ = self.processes.request_stop("run", None); - push_run_line( - &mut self.run_output, - crate::i18n::menu_text(cx, "run.stopping").to_string(), - ); + self.run_console + .write_muted(crate::i18n::menu_text(cx, "run.stopping")); self.run_running = false; stopped = true; } if self.maven_running { self.maven_seq += 1; let _ = self.processes.request_stop("maven", None); - push_run_line( - &mut self.maven_output, - crate::i18n::menu_text(cx, "run.stopping").to_string(), - ); + self.maven_console + .write_muted(crate::i18n::menu_text(cx, "run.stopping")); self.maven_running = false; stopped = true; } @@ -1304,7 +1281,7 @@ impl BottomPanelView { /// 是否发生过 Maven 运行;宿主据此决定左侧栏是否展示 maven 项 ///(对齐 Tauri `hasMavenRun`:任务跑过即真,与终端历史无关)。 pub fn has_maven_run(&self) -> bool { - self.maven_running || !self.maven_output.is_empty() + self.maven_running || !self.maven_console.is_empty() } /// 运行 Maven 目标:Core 生成计划,Linux 只解析工具链并托管进程。 @@ -1337,7 +1314,7 @@ impl BottomPanelView { self.active_tab = BottomTab::Maven; self.is_collapsed = false; self.maven_title = Some(title); - self.maven_output.clear(); + self.maven_console.clear(); self.maven_running = true; cx.notify(); @@ -1346,6 +1323,7 @@ impl BottomPanelView { let module = maven_module_for_pom(&self.working_dir, pom_path); let profiles = profiles.to_vec(); let processes = self.processes.clone(); + let terminal_size = self.maven_console.size(); cx.spawn(async move |this, cx| { let plan = client .execute::( @@ -1376,7 +1354,7 @@ impl BottomPanelView { let _ = this.update(cx, |view, cx| { if view.maven_seq == seq { view.maven_running = false; - push_run_line(&mut view.maven_output, error); + view.maven_console.write_error(&error); cx.notify(); } }); @@ -1389,7 +1367,7 @@ impl BottomPanelView { let _ = this.update(cx, |view, cx| { if view.maven_seq == seq { view.maven_running = false; - push_run_line(&mut view.maven_output, error); + view.maven_console.write_error(&error); cx.notify(); } }); @@ -1404,7 +1382,7 @@ impl BottomPanelView { let _ = this.update(cx, |view, cx| { if view.maven_seq == seq { view.maven_running = false; - push_run_line(&mut view.maven_output, error); + view.maven_console.write_error(&error); cx.notify(); } }); @@ -1419,13 +1397,14 @@ impl BottomPanelView { } let execution_id = uuid::Uuid::new_v4().to_string(); let (sender, receiver) = mpsc::channel::(); - let handle = match processes.start("maven", &execution_id, steps, sender) { + let handle = match processes.start("maven", &execution_id, steps, sender, terminal_size) + { Ok(handle) => handle, Err(error) => { let _ = this.update(cx, |view, cx| { if view.maven_seq == seq { view.maven_running = false; - push_run_line(&mut view.maven_output, error); + view.maven_console.write_error(&error); cx.notify(); } }); @@ -1459,11 +1438,14 @@ impl BottomPanelView { } match event { ProcessEvent::Started { label, .. } => { - push_run_line(&mut view.maven_output, label.clone()); + view.maven_console.write_heading(&label); view.record_run(&label, cx); } - ProcessEvent::Output { text, .. } => { - push_run_line(&mut view.maven_output, text); + ProcessEvent::Output { bytes } => { + view.maven_console.write_bytes(&bytes); + if view.run_follow_end { + view.maven_console.scroll_to_bottom(cx); + } } ProcessEvent::Finished { exit_code, @@ -1471,22 +1453,17 @@ impl BottomPanelView { error, } => { if let Some(error) = error { - push_run_line(&mut view.maven_output, error); + view.maven_console.write_error(&error); } if let Some(code) = exit_code { - push_run_line( - &mut view.maven_output, - format!( - "{}:{}", - crate::i18n::menu_text(cx, "run.exited"), - code - ), - ); + view.maven_console.write_muted(&format!( + "{}:{}", + crate::i18n::menu_text(cx, "run.exited"), + code + )); } else if cancelled { - push_run_line( - &mut view.maven_output, - crate::i18n::menu_text(cx, "run.finished").to_string(), - ); + view.maven_console + .write_muted(crate::i18n::menu_text(cx, "run.finished")); } view.maven_running = false; } @@ -1693,8 +1670,10 @@ impl BottomPanelView { crate::settings::update(cx, |s| { s.run_scroll_to_end = this.run_follow_end; }); - // 打开跟随即滚到底(长度归零触发下次 render 滚动)。 - this.run_followed_len = 0; + // 打开跟随时立即滚到底;之后的输出跟随由事件泵负责。 + if this.run_follow_end { + this.run_console.scroll_to_bottom(cx); + } cx.notify(); })) .into_any_element(), @@ -1703,10 +1682,10 @@ impl BottomPanelView { "run-clear-output".to_string(), IconName::Trash, crate::i18n::menu_text(cx, "run.clearOutput").to_string(), - self.run_output.is_empty(), + self.run_console.is_empty(), cx, |this, _window, cx| { - this.run_output.clear(); + this.run_console.clear(); cx.notify(); }, )); @@ -1735,7 +1714,7 @@ impl BottomPanelView { .into_any_element() }); let can_rerun = self.last_maven_goal.is_some() && !self.maven_running; - let can_clear = !self.maven_output.is_empty() || self.maven_title.is_some(); + let can_clear = !self.maven_console.is_empty() || self.maven_title.is_some(); self.render_pane_header( IconName::Box, title, @@ -1764,7 +1743,7 @@ impl BottomPanelView { !can_clear, cx, |this, _window, cx| { - this.maven_output.clear(); + this.maven_console.clear(); cx.notify(); }, ), @@ -1903,27 +1882,6 @@ impl BottomPanelView { }) .collect(); - let total = self.run_output.len(); - let start = total.saturating_sub(800); - let output: Vec = if self.run_output.is_empty() { - vec![div() - .text_color(ThemeColors::text_muted()) - .child(crate::i18n::menu_text(cx, "run.emptyOutput")) - .into_any_element()] - } else { - self.run_output[start..] - .iter() - .map(|line| render_output_line(line)) - .collect() - }; - // 跟随末尾:只在新增输出时滚到最后一行(对齐 Tauri `useFollowOutputEnd`)。 - if self.run_follow_end && output.len() != self.run_followed_len { - self.run_followed_len = output.len(); - if output.len() > 1 { - self.run_scroll.scroll_to_item(output.len() - 1); - } - } - v_flex() .size_full() .child( @@ -1948,45 +1906,19 @@ impl BottomPanelView { .flex_1() .h_full() .min_h_0() - .id("run-output-scroll") - .overflow_y_scroll() - .track_scroll(&self.run_scroll) - .vertical_scrollbar(&self.run_scroll) - .p_2() - .font_family(crate::fonts::mono_family(cx)) - .text_xs() - .text_color(ThemeColors::text_primary()) - .children(output), + .child(self.run_console.view.clone()), ), ) .into_any_element() } /// Maven 面板体:输出区(首行 `$ mvn …`,流式追加);头部由 `render_maven_header` 负责。 - fn render_maven_panel(&self, cx: &mut Context) -> AnyElement { - let total = self.maven_output.len(); - let start = total.saturating_sub(800); - let output: Vec = if self.maven_output.is_empty() { - vec![div() - .text_color(ThemeColors::text_muted()) - .child(crate::i18n::menu_text(cx, "run.emptyOutput")) - .into_any_element()] - } else { - self.maven_output[start..] - .iter() - .map(|line| render_output_line(line)) - .collect() - }; + fn render_maven_panel(&self, _cx: &mut Context) -> AnyElement { div() .flex_1() .w_full() .min_h_0() - .overflow_y_scrollbar() - .p_2() - .font_family(crate::fonts::mono_family(cx)) - .text_xs() - .text_color(ThemeColors::text_primary()) - .children(output) + .child(self.maven_console.view.clone()) .into_any_element() } @@ -2266,38 +2198,6 @@ fn maven_module_for_pom(root: &str, pom: &str) -> String { } } -/// 行首空白转不换行空格:GPUI `Normal` 会塌缩连续空白(Tauri `pre-wrap` -/// 保留),只转行首以兼顾缩进保留与行内换行。 -fn preserve_leading_whitespace(line: &str) -> String { - let indent_len = line.len() - line.trim_start_matches([' ', '\t']).len(); - let (indent, rest) = line.split_at(indent_len); - let mut out = String::with_capacity(line.len() + indent.len() * 2); - for c in indent.chars() { - if c == '\t' { - out.push_str("\u{a0}\u{a0}\u{a0}\u{a0}"); - } else { - out.push('\u{a0}'); - } - } - out.push_str(rest); - out -} - -/// 输出行渲染:显式换行模式(对齐 Tauri `pre-wrap`)+ 行首缩进保留。 -fn render_output_line(line: &str) -> AnyElement { - div() - .whitespace_normal() - .child(preserve_leading_whitespace(line)) - .into_any_element() -} -fn push_run_line(output: &mut Vec, line: String) { - output.push(line); - if output.len() > MAX_RUN_OUTPUT { - let overflow = output.len() - MAX_RUN_OUTPUT; - output.drain(..overflow); - } -} - fn run_center_text(text: String) -> AnyElement { div() .size_full() diff --git a/linux/src/workbench/console.rs b/linux/src/workbench/console.rs new file mode 100644 index 000000000..5f747be24 --- /dev/null +++ b/linux/src/workbench/console.rs @@ -0,0 +1,188 @@ +//! 只读输出控制台:把字节流交给 `gpui_xterm` 终端组件渲染。 +//! +//! Run/Maven 的受管进程跑在 PTY 上,输出是带 ANSI 的原始字节。这里用一个只读 +//! 终端视图承接它们,得到彩色输出、`\r` 进度条覆盖与正确的宽字符/换行对齐, +//! 而不是逐行纯文本。控制台只渲染:输入写端是 `sink`,宿主通过 [`OutputConsole`] +//! 的写方法从 UI 线程喂字节。 +//! +//! Note: 为什么受管进程必须跑 PTY、以及只读控制台的边界见 +//! `.agents/notes/implemented/feature/2026-09-26-linux-run-console-pty-terminal.md`。 + +use std::io::{self, Read}; +use std::sync::mpsc; +use std::sync::{Arc, Mutex}; + +use gpui_kit::{px, App, Edges, Entity}; +use gpui_xterm::{TerminalConfig, TerminalView}; +use portable_pty::PtySize; + +use crate::settings; + +/// 控制台行列数兜底值:首帧测量前与异常情况下使用。 +const FALLBACK_COLS: u16 = 120; +const FALLBACK_ROWS: u16 = 30; +/// PTY 行列数下限,避免退化到 0。 +const MIN_COLS: u16 = 20; +const MIN_ROWS: u16 = 5; +/// 控制台内容内边距。 +const PADDING: f32 = 4.0; + +/// 错误行配色(亮红)。 +const ANSI_ERROR: &str = "\x1b[91m"; +/// 表头/命令行的强调(粗体)。 +const ANSI_BOLD: &str = "\x1b[1m"; +/// 次要提示(256 色灰)。 +const ANSI_MUTED: &str = "\x1b[38;5;245m"; +const ANSI_RESET: &str = "\x1b[0m"; + +/// 把收到的字节块按顺序交给 `Read` 的适配器;所有发送端 drop 后返回 EOF。 +struct ChannelReader { + rx: mpsc::Receiver>, + buf: Vec, + pos: usize, +} + +impl Read for ChannelReader { + fn read(&mut self, out: &mut [u8]) -> io::Result { + while self.pos >= self.buf.len() { + match self.rx.recv() { + Ok(next) => { + self.buf = next; + self.pos = 0; + } + Err(_) => return Ok(0), + } + } + let available = &self.buf[self.pos..]; + let count = available.len().min(out.len()); + out[..count].copy_from_slice(&available[..count]); + self.pos += count; + Ok(count) + } +} + +/// 只读输出控制台:终端视图 + 写端 + 最近一次实测尺寸。 +pub struct OutputConsole { + /// 终端视图实体,直接作为元素渲染。 + pub view: Entity, + tx: mpsc::Sender>, + /// 终端视图上报的行列数,作为受管进程 PTY 的初始尺寸。 + size: Arc>, + /// 是否写入过内容;宿主据此决定“清空”等动作是否可用。 + has_output: bool, +} + +impl OutputConsole { + /// 创建控制台。视图的生命周期与写端一致:写端常驻,读取线程不会提前 EOF。 + pub fn new(cx: &mut impl gpui_kit::AppContext) -> Self { + let (tx, rx) = mpsc::channel::>(); + let size = Arc::new(Mutex::new((FALLBACK_COLS, FALLBACK_ROWS))); + let size_slot = size.clone(); + let view = cx.new(move |cx| { + let config = console_config(cx); + let reader = ChannelReader { + rx, + buf: Vec::new(), + pos: 0, + }; + TerminalView::new(io::sink(), reader, config, cx).with_resize_callback( + move |cols, rows| { + if let Ok(mut slot) = size_slot.lock() { + *slot = ( + (cols as u16).clamp(MIN_COLS, u16::MAX), + (rows as u16).clamp(MIN_ROWS, u16::MAX), + ); + } + }, + ) + }); + Self { + view, + tx, + size, + has_output: false, + } + } + + /// 写入原始字节(受管进程的 PTY 输出)。 + pub fn write_bytes(&mut self, bytes: &[u8]) { + if bytes.is_empty() { + return; + } + self.has_output = true; + let _ = self.tx.send(bytes.to_vec()); + } + + /// 写入一行宿主自己的文本(命令回显、状态、错误)。 + pub fn write_text(&mut self, text: &str, ansi: Option<&str>) { + let line = match ansi { + Some(prefix) => format!("{prefix}{text}{ANSI_RESET}\r\n"), + None => format!("{text}\r\n"), + }; + self.write_bytes(line.as_bytes()); + } + + /// 命令回显/表头(粗体)。 + pub fn write_heading(&mut self, text: &str) { + self.write_text(text, Some(ANSI_BOLD)); + } + + /// 次要提示(灰)。 + pub fn write_muted(&mut self, text: &str) { + self.write_text(text, Some(ANSI_MUTED)); + } + + /// 错误(红)。 + pub fn write_error(&mut self, text: &str) { + self.write_text(text, Some(ANSI_ERROR)); + } + + /// 清屏并复位光标。 + pub fn clear(&mut self) { + let _ = self.tx.send(b"\x1b[2J\x1b[H".to_vec()); + self.has_output = false; + } + + /// 是否还没有输出(宿主门控用,与 `Vec::is_empty` 语义一致)。 + pub fn is_empty(&self) -> bool { + !self.has_output + } + + /// 受管进程 PTY 的初始尺寸(钳制到下限)。 + pub fn size(&self) -> PtySize { + let (cols, rows) = self + .size + .lock() + .map(|slot| *slot) + .unwrap_or((FALLBACK_COLS, FALLBACK_ROWS)); + PtySize { + cols: cols.max(MIN_COLS), + rows: rows.max(MIN_ROWS), + pixel_width: 0, + pixel_height: 0, + } + } + + /// 把视口滚到底部(“跟随末尾”)。 + pub fn scroll_to_bottom(&self, cx: &mut impl gpui_kit::AppContext) { + self.view.update(cx, |view, cx| { + view.state().scroll_to_bottom(); + cx.notify(); + }); + } +} + +/// 控制台终端配置:等宽字体跟随设置,配色取工作台主题 token。 +fn console_config(cx: &App) -> TerminalConfig { + let s = settings::get(cx); + TerminalConfig { + cols: FALLBACK_COLS as usize, + rows: FALLBACK_ROWS as usize, + font_family: crate::fonts::mono_family(cx).to_string(), + font_size: px(s.terminal_font_size), + scrollback: s.terminal_scrollback.max(1), + line_height_multiplier: 1.0, + padding: Edges::all(px(PADDING)), + colors: crate::workbench::terminal::terminal_color_palette(), + } +} diff --git a/linux/src/workbench/mod.rs b/linux/src/workbench/mod.rs index 1638a2a4b..ea0ce0a24 100644 --- a/linux/src/workbench/mod.rs +++ b/linux/src/workbench/mod.rs @@ -3,6 +3,7 @@ pub mod bottom_panel; pub mod branch_manager; pub mod branch_manager_logic; pub mod command_palette; +pub mod console; pub mod editor; pub mod extensions_panel; pub mod file_icon; diff --git a/linux/src/workbench/run/mod.rs b/linux/src/workbench/run/mod.rs index 112c48bcf..64913b3eb 100644 --- a/linux/src/workbench/run/mod.rs +++ b/linux/src/workbench/run/mod.rs @@ -12,4 +12,4 @@ pub use config::{ sequence_is_current, toolchain_candidates, write_generated_documents, write_toolchain_paths, LaunchPlan, RunConfigItem, ToolchainPaths, }; -pub use process::{OutputStream, ProcessEvent, ProcessManager}; +pub use process::{ProcessEvent, ProcessManager}; diff --git a/linux/src/workbench/run/process.rs b/linux/src/workbench/run/process.rs index 2855572ff..0b6e36a05 100644 --- a/linux/src/workbench/run/process.rs +++ b/linux/src/workbench/run/process.rs @@ -1,8 +1,10 @@ //! Run/Maven 进程解析与受管会话(跨平台)。 //! //! 每个执行由一个 session/execution 身份拥有。一个后台线程顺序执行所有步骤, -//! stdout/stderr 各自有明确归属并在进程结束后 join;停止操作向独立进程组 -//! 发送 TERM,超时后再 KILL,调用方通过 `ProcessHandle::join` 完成回收。 +//! 每个步骤跑在自己的 PTY 上:子进程因此看到真正的 TTY,Maven/JDK/Spring 才会 +//! 输出彩色与 `\r` 进度;PTY 输出以原始字节流交给宿主(由终端组件渲染), +//! 读取线程在进程结束后 join。停止操作向独立进程组发送 TERM,超时后再 KILL, +//! 调用方通过 `ProcessHandle::join` 完成回收。 //! //! 进程组信号是平台能力:Unix 用 `setpgid` + `kill(-pgid)` 覆盖整棵进程树, //! Windows 没有等价的原生进程组信号,改用 `taskkill /T` 终止同一 pid 的 @@ -10,14 +12,14 @@ use std::collections::{BTreeMap, HashMap}; use std::fs; -use std::io::{BufRead, BufReader}; +use std::io::Read; use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{mpsc, Arc, Condvar, Mutex}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; +use portable_pty::{native_pty_system, Child as PtyChild, CommandBuilder, PtySize}; use serde_json::Value; use super::config::{ @@ -62,21 +64,16 @@ pub struct ProcessStep { pub command: ProcessCommand, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OutputStream { - Stdout, - Stderr, -} - #[derive(Debug)] pub enum ProcessEvent { Started { index: usize, label: String, }, + /// PTY 原始输出字节。stdout/stderr 已由 PTY 合并,保留 ANSI 序列, + /// 由宿主的终端组件渲染。 Output { - stream: OutputStream, - text: String, + bytes: Vec, }, Finished { exit_code: Option, @@ -179,12 +176,16 @@ impl ProcessManager { } /// 启动一个 session;步骤失败会由同一个 worker 停止后续步骤。 + /// + /// `terminal_size` 是宿主输出控制台当前的行列数,用作每个步骤 PTY 的初始 + /// 尺寸;子进程看到的终端宽度因此与实际渲染宽度一致。 pub fn start( &self, session_id: &str, execution_id: &str, steps: Vec, sender: mpsc::Sender, + terminal_size: PtySize, ) -> Result { if session_id.trim().is_empty() || execution_id.trim().is_empty() { return Err("A process session and execution id are required".to_string()); @@ -213,7 +214,7 @@ impl ProcessManager { let worker = thread::Builder::new() .name("lithe-run-process".to_string()) .spawn(move || { - let outcome = execute_steps(&steps, &sender, &worker_control); + let outcome = execute_steps(&steps, &sender, &worker_control, terminal_size); if let Err(error) = outcome { let _ = sender.send(ProcessEvent::Finished { exit_code: Some(1), @@ -387,6 +388,7 @@ fn execute_steps( steps: &[ProcessStep], sender: &mpsc::Sender, control: &Control, + terminal_size: PtySize, ) -> Result<(), String> { for (index, step) in steps.iter().enumerate() { if control.is_stop_requested() { @@ -406,7 +408,7 @@ fn execute_steps( { return Ok(()); } - let outcome = execute_step(&step.command, sender, control)?; + let outcome = execute_step(&step.command, sender, control, terminal_size)?; let Some(code) = outcome else { let _ = sender.send(ProcessEvent::Finished { exit_code: None, @@ -433,79 +435,99 @@ fn execute_steps( } /// Returns `Some(exit_code)` for a completed child, or `None` when stopped. +/// +/// 步骤跑在自己的 PTY 上:子进程因此看到 TTY(彩色输出、`\r` 进度、宽度感知), +/// stdout/stderr 由 PTY 合并为一路原始字节流,交给宿主的输出控制台渲染。 fn execute_step( spec: &ProcessCommand, sender: &mpsc::Sender, control: &Control, + terminal_size: PtySize, ) -> Result, String> { - let mut command = Command::new(&spec.program); - command - .args(&spec.arguments) - .current_dir(&spec.working_directory) - .envs(&spec.environment) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - configure_process_group(&mut command); - let mut child = command - .spawn() - .map_err(|error| format!("Unable to start process {}: {error}", spec.program))?; - let pgid = child.id() as i32; - control.set_pgid(pgid); - let stdout = child.stdout.take(); - let stderr = child.stderr.take(); - let stdout_thread = spawn_reader(stdout, OutputStream::Stdout, sender.clone()); - let stderr_thread = spawn_reader(stderr, OutputStream::Stderr, sender.clone()); - - let status = wait_for_child(&mut child, control); - // A child can exit while a forked descendant still owns the pipe. Kill the - // whole process group before joining readers so the reader threads cannot - // remain blocked on inherited descriptors. - send_signal(Some(pgid), ProcessSignal::Kill); - if let Some(thread) = stdout_thread { - let _ = thread.join(); + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(terminal_size) + .map_err(|error| format!("Unable to allocate a pty for {}: {error}", spec.program))?; + + let mut command = CommandBuilder::new(&spec.program); + command.args(&spec.arguments); + if !spec.working_directory.trim().is_empty() { + command.cwd(&spec.working_directory); } - if let Some(thread) = stderr_thread { - let _ = thread.join(); + for (key, value) in &spec.environment { + command.env(key, value); } + + let mut child = pair + .slave + .spawn_command(command) + .map_err(|error| format!("Unable to start process {}: {error}", spec.program))?; + // `portable-pty` 让子进程 `setsid`,因此它的 pid 就是进程组 id,停止信号 + // 按组覆盖整棵进程树时直接用该值。 + let pgid = child.process_id().and_then(|pid| i32::try_from(pid).ok()); + if let Some(pgid) = pgid { + control.set_pgid(pgid); + } + // 父进程必须释放 slave,否则 PTY 永远看不到 hangup/EOT。 + drop(pair.slave); + + let reader = pair + .master + .try_clone_reader() + .map_err(|error| format!("Unable to read process output: {error}"))?; + let reader_thread = spawn_reader(reader, sender.clone()); + + let status = wait_for_child(child.as_mut(), control); + // 直接子进程可能先退出,而派生的子孙仍持有 PTY;先杀整组,再 join 读取 + // 线程,避免它永远阻塞在继承来的描述符上。 + if let Some(pgid) = pgid { + send_signal(Some(pgid), ProcessSignal::Kill); + } + let _ = reader_thread.join(); if control.is_stop_requested() { return Ok(None); } let status = status?; - status.code().map(Some).ok_or_else(|| { - format!( - "Process {} was terminated without an exit code", + if let Some(signal) = status.signal() { + return Err(format!( + "Process {} was terminated by signal {signal}", spec.program - ) - }) + )); + } + Ok(Some(status.exit_code() as i32)) } -fn spawn_reader( - reader: Option, - stream: OutputStream, - sender: mpsc::Sender, -) -> Option> +/// 读取 PTY 原始字节并转成输出事件;读取结束(EOF 或错误)即退出。 +fn spawn_reader(mut reader: R, sender: mpsc::Sender) -> JoinHandle<()> where - R: std::io::Read + Send + 'static, + R: Read + Send + 'static, { - let reader = reader?; - Some(thread::spawn(move || { - for line in BufReader::new(reader).lines() { - let Ok(line) = line else { break }; - if sender - .send(ProcessEvent::Output { stream, text: line }) - .is_err() - { - break; + thread::spawn(move || { + let mut buffer = [0u8; 8192]; + loop { + match reader.read(&mut buffer) { + Ok(0) => break, + Ok(count) => { + if sender + .send(ProcessEvent::Output { + bytes: buffer[..count].to_vec(), + }) + .is_err() + { + break; + } + } + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, } } - })) + }) } fn wait_for_child( - child: &mut Child, + child: &mut (dyn PtyChild + Send + Sync), control: &Control, -) -> Result { +) -> Result { loop { match child.try_wait() { Ok(Some(status)) => return Ok(status), @@ -535,26 +557,6 @@ fn stop_control(control: &Control) -> Result<(), String> { Ok(()) } -/// 把子进程放进独立进程组,使停止操作能覆盖子进程派生的整棵进程树。 -/// -/// Unix 用 `setpgid(0, 0)`;Windows 用 `CREATE_NEW_PROCESS_GROUP`,二者都让 -/// 后续终止按组/树进行,而不是只杀掉直接子进程。 -fn configure_process_group(command: &mut Command) { - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - command.process_group(0); - } - #[cfg(windows)] - { - use std::os::windows::process::CommandExt as _; - // CREATE_NEW_PROCESS_GROUP (0x0000_0200):让子进程成为新进程组 - // 组长,配合 `taskkill /T` 能一并清理其派生进程。 - const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; - command.creation_flags(CREATE_NEW_PROCESS_GROUP); - } -} - /// 终止一个外部拥有的进程组并有界等待它消失,语义与 [`ProcessHandle::stop`] /// 完全一致:先 `Terminate`,超过 `grace` 仍未退出再 `Kill`。 /// @@ -1211,6 +1213,16 @@ mod tests { } } + /// 测试用 PTY 尺寸;受管进程现在跑在 PTY 上,启动必须给出行列数。 + fn test_size() -> PtySize { + PtySize { + cols: 80, + rows: 24, + pixel_width: 0, + pixel_height: 0, + } + } + fn fixture(name: &str) -> Fixture { let root = std::env::temp_dir().join(format!( "lithe-linux-process-{name}-{}-{}", @@ -1275,7 +1287,7 @@ mod tests { let (sender, receiver) = mpsc::channel(); let handle = fixture .manager - .start("test", "execution-1", steps, sender) + .start("test", "execution-1", steps, sender, test_size()) .expect("start"); let events = wait_for_finished(&receiver, Duration::from_secs(2)); handle.join().expect("join"); @@ -1300,14 +1312,16 @@ mod tests { let (sender, receiver) = mpsc::channel(); let handle = fixture .manager - .start("test", "execution-2", steps, sender) + .start("test", "execution-2", steps, sender, test_size()) .expect("start"); let mut child_pid = None; let deadline = Instant::now() + Duration::from_secs(2); while Instant::now() < deadline && child_pid.is_none() { let remaining = deadline.saturating_duration_since(Instant::now()); match receiver.recv_timeout(remaining) { - Ok(ProcessEvent::Output { text, .. }) => { + Ok(ProcessEvent::Output { bytes }) => { + // PTY 下的换行是 CRLF,trim 后再解析 pid。 + let text = String::from_utf8_lossy(&bytes); if let Ok(pid) = text.trim().parse::() { child_pid = Some(pid); } diff --git a/linux/src/workbench/terminal.rs b/linux/src/workbench/terminal.rs index 5f577dee3..dcdc31b53 100644 --- a/linux/src/workbench/terminal.rs +++ b/linux/src/workbench/terminal.rs @@ -779,7 +779,9 @@ fn rgb8(color: Rgba) -> (u8, u8, u8) { /// 由工作台主题派生终端调色板:8 个基础色取 `terminal_*` token,亮色变体向前景 /// 混合,前景/背景/光标取主题对应色。仓库里不再保留第二份硬编码 ANSI 调色板。 -fn terminal_color_palette() -> ColorPalette { +/// +/// 集成终端与 Run/Maven 输出控制台共用这一份,避免两套配色漂移。 +pub(crate) fn terminal_color_palette() -> ColorPalette { let p = theme::palette(); let bright = |base: Rgba| rgb8(theme::mix(base, p.foreground, BRIGHT_MIX)); ColorPalette::builder() From eb0177085b8a51ccd41fa5457bc9a63c1a1e4810 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Sat, 26 Sep 2026 02:15:54 +0800 Subject: [PATCH 081/109] =?UTF-8?q?fix(linux):=20=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E5=A4=8D=E5=88=B6=E5=A4=B1=E6=95=88=E6=94=B9=E7=94=A8=20GPUI?= =?UTF-8?q?=20=E5=89=AA=E8=B4=B4=E6=9D=BF=EF=BC=8C=E5=8F=B3=E9=94=AE?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E6=96=87=E6=A1=88=E6=8E=A5=E5=85=A5=E8=AF=AD?= =?UTF-8?q?=E8=A8=80=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复制失效的根因与修法 - 上游 gpui_xterm 每次复制都新建并立即 drop arboard::Clipboard;X11 下 arboard 在 drop 时才把选区交给剪贴板管理器,句柄一销毁就来不及交接,复制到其它应用读不到 (本机已运行 CopyQ 仍复现,因此不是“缺管理器”) - 按你的要求不改上游剪贴板实现:在宿主侧兜底——终端与控制台在左键抬起与 Ctrl+C 时 通过组件暴露的 state() 取 selection_to_string(),写进 GPUI 自己的平台剪贴板 (其 X11 客户端进程内长期持有并后台线程服务选区)。无选区时返回 false,Ctrl+C 仍作为中断信号发给前台程序 - Run/Maven 输出控制台同样处理 右键菜单文案本地化(最小上游补丁) - third_party/gpui_xterm:新增 ContextMenuLabels 与 TerminalView::with_context_menu_labels,宿主注入文案;默认保持上游英文原文 - Lithe 侧用 menu.copy / menu.paste / ui.clear 三个既有 i18n 键注入,跟随中英文设置 说明 - 上游的 Clear 本来就是真清屏(ESC[3J ESC[H ESC[2J),无需修改 - 补丁保持最小、追加式,不改上游渲染与输入逻辑;third_party README 增补补丁与 “宿主侧剪贴板修复”的说明 验证:bash scripts/build-linux.sh 通过;cargo test -p lithe-linux --lib 98 passed; node scripts/verify-agent-notes.mjs 通过 --- ...-26-linux-gpui-terminal-component-reuse.md | 17 +++++- linux/src/workbench/bottom_panel.rs | 18 +++++- linux/src/workbench/console.rs | 29 +++++++-- linux/src/workbench/terminal.rs | 60 ++++++++++++++++++- third_party/gpui_xterm/README.md | 14 +++++ third_party/gpui_xterm/src/lib.rs | 4 +- third_party/gpui_xterm/src/view.rs | 48 +++++++++++++-- 7 files changed, 172 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-26-linux-gpui-terminal-component-reuse.md b/.agents/notes/implemented/architecture/2026-09-26-linux-gpui-terminal-component-reuse.md index ec096fc2f..d359c2b48 100644 --- a/.agents/notes/implemented/architecture/2026-09-26-linux-gpui-terminal-component-reuse.md +++ b/.agents/notes/implemented/architecture/2026-09-26-linux-gpui-terminal-component-reuse.md @@ -54,9 +54,11 @@ Linux 原生 GPUI 版终端不再自己把 `alacritty_terminal` 的网格投影 `Cargo.toml` 的依赖指向(`gpui = { package = "gpui-pre" }`、`gpui-component` crates.io 版、`alacritty_terminal` 与产品一致的 0.26),并记录上游 revision `4bdcdbbeab4211f7938f31919819b06d755a1733`。本地补丁只做必要扩展:暴露引擎 -状态(搜索/滚动/显示偏移)、让 `scrollback` 生效,以及把单元格宽度改为按半角 -ASCII 步进测量(上游取 `M/W/█/▀/▄` 最大值,块元素会回退到全角字体把整列撑宽)。 +状态(搜索/滚动/显示偏移)、让 `scrollback` 生效、把单元格宽度改为按半角 +ASCII 步进测量(上游取 `M/W/█/▀/▄` 最大值,块元素会回退到全角字体把整列撑宽), +以及让宿主注入右键菜单文案(上游把 Copy/Paste/Clear 写死,无法本地化)。 完整清单见 `third_party/gpui_xterm/README.md`;升级上游时按该清单重新应用补丁。 +补丁保持“最小、追加式”:不改上游的渲染与输入逻辑。 ### 会话层:终止与回收只有一套契约 @@ -87,6 +89,17 @@ KILL,最后兜底 `ChildKiller::kill` 并 join 等待线程。每一步都是 TUI 重排。Ctrl+F 由宿主的按键钩子(`with_key_handler`)从组件手里吞掉,再由宿主 容器打开搜索栏,避免组件把 `^F` 写进 PTY 后工作台再收到同一个按键。 +### 复制:宿主侧兜底,不改上游剪贴板代码 + +上游每次复制都新建一个 `arboard::Clipboard` 并立即 drop。X11 下 arboard 是在 drop +时把选区交给剪贴板管理器(CopyQ 之类),句柄一销毁就来不及交接,于是复制到其它 +应用读不到内容(本机就装并运行着 CopyQ,仍复现,说明不是“缺管理器”)。 + +修法放在宿主:终端/控制台的左键抬起与 Ctrl+C 时,用组件暴露的 `state()` 取 +`selection_to_string()`,再写进 **GPUI 自己的平台剪贴板**(其 X11 客户端在进程内 +长期持有 clipboard 并后台线程服务选区请求)。无选区时返回 false,Ctrl+C 继续作为 +中断信号发给前台程序。**不要为此重写上游的 `copy_selection`/`paste_from_clipboard`。** + ### 正确做法 - 需要滚动、选择、复制、随窗缩放时,用组件自身行为或 `TerminalView::state()` diff --git a/linux/src/workbench/bottom_panel.rs b/linux/src/workbench/bottom_panel.rs index f9f13d0e1..8add2990d 100644 --- a/linux/src/workbench/bottom_panel.rs +++ b/linux/src/workbench/bottom_panel.rs @@ -9,8 +9,8 @@ use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::EventEmitter; use gpui_kit::{ div, px, AnyElement, AppContext as _, Context, Entity, FontWeight, InteractiveElement as _, - IntoElement, ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, - WeakEntity, Window, + IntoElement, MouseButton, ParentElement as _, Render, StatefulInteractiveElement as _, + Styled as _, WeakEntity, Window, }; use crate::core::CoreClient; @@ -1906,6 +1906,12 @@ impl BottomPanelView { .flex_1() .h_full() .min_h_0() + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _event, _window, cx| { + this.run_console.copy_selection(cx); + }), + ) .child(self.run_console.view.clone()), ), ) @@ -1913,11 +1919,17 @@ impl BottomPanelView { } /// Maven 面板体:输出区(首行 `$ mvn …`,流式追加);头部由 `render_maven_header` 负责。 - fn render_maven_panel(&self, _cx: &mut Context) -> AnyElement { + fn render_maven_panel(&self, cx: &mut Context) -> AnyElement { div() .flex_1() .w_full() .min_h_0() + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _event, _window, cx| { + this.maven_console.copy_selection(cx); + }), + ) .child(self.maven_console.view.clone()) .into_any_element() } diff --git a/linux/src/workbench/console.rs b/linux/src/workbench/console.rs index 5f747be24..dbd7f8b24 100644 --- a/linux/src/workbench/console.rs +++ b/linux/src/workbench/console.rs @@ -12,7 +12,7 @@ use std::io::{self, Read}; use std::sync::mpsc; use std::sync::{Arc, Mutex}; -use gpui_kit::{px, App, Edges, Entity}; +use gpui_kit::{px, App, ClipboardItem, Edges, Entity}; use gpui_xterm::{TerminalConfig, TerminalView}; use portable_pty::PtySize; @@ -85,16 +85,16 @@ impl OutputConsole { buf: Vec::new(), pos: 0, }; - TerminalView::new(io::sink(), reader, config, cx).with_resize_callback( - move |cols, rows| { + TerminalView::new(io::sink(), reader, config, cx) + .with_context_menu_labels(crate::workbench::terminal::context_menu_labels(cx)) + .with_resize_callback(move |cols, rows| { if let Ok(mut slot) = size_slot.lock() { *slot = ( (cols as u16).clamp(MIN_COLS, u16::MAX), (rows as u16).clamp(MIN_ROWS, u16::MAX), ); } - }, - ) + }) }); Self { view, @@ -170,6 +170,25 @@ impl OutputConsole { cx.notify(); }); } + + /// 把控制台当前选区写入 GPUI 的平台剪贴板。 + /// + /// 与集成终端同理:组件的复制用临时 `arboard` 句柄,X11 下可能丢数据,宿主 + /// 侧再用 GPUI 剪贴板写一次兜底。返回是否复制了非空选区。 + pub fn copy_selection(&self, cx: &mut App) -> bool { + let text = self + .view + .read(cx) + .state() + .with_term(|term| term.selection_to_string()); + match text.filter(|text| !text.is_empty()) { + Some(text) => { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + true + } + None => false, + } + } } /// 控制台终端配置:等宽字体跟随设置,配色取工作台主题 token。 diff --git a/linux/src/workbench/terminal.rs b/linux/src/workbench/terminal.rs index dcdc31b53..584f280c3 100644 --- a/linux/src/workbench/terminal.rs +++ b/linux/src/workbench/terminal.rs @@ -35,8 +35,9 @@ use gpui_kit::component::input::InputEvent; use gpui_kit::component::{h_flex, v_flex, Disableable as _, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, App, AppContext as _, Context, Edges, Entity, InteractiveElement as _, IntoElement, - KeyDownEvent, ParentElement as _, Render, Rgba, Styled as _, Subscription, Window, + div, px, App, AppContext as _, ClipboardItem, Context, Edges, Entity, + InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, ParentElement as _, Render, + Rgba, Styled as _, Subscription, Window, }; use gpui_xterm::{ColorPalette, TerminalConfig, TerminalView as XtermView}; use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize}; @@ -893,6 +894,24 @@ fn is_search_shortcut(event: &KeyDownEvent) -> bool { && keystroke.key.eq_ignore_ascii_case("f") } +/// 判断一次按键是否是“复制”(Ctrl+C),与组件的判定保持一致。 +fn is_copy_shortcut(event: &KeyDownEvent) -> bool { + let keystroke = &event.keystroke; + keystroke.modifiers.control + && !keystroke.modifiers.alt + && !keystroke.modifiers.platform + && keystroke.key.eq_ignore_ascii_case("c") +} + +/// 终端右键菜单文案(跟随应用语言)。Run/Maven 输出控制台也复用这一份。 +pub(crate) fn context_menu_labels(cx: &App) -> gpui_xterm::ContextMenuLabels { + gpui_xterm::ContextMenuLabels { + copy: crate::i18n::menu_text(cx, "menu.copy").to_string(), + paste: crate::i18n::menu_text(cx, "menu.paste").to_string(), + clear: crate::i18n::menu_text(cx, "ui.clear").to_string(), + } +} + // --------------------------------------------------------------------------- // 视图层 // --------------------------------------------------------------------------- @@ -1003,6 +1022,7 @@ impl TerminalView { XtermView::new(writer, reader, config, cx) .with_resize_callback(move |cols, rows| resize(cols, rows)) .with_key_handler(is_search_shortcut) + .with_context_menu_labels(context_menu_labels(cx)) }); self.applied_font_size = settings::get(cx).terminal_font_size; self.applied_font_family = crate::fonts::mono_family(cx).to_string(); @@ -1301,6 +1321,29 @@ impl TerminalView { }); } } + + /// 把终端当前选区写入 GPUI 的平台剪贴板。 + /// + /// 不改上游:组件自身的复制用临时 `arboard::Clipboard`,X11 下句柄一 drop 就 + /// 可能丢失数据(复制到其它应用读不到)。GPUI 的剪贴板由 App 进程长期持有并 + /// 持续服务选区请求,因此在宿主侧再写一次即可让复制真正生效。返回是否复制了 + /// 非空选区;无选区时返回 false,让 Ctrl+C 继续作为中断信号发给前台程序。 + fn copy_selection_to_clipboard(&self, cx: &mut Context) -> bool { + let Some(xterm) = self.xterm.clone() else { + return false; + }; + let text = xterm + .read(cx) + .state() + .with_term(|term| term.selection_to_string()); + match text.filter(|text| !text.is_empty()) { + Some(text) => { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + true + } + None => false, + } + } } impl Render for TerminalView { @@ -1337,8 +1380,21 @@ impl Render for TerminalView { if is_search_shortcut(event) { this.open_search(window, cx); cx.stop_propagation(); + return; + } + // Ctrl+C:组件用临时 arboard 句柄复制,X11 下可能丢数据; + // 有选区时这里再用 GPUI 剪贴板复制一次兜底。 + if is_copy_shortcut(event) && this.copy_selection_to_clipboard(cx) { + cx.stop_propagation(); } })) + // 左键抬起(拖选/双击选词结束)后同样兜底复制一次。 + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _event, _window, cx| { + this.copy_selection_to_clipboard(cx); + }), + ) .child(xterm.clone()) .when_some(search_bar, |el, bar| el.child(bar)) .into_any_element() diff --git a/third_party/gpui_xterm/README.md b/third_party/gpui_xterm/README.md index 2c4c3cf16..840829d29 100644 --- a/third_party/gpui_xterm/README.md +++ b/third_party/gpui_xterm/README.md @@ -33,6 +33,20 @@ comment at the change site. target Linux systems the block-element samples fall back to a full-width (1em) CJK font, inflating every column to ~1.7× and making the whole grid much wider than a normal terminal. +5. `src/view.rs` + `src/lib.rs`: `ContextMenuLabels` and + `TerminalView::with_context_menu_labels` — upstream hardcoded the context-menu + labels ("Copy"/"Paste"/"Clear"); the host injects localized strings. The default + keeps upstream's wording so the component still works standalone. When pulling a newer upstream revision, re-apply these patches and re-run `cargo check -p gpui_xterm`. + +## Host-side behavior we do NOT patch upstream for + +Copy from the context menu / Ctrl+C is delivered by the host instead. Upstream +builds a throwaway `arboard::Clipboard` per copy and drops it immediately; on X11 +that drops the selection before it can be handed to a clipboard manager, so pasting +in another app yields nothing. Lithe reads the selection through the `state()` +accessor and writes it to GPUI's own platform clipboard (held for the process +lifetime), on left mouse-up and Ctrl+C. Keep the fix on the host side; do not +rewrite upstream's clipboard code. diff --git a/third_party/gpui_xterm/src/lib.rs b/third_party/gpui_xterm/src/lib.rs index 9b0762ffb..ed155c6da 100644 --- a/third_party/gpui_xterm/src/lib.rs +++ b/third_party/gpui_xterm/src/lib.rs @@ -13,6 +13,6 @@ pub use event::{GpuiEventProxy, TerminalEvent}; pub use render::TerminalRenderer; pub use terminal::TerminalState; pub use view::{ - BellCallback, ClipboardStoreCallback, ExitCallback, KeyHandler, ResizeCallback, TerminalConfig, - TerminalView, TitleCallback, + BellCallback, ClipboardStoreCallback, ContextMenuLabels, ExitCallback, KeyHandler, + ResizeCallback, TerminalConfig, TerminalView, TitleCallback, }; diff --git a/third_party/gpui_xterm/src/view.rs b/third_party/gpui_xterm/src/view.rs index 2bc9b0df3..20439d5fb 100644 --- a/third_party/gpui_xterm/src/view.rs +++ b/third_party/gpui_xterm/src/view.rs @@ -64,6 +64,28 @@ pub type ClipboardStoreCallback = Box)>; +/// 右键上下文菜单文案。 +/// +/// Lithe patch: 上游把 "Copy"/"Paste"/"Clear" 写死在菜单里,宿主无法本地化。这里 +/// 提供一个可注入的文案结构,默认值与上游一致;宿主通过 +/// [`TerminalView::with_context_menu_labels`] 传入本地化字符串。 +#[derive(Clone, Debug)] +pub struct ContextMenuLabels { + pub copy: String, + pub paste: String, + pub clear: String, +} + +impl Default for ContextMenuLabels { + fn default() -> Self { + Self { + copy: "Copy".to_string(), + paste: "Paste".to_string(), + clear: "Clear".to_string(), + } + } +} + #[derive(Clone, Copy, Debug, Default)] struct LayoutMetrics { origin: Point, @@ -100,6 +122,8 @@ pub struct TerminalView { exit_callback: Option, + context_menu_labels: ContextMenuLabels, + layout_metrics: Arc>, dragging_selection: bool, @@ -201,6 +225,7 @@ impl TerminalView { title_callback: None, clipboard_store_callback: None, exit_callback: None, + context_menu_labels: ContextMenuLabels::default(), layout_metrics: Arc::new(parking_lot::Mutex::new(LayoutMetrics::default())), dragging_selection: false, ime_text: String::new(), @@ -220,6 +245,12 @@ impl TerminalView { self } + /// Lithe patch: 注入右键菜单文案,供宿主本地化。 + pub fn with_context_menu_labels(mut self, labels: ContextMenuLabels) -> Self { + self.context_menu_labels = labels; + self + } + pub fn with_key_handler( mut self, handler: impl Fn(&KeyDownEvent) -> bool + Send + Sync + 'static, @@ -564,12 +595,13 @@ impl TerminalView { fn build_context_menu( view: WeakEntity, has_selection: bool, + labels: ContextMenuLabels, menu: PopupMenu, _window: &mut Window, _cx: &mut Context, ) -> PopupMenu { menu.item( - PopupMenuItem::new("Copy") + PopupMenuItem::new(labels.copy) .disabled(!has_selection) .on_click({ let view = view.clone(); @@ -580,7 +612,7 @@ impl TerminalView { } }), ) - .item(PopupMenuItem::new("Paste").on_click({ + .item(PopupMenuItem::new(labels.paste).on_click({ let view = view.clone(); move |_, _, cx| { let _ = view.update(cx, |this, cx| { @@ -590,7 +622,7 @@ impl TerminalView { } })) .item(PopupMenuItem::separator()) - .item(PopupMenuItem::new("Clear").on_click({ + .item(PopupMenuItem::new(labels.clear).on_click({ move |_, _, cx| { let _ = view.update(cx, |this, cx| { this.clear(cx); @@ -738,8 +770,16 @@ impl Render for TerminalView { .context_menu({ let view = cx.entity().downgrade(); let has_selection = self.has_selection(); + let labels = self.context_menu_labels.clone(); move |menu, window, cx| { - Self::build_context_menu(view.clone(), has_selection, menu, window, cx) + Self::build_context_menu( + view.clone(), + has_selection, + labels.clone(), + menu, + window, + cx, + ) } }) .child( From b30fbcaf467e216413293c1478a4436de9d48f7f Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Sat, 26 Sep 2026 02:21:32 +0800 Subject: [PATCH 082/109] =?UTF-8?q?fix(linux):=20=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E4=B8=8E=20Run/Maven=20=E6=8E=A7=E5=88=B6=E5=8F=B0=E9=85=8D?= =?UTF-8?q?=E8=89=B2=E8=B7=9F=E9=9A=8F=E4=B8=BB=E9=A2=98=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:应用切到深色后,集成终端(以及 Run/Maven 输出控制台)仍是浅色。组件不会自己 感知主题变化——`TerminalView::update_config(config, cx)` 才是切换配色的入口,而宿主 只在字号/字体变化时才推 config,主题切换时没推,只能重启才生效。 修复(全部在宿主侧,未改上游): - terminal.rs:记录已应用的主题背景色,Render 里检测到调色板变化就调用 update_config,把新的 `TerminalConfig.colors` 推给组件 - console.rs:新增 `apply_config(cx)`,用同一个 `update_config` 入口刷新配色/字体 - bottom_panel.rs:Render 里检测调色板变化,统一刷新 Run 与 Maven 两个控制台 建会话/建控制台时仍记录一次初始背景色,保证首帧与后续切换都正确。 验证:bash scripts/build-linux.sh 通过;cargo test -p lithe-linux --lib 98 passed; node scripts/verify-agent-notes.mjs 通过 --- ...26-09-26-linux-gpui-terminal-component-reuse.md | 8 ++++++++ linux/src/workbench/bottom_panel.rs | 14 +++++++++++++- linux/src/workbench/console.rs | 9 +++++++++ linux/src/workbench/terminal.rs | 12 ++++++++++-- 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-26-linux-gpui-terminal-component-reuse.md b/.agents/notes/implemented/architecture/2026-09-26-linux-gpui-terminal-component-reuse.md index d359c2b48..dc63377f7 100644 --- a/.agents/notes/implemented/architecture/2026-09-26-linux-gpui-terminal-component-reuse.md +++ b/.agents/notes/implemented/architecture/2026-09-26-linux-gpui-terminal-component-reuse.md @@ -89,6 +89,14 @@ KILL,最后兜底 `ChildKiller::kill` 并 join 等待线程。每一步都是 TUI 重排。Ctrl+F 由宿主的按键钩子(`with_key_handler`)从组件手里吞掉,再由宿主 容器打开搜索栏,避免组件把 `^F` 写进 PTY 后工作台再收到同一个按键。 +### 主题:运行时切换也要跟随 + +组件用 `TerminalConfig.colors` 决定配色,切换主题后不会自己变色。宿主在 `Render` +里检测主题背景色是否变化,变了就调用组件已有的 `TerminalView::update_config` +(集成终端在 `terminal.rs`,Run/Maven 控制台在 `console.rs`,由 `bottom_panel` +在调色板变化时统一推)。**不要只在建会话时设一次配色**,否则运行时切到深色主题后 +终端/控制台仍是旧配色,必须重启才生效。 + ### 复制:宿主侧兜底,不改上游剪贴板代码 上游每次复制都新建一个 `arboard::Clipboard` 并立即 drop。X11 下 arboard 是在 drop diff --git a/linux/src/workbench/bottom_panel.rs b/linux/src/workbench/bottom_panel.rs index 8add2990d..d3b8367dd 100644 --- a/linux/src/workbench/bottom_panel.rs +++ b/linux/src/workbench/bottom_panel.rs @@ -9,7 +9,7 @@ use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::EventEmitter; use gpui_kit::{ div, px, AnyElement, AppContext as _, Context, Entity, FontWeight, InteractiveElement as _, - IntoElement, MouseButton, ParentElement as _, Render, StatefulInteractiveElement as _, + IntoElement, MouseButton, ParentElement as _, Render, Rgba, StatefulInteractiveElement as _, Styled as _, WeakEntity, Window, }; @@ -135,6 +135,8 @@ pub struct BottomPanelView { pub(crate) maven_title: Option, /// Maven 任务输出控制台(同 [`Self::run_console`])。 pub(crate) maven_console: OutputConsole, + /// 最近一次应用到输出控制台的主题背景色;主题切换时用于同步配色。 + console_background: Rgba, /// Maven 任务是否在跑。 pub(crate) maven_running: bool, /// core 客户端(`reload_run_project` / `createLaunchPlan` 经它走 core JSON 命令)。 @@ -450,6 +452,7 @@ impl BottomPanelView { run_follow_end: crate::settings::get(cx).run_scroll_to_end, maven_title: None, maven_console: OutputConsole::new(cx), + console_background: crate::theme::palette().background, maven_running: false, client: CoreClient::new(), workbench: None, @@ -2010,6 +2013,15 @@ impl Render for BottomPanelView { return div().h(px(0.0)); } + // 主题切换时把新调色板同步给 Run/Maven 输出控制台(与集成终端一致); + // 否则切到深色后控制台仍保持创建时的浅色配色。 + let background = crate::theme::palette().background; + if background != self.console_background { + self.run_console.apply_config(cx); + self.maven_console.apply_config(cx); + self.console_background = background; + } + // 各窗格自带按钮头(对齐 Tauri:底部无标签切换条,切换只走左侧活动栏)。 let (header, body) = match self.active_tab { BottomTab::Terminal => { diff --git a/linux/src/workbench/console.rs b/linux/src/workbench/console.rs index dbd7f8b24..5702a438a 100644 --- a/linux/src/workbench/console.rs +++ b/linux/src/workbench/console.rs @@ -171,6 +171,15 @@ impl OutputConsole { }); } + /// 主题变化时把新调色板/字体推给控制台。 + /// + /// 走组件已有的 `TerminalView::update_config`;不推的话切到深色主题后控制台 + /// 仍保持创建时的浅色配色。 + pub fn apply_config(&self, cx: &mut App) { + let config = console_config(cx); + self.view.update(cx, |view, cx| view.update_config(config, cx)); + } + /// 把控制台当前选区写入 GPUI 的平台剪贴板。 /// /// 与集成终端同理:组件的复制用临时 `arboard` 句柄,X11 下可能丢数据,宿主 diff --git a/linux/src/workbench/terminal.rs b/linux/src/workbench/terminal.rs index 584f280c3..b174c3632 100644 --- a/linux/src/workbench/terminal.rs +++ b/linux/src/workbench/terminal.rs @@ -930,6 +930,9 @@ pub struct TerminalView { applied_font_size: f32, /// 最近一次应用到组件的字体族;编辑器字体变化时用于触发 `update_config`。 applied_font_family: String, + /// 最近一次应用到组件的主题背景色;主题切换时用于触发 `update_config`, + /// 让终端配色跟随深浅主题(否则切到深色后终端仍是浅色)。 + applied_background: Rgba, /// 终端内搜索状态。 search: SearchState, /// 搜索输入框(懒创建,复用工作台唯一输入实现)。 @@ -947,6 +950,7 @@ impl TerminalView { session_seq: 0, applied_font_size: settings::get(cx).terminal_font_size, applied_font_family: crate::fonts::mono_family(cx).to_string(), + applied_background: theme::palette().background, search: SearchState::new(), search_input: None, search_subscription: None, @@ -1026,6 +1030,7 @@ impl TerminalView { }); self.applied_font_size = settings::get(cx).terminal_font_size; self.applied_font_family = crate::fonts::mono_family(cx).to_string(); + self.applied_background = theme::palette().background; self.xterm = Some(xterm); self.session = Some(session); Self::spawn_event_pump(rx, seq, cx); @@ -1348,17 +1353,20 @@ impl TerminalView { impl Render for TerminalView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - // 字号或编辑器字体变化时把新配置推给组件(回滚缓冲只在建会话时生效)。 + // 字号、编辑器字体或主题变化时把新配置推给组件(回滚缓冲只在建会话时生效)。 let font_size = settings::get(cx).terminal_font_size; let font_family = crate::fonts::mono_family(cx).to_string(); + let background = theme::palette().background; if let Some(xterm) = self.xterm.clone() { let size_changed = (font_size - self.applied_font_size).abs() > f32::EPSILON; let family_changed = font_family != self.applied_font_family; - if size_changed || family_changed { + let theme_changed = background != self.applied_background; + if size_changed || family_changed || theme_changed { let config = self.xterm_config(cx); xterm.update(cx, |view, cx| view.update_config(config, cx)); self.applied_font_size = font_size; self.applied_font_family = font_family; + self.applied_background = background; } } From a751c91d5eb29448cdb6aa2a9858e819d64ca366 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Sat, 26 Sep 2026 02:37:41 +0800 Subject: [PATCH 083/109] =?UTF-8?q?feat(linux):=20=E5=BC=95=E5=85=A5=20rgi?= =?UTF-8?q?tui=20=E5=BC=95=E6=93=8E=EF=BC=88vendored=20diff=20=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E5=AD=90=E9=9B=86=EF=BC=8C=E7=BC=96=E8=AF=91=E9=80=9A?= =?UTF-8?q?=E8=BF=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按"部分引入上游引擎、数据仍走共享 core"的方案,vendored rgitui 的 diff 视图闭包: - third_party/rgitui/:rgitui_diff(diff 视图)+ rgitui_ui + rgitui_theme + rgitui_git(仅取 FileDiff/DiffLine/FileChangeKind 等普通数据类型)+ rgitui_settings - 上游 rev ead56a9;MIT,LICENSE 随附 适配(唯一改动,记录在 third_party/rgitui/Cargo.toml 与 README): - 工作区根把 gpui 从 Zed git rev 改指到 Lithe 的 gpui-pre 0.3.6(lib 名同为 gpui), 否则包身份不同无法与 gpui-kit 类型互通;成员源码只删了未使用的 perf/test-support 可选依赖与其引用注释,代码本身未改 验证:cargo check -p rgitui_diff 0 error / 0 warning;bash scripts/build-linux.sh 通过 (首次把 libgit2 拉进 Linux 构建;仅被 rgitui_git 编译,不参与取数) 下一步(未包含在本提交):theme 桥接、由 lithe-core git.diff 适配 FileDiff、Git 面板 UI --- linux/Cargo.lock | 431 +- linux/Cargo.toml | 1 + third_party/rgitui/Cargo.toml | 85 + third_party/rgitui/LICENSE | 21 + third_party/rgitui/README.md | 51 + .../rgitui/crates/rgitui_diff/Cargo.toml | 25 + .../crates/rgitui_diff/src/heap_size.rs | 712 ++ .../rgitui/crates/rgitui_diff/src/lib.rs | 7315 +++++++++++++++ .../rgitui/crates/rgitui_git/Cargo.toml | 29 + .../rgitui/crates/rgitui_git/src/graph.rs | 1959 ++++ .../rgitui/crates/rgitui_git/src/heap_size.rs | 322 + .../rgitui/crates/rgitui_git/src/lib.rs | 9 + .../crates/rgitui_git/src/project/argsafe.rs | 152 + .../crates/rgitui_git/src/project/auth.rs | 583 ++ .../crates/rgitui_git/src/project/bisect.rs | 163 + .../crates/rgitui_git/src/project/blame.rs | 323 + .../crates/rgitui_git/src/project/census.rs | 231 + .../crates/rgitui_git/src/project/diff.rs | 2294 +++++ .../rgitui_git/src/project/file_history.rs | 236 + .../rgitui_git/src/project/history_cache.rs | 419 + .../rgitui_git/src/project/local_ops.rs | 8152 +++++++++++++++++ .../crates/rgitui_git/src/project/mod.rs | 1882 ++++ .../crates/rgitui_git/src/project/network.rs | 510 ++ .../crates/rgitui_git/src/project/rebase.rs | 257 + .../crates/rgitui_git/src/project/reflog.rs | 196 + .../crates/rgitui_git/src/project/refresh.rs | 2117 +++++ .../crates/rgitui_git/src/project/search.rs | 443 + .../rgitui_git/src/project/submodule.rs | 295 + .../crates/rgitui_git/src/project/watcher.rs | 877 ++ .../rgitui_git/src/project/worktree_patch.rs | 3265 +++++++ .../rgitui/crates/rgitui_git/src/types.rs | 894 ++ .../rgitui/crates/rgitui_settings/Cargo.toml | 19 + .../rgitui/crates/rgitui_settings/src/lib.rs | 2143 +++++ .../rgitui/crates/rgitui_theme/Cargo.toml | 16 + .../rgitui/crates/rgitui_theme/src/colors.rs | 1493 +++ .../crates/rgitui_theme/src/json_theme.rs | 404 + .../rgitui/crates/rgitui_theme/src/lib.rs | 23 + .../rgitui/crates/rgitui_theme/src/theme.rs | 402 + .../rgitui/crates/rgitui_ui/Cargo.toml | 17 + .../crates/rgitui_ui/src/avatar_cache.rs | 472 + .../rgitui/crates/rgitui_ui/src/badge.rs | 110 + .../rgitui/crates/rgitui_ui/src/button.rs | 589 ++ .../rgitui/crates/rgitui_ui/src/checkbox.rs | 185 + .../crates/rgitui_ui/src/context_menu.rs | 182 + .../rgitui/crates/rgitui_ui/src/diff_stat.rs | 77 + .../rgitui/crates/rgitui_ui/src/disclosure.rs | 97 + .../rgitui/crates/rgitui_ui/src/divider.rs | 54 + .../rgitui/crates/rgitui_ui/src/fuzzy.rs | 100 + .../rgitui/crates/rgitui_ui/src/icon.rs | 211 + .../rgitui/crates/rgitui_ui/src/indicator.rs | 35 + .../rgitui/crates/rgitui_ui/src/label.rs | 106 + .../rgitui/crates/rgitui_ui/src/lib.rs | 59 + .../rgitui/crates/rgitui_ui/src/list_item.rs | 197 + .../rgitui/crates/rgitui_ui/src/modal.rs | 146 + .../rgitui/crates/rgitui_ui/src/picker.rs | 1270 +++ .../crates/rgitui_ui/src/scroll_list.rs | 5 + .../rgitui/crates/rgitui_ui/src/scrollbar.rs | 506 + .../rgitui/crates/rgitui_ui/src/select.rs | 570 ++ .../rgitui/crates/rgitui_ui/src/spinner.rs | 75 + .../rgitui/crates/rgitui_ui/src/stack.rs | 12 + .../crates/rgitui_ui/src/status_pill.rs | 207 + .../rgitui/crates/rgitui_ui/src/tab_bar.rs | 212 + .../rgitui/crates/rgitui_ui/src/text_input.rs | 680 ++ .../rgitui/crates/rgitui_ui/src/toast.rs | 214 + .../rgitui/crates/rgitui_ui/src/tooltip.rs | 89 + .../crates/rgitui_ui/src/window_controls.rs | 92 + 66 files changed, 45309 insertions(+), 9 deletions(-) create mode 100644 third_party/rgitui/Cargo.toml create mode 100644 third_party/rgitui/LICENSE create mode 100644 third_party/rgitui/README.md create mode 100644 third_party/rgitui/crates/rgitui_diff/Cargo.toml create mode 100644 third_party/rgitui/crates/rgitui_diff/src/heap_size.rs create mode 100644 third_party/rgitui/crates/rgitui_diff/src/lib.rs create mode 100644 third_party/rgitui/crates/rgitui_git/Cargo.toml create mode 100644 third_party/rgitui/crates/rgitui_git/src/graph.rs create mode 100644 third_party/rgitui/crates/rgitui_git/src/heap_size.rs create mode 100644 third_party/rgitui/crates/rgitui_git/src/lib.rs create mode 100644 third_party/rgitui/crates/rgitui_git/src/project/argsafe.rs create mode 100644 third_party/rgitui/crates/rgitui_git/src/project/auth.rs create mode 100644 third_party/rgitui/crates/rgitui_git/src/project/bisect.rs create mode 100644 third_party/rgitui/crates/rgitui_git/src/project/blame.rs create mode 100644 third_party/rgitui/crates/rgitui_git/src/project/census.rs create mode 100644 third_party/rgitui/crates/rgitui_git/src/project/diff.rs create mode 100644 third_party/rgitui/crates/rgitui_git/src/project/file_history.rs create mode 100644 third_party/rgitui/crates/rgitui_git/src/project/history_cache.rs create mode 100644 third_party/rgitui/crates/rgitui_git/src/project/local_ops.rs create mode 100644 third_party/rgitui/crates/rgitui_git/src/project/mod.rs create mode 100644 third_party/rgitui/crates/rgitui_git/src/project/network.rs create mode 100644 third_party/rgitui/crates/rgitui_git/src/project/rebase.rs create mode 100644 third_party/rgitui/crates/rgitui_git/src/project/reflog.rs create mode 100644 third_party/rgitui/crates/rgitui_git/src/project/refresh.rs create mode 100644 third_party/rgitui/crates/rgitui_git/src/project/search.rs create mode 100644 third_party/rgitui/crates/rgitui_git/src/project/submodule.rs create mode 100644 third_party/rgitui/crates/rgitui_git/src/project/watcher.rs create mode 100644 third_party/rgitui/crates/rgitui_git/src/project/worktree_patch.rs create mode 100644 third_party/rgitui/crates/rgitui_git/src/types.rs create mode 100644 third_party/rgitui/crates/rgitui_settings/Cargo.toml create mode 100644 third_party/rgitui/crates/rgitui_settings/src/lib.rs create mode 100644 third_party/rgitui/crates/rgitui_theme/Cargo.toml create mode 100644 third_party/rgitui/crates/rgitui_theme/src/colors.rs create mode 100644 third_party/rgitui/crates/rgitui_theme/src/json_theme.rs create mode 100644 third_party/rgitui/crates/rgitui_theme/src/lib.rs create mode 100644 third_party/rgitui/crates/rgitui_theme/src/theme.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/Cargo.toml create mode 100644 third_party/rgitui/crates/rgitui_ui/src/avatar_cache.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/badge.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/button.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/checkbox.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/context_menu.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/diff_stat.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/disclosure.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/divider.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/fuzzy.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/icon.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/indicator.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/label.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/lib.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/list_item.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/modal.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/picker.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/scroll_list.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/scrollbar.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/select.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/spinner.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/stack.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/status_pill.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/tab_bar.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/text_input.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/toast.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/tooltip.rs create mode 100644 third_party/rgitui/crates/rgitui_ui/src/window_controls.rs diff --git a/linux/Cargo.lock b/linux/Cargo.lock index c1818222b..b14805e97 100644 --- a/linux/Cargo.lock +++ b/linux/Cargo.lock @@ -180,6 +180,12 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + [[package]] name = "ammonia" version = "4.2.0" @@ -658,6 +664,15 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bindgen" version = "0.72.1" @@ -678,15 +693,30 @@ dependencies = [ "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", + "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" @@ -891,6 +921,48 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cap-fs-ext" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ff379b70af8e08307a8f65e7040c7301cb4a572538ade16b4984f0da77847f" +dependencies = [ + "cap-primitives", + "cap-std", + "io-lifetimes 3.0.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "cap-primitives" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "cap-std" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ec78e242cfa2cfe276807ac2ecc00315a6c97786977414bcd1c3963b6c91b8" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes 3.0.1", + "rustix", +] + [[package]] name = "caseless" version = "0.2.2" @@ -1921,6 +1993,17 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -2140,6 +2223,17 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -2360,6 +2454,19 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags 2.13.2", + "libc", + "libgit2-sys", + "log", + "url", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -2515,7 +2622,7 @@ dependencies = [ "log", "lsp-types", "markdown", - "notify", + "notify 7.0.0", "num-traits", "objc2 0.6.4", "objc2-app-kit 0.3.2", @@ -3623,6 +3730,17 @@ dependencies = [ "libc", ] +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.2", + "inotify-sys", + "libc", +] + [[package]] name = "inotify-sys" version = "0.1.8" @@ -3674,6 +3792,28 @@ dependencies = [ "rustversion", ] +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.60.2", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + [[package]] name = "io-surface" version = "0.16.1" @@ -3799,6 +3939,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "linux-keyutils", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -3911,6 +4066,18 @@ dependencies = [ "cc", ] +[[package]] +name = "libgit2-sys" +version = "0.18.8+1.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7c568b25d7489bc3fb2988ed69ab111d2944d2f5fec3d5c987fe545ea97b50" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + [[package]] name = "libloading" version = "0.8.9" @@ -3936,6 +4103,18 @@ dependencies = [ "libc", ] +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linebender_resource_handle" version = "0.1.1" @@ -3948,12 +4127,28 @@ version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9" +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linktime-proc-macro" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e57c38c1e860fd37c604281cdfb1dd2216977fd76a50f85ba2f388ef3219616" +[[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags 2.13.2", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4015,6 +4210,7 @@ dependencies = [ "portable-pty", "regex", "rfd", + "rgitui_diff", "serde", "serde_json", "tokio", @@ -4228,6 +4424,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -4394,7 +4596,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" dependencies = [ "arrayvec", - "bit-set", + "bit-set 0.9.1", "bitflags 2.13.2", "cfg-if", "cfg_aliases 0.2.2", @@ -4498,16 +4700,34 @@ dependencies = [ "bitflags 2.13.2", "filetime", "fsevent-sys", - "inotify", + "inotify 0.10.2", "kqueue", "libc", "log", "mio", - "notify-types", + "notify-types 1.0.1", "walkdir", "windows-sys 0.52.0", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.2", + "fsevent-sys", + "inotify 0.11.5", + "kqueue", + "libc", + "log", + "mio", + "notify-types 2.1.0", + "walkdir", + "windows-sys 0.60.2", +] + [[package]] name = "notify-rust" version = "4.18.0" @@ -4531,6 +4751,15 @@ dependencies = [ "instant", ] +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -5452,6 +5681,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "plist" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" +dependencies = [ + "base64 0.23.1", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "png" version = "0.17.16" @@ -5711,6 +5953,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.12" @@ -6202,6 +6453,82 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "rgitui_diff" +version = "0.4.1" +dependencies = [ + "anyhow", + "git2", + "gpui-pre", + "log", + "rgitui_git", + "rgitui_settings", + "rgitui_theme", + "rgitui_ui", + "similar", + "syntect", +] + +[[package]] +name = "rgitui_git" +version = "0.4.1" +dependencies = [ + "anyhow", + "cap-fs-ext", + "cap-std", + "chrono", + "dirs 6.0.0", + "git2", + "gpui-pre", + "log", + "notify 8.2.0", + "parking_lot", + "rgitui_settings", + "same-file", + "serde", + "serde_json", + "smol", + "tempfile", +] + +[[package]] +name = "rgitui_settings" +version = "0.4.1" +dependencies = [ + "anyhow", + "chrono", + "dirs 6.0.0", + "gpui-pre", + "keyring", + "log", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "rgitui_theme" +version = "0.4.1" +dependencies = [ + "anyhow", + "gpui-pre", + "log", + "rgitui_settings", + "serde", + "serde_json", +] + +[[package]] +name = "rgitui_ui" +version = "0.4.1" +dependencies = [ + "gpui-pre", + "log", + "rgitui_settings", + "rgitui_theme", + "smallvec", +] + [[package]] name = "ring" version = "0.17.14" @@ -6384,6 +6711,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix", +] + [[package]] name = "rustix-openpty" version = "0.2.0" @@ -6418,7 +6755,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -6566,6 +6903,19 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -6897,6 +7247,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "simplecss" version = "0.2.2" @@ -7294,6 +7650,27 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror 2.0.20", + "walkdir", + "yaml-rust", +] + [[package]] name = "sys-locale" version = "0.3.2" @@ -7489,6 +7866,7 @@ dependencies = [ "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -7497,6 +7875,16 @@ 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" @@ -8245,6 +8633,12 @@ 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_check" version = "0.9.5" @@ -8588,8 +8982,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" dependencies = [ "arrayvec", - "bit-set", - "bit-vec", + "bit-set 0.9.1", + "bit-vec 0.9.1", "bitflags 2.13.2", "bytemuck", "cfg_aliases 0.2.2", @@ -8660,7 +9054,7 @@ dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set", + "bit-set 0.9.1", "bitflags 2.13.2", "block2 0.6.2", "bytemuck", @@ -9406,6 +9800,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags 2.13.2", + "windows-sys 0.59.0", +] + [[package]] name = "wio" version = "0.2.2" @@ -9568,6 +9972,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "yazi" version = "0.2.1" diff --git a/linux/Cargo.toml b/linux/Cargo.toml index b5a8f192d..043e60bae 100644 --- a/linux/Cargo.toml +++ b/linux/Cargo.toml @@ -20,6 +20,7 @@ gpui-kit = { version = "0.6", features = [ ] } lithe-core = { path = "../rust/lithe-core" } gpui_xterm = { path = "../third_party/gpui_xterm" } +rgitui_diff = { path = "../third_party/rgitui/crates/rgitui_diff" } portable-pty = "0.9" alacritty_terminal = "0.26" serde = { version = "1", features = ["derive"] } diff --git a/third_party/rgitui/Cargo.toml b/third_party/rgitui/Cargo.toml new file mode 100644 index 000000000..3529d3ed8 --- /dev/null +++ b/third_party/rgitui/Cargo.toml @@ -0,0 +1,85 @@ +# Lithe vendored subset of noahbclarkson/rgitui. +# +# Upstream: https://github.com/noahbclarkson/rgitui +# Revision: ead56a9ec9d43cf099f27ae1129323a08d56149e +# License: MIT (see LICENSE) +# +# Only the diff-viewer closure is vendored: `rgitui_diff` (the diff view) plus +# `rgitui_ui`, `rgitui_theme`, `rgitui_git` (used for its plain data types only) +# and `rgitui_settings`. The app crate and the graph/AI/perf/test-support crates +# are not vendored. +# +# Adaptation (keep this as the single place that records it): +# upstream pins `gpui`/`gpui_platform`/`http_client` to a Zed git revision. +# Cargo treats a git dependency as a different package identity from the +# published `gpui-pre`/`gpui-component` family Lithe renders through, so the +# workspace re-points `gpui` at `gpui-pre` (same lib name) and drops the unused +# optional dependencies (`rgitui_perf`, `rgitui_test_support`, and the `perf` +# features that reached them). Nothing else in the members was edited. +# +# The vendored members are used for presentation only: repository data still +# comes from the shared `lithe-core` `git.*` contract that macOS and Windows +# already use. `git2` is compiled because `rgitui_git` needs it, but it is not +# used to read repositories from Lithe. + +[workspace] +resolver = "2" +members = [ + "crates/rgitui_settings", + "crates/rgitui_theme", + "crates/rgitui_ui", + "crates/rgitui_git", + "crates/rgitui_diff", +] + +[workspace.package] +edition = "2021" +publish = false +license = "MIT" +repository = "https://github.com/noahbclarkson/rgitui" + +[workspace.lints.clippy] +dbg_macro = "deny" +todo = "deny" + +[workspace.dependencies] +# GPUI framework, re-pointed from the Zed git revision to Lithe's published +# `gpui-pre` family (its lib name is `gpui`). +gpui = { package = "gpui-pre", version = "0.3.6" } + +# Internal crates +rgitui_theme = { path = "crates/rgitui_theme" } +rgitui_ui = { path = "crates/rgitui_ui" } +rgitui_git = { path = "crates/rgitui_git" } +rgitui_diff = { path = "crates/rgitui_diff" } +rgitui_settings = { path = "crates/rgitui_settings" } + +# Core +anyhow = "1" +similar = "2.6" +log = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +chrono = { version = "0.4", features = ["serde"] } +parking_lot = "0.12" +smallvec = { version = "1", features = ["union"] } + +# Git +# +# All network and authentication work shells out to the `git` CLI, so libgit2's +# own transports are never used; default features are disabled as upstream does. +git2 = { version = "0.20", default-features = false } + +# Async +smol = "2" + +# FS watching +notify = "8" + +# Utils +same-file = "1" +cap-std = "4.0.3" +cap-fs-ext = "4.0.3" +dirs = "6" +uuid = { version = "1", features = ["v4"] } +tempfile = "3.27" diff --git a/third_party/rgitui/LICENSE b/third_party/rgitui/LICENSE new file mode 100644 index 000000000..465d2367a --- /dev/null +++ b/third_party/rgitui/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 rgitui contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/rgitui/README.md b/third_party/rgitui/README.md new file mode 100644 index 000000000..6312b671f --- /dev/null +++ b/third_party/rgitui/README.md @@ -0,0 +1,51 @@ +# Vendored `rgitui` (diff-viewer subset) + +Lithe vendors a **subset** of [noahbclarkson/rgitui](https://github.com/noahbclarkson/rgitui) +to render Git diffs on Linux instead of writing a second diff view. + +- Upstream revision: `ead56a9ec9d43cf099f27ae1129323a08d56149e` +- License: MIT (see `LICENSE`) + +## What is vendored + +Only the closure needed by the diff viewer: + +| Crate | Role | +| --- | --- | +| `crates/rgitui_diff` | the diff view (`DiffViewer`), pairing/highlighting, syntect | +| `crates/rgitui_ui` | its widget kit (buttons, labels, icons, scroll) | +| `crates/rgitui_theme` | its theme state (appearance + colors) | +| `crates/rgitui_git` | **plain data types only** (`FileDiff`, `DiffLine`, `FileChangeKind`, …) | +| `crates/rgitui_settings` | settings type used by the crates above | + +Not vendored: the `rgitui` app crate, `rgitui_graph`, `rgitui_ai`, `rgitui_perf`, +`rgitui_test_support`, and any of their assets. + +## Adaptation (the only edits to upstream source) + +Upstream pins `gpui` / `gpui_platform` / `http_client` to a **Zed git revision**. +Cargo treats a git dependency as a different package identity from the published +`gpui-pre` / `gpui-component` family Lithe renders through, so: + +1. The workspace root `Cargo.toml` re-points `gpui` at `gpui-pre` (its lib name is + `gpui`) and drops the Zed git and `http_client` entries. Nothing in the member + sources was changed for this. +2. The members' unused optional dependencies were dropped: `rgitui_perf`, + `rgitui_test_support`, and the `perf` features that reached them. The leftover + `perf = []` feature declarations keep the members' `#[cfg(feature = "perf")]` + blocks known-but-off so the build stays warning-free. + +`cargo check -p rgitui_diff` against `gpui-pre 0.3.6` reports **0 errors and +0 warnings**, i.e. the pinned gpui revision and `gpui-pre` are API-compatible for +this closure. + +## Data ownership + +The vendored crates are **presentation only**. Repository data keeps coming from +the shared `lithe-core` `git.*` contract that macOS and Windows already use; +Lithe adapts `git.diff`'s structured rows/hunks into `rgitui_git::FileDiff` and +hands it to `DiffViewer::set_diff`. `git2` is compiled because `rgitui_git` +needs it, but it never reads a repository for Lithe. + +When updating the upstream revision, re-apply the three adaptations above and +re-run `cargo check -p rgitui_diff`. diff --git a/third_party/rgitui/crates/rgitui_diff/Cargo.toml b/third_party/rgitui/crates/rgitui_diff/Cargo.toml new file mode 100644 index 000000000..531621289 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_diff/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "rgitui_diff" +version = "0.4.1" +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +similar.workspace = true +gpui.workspace = true +rgitui_theme.workspace = true +rgitui_ui.workspace = true +rgitui_git.workspace = true +rgitui_settings.workspace = true +anyhow.workspace = true +# Only for parsing a hex OID out of a `DiffSource` when building the tree pair +# an apply/revert patch comes from. +git2.workspace = true +log.workspace = true +syntect = { version = "5.3", default-features = false, features = ["default-fancy"] } + +[features] +perf = [] diff --git a/third_party/rgitui/crates/rgitui_diff/src/heap_size.rs b/third_party/rgitui/crates/rgitui_diff/src/heap_size.rs new file mode 100644 index 000000000..de8e841ac --- /dev/null +++ b/third_party/rgitui/crates/rgitui_diff/src/heap_size.rs @@ -0,0 +1,712 @@ +//! [`HeapSize`] for the rendered rows and the cache that retains them. +//! +//! This is the view where a naive measurement misleads. Every source line is +//! stored twice — once as a unified row, once as a side-by-side row — but the +//! two rows hold clones of one [`gpui::SharedString`], so the text is on the +//! heap once while the highlight spans painted over it are on it twice. +//! Charging text at both sites would report roughly double what the app holds +//! and send someone deduplicating storage that is already deduplicated; +//! charging highlights at one site would hide duplication that is really +//! there. Every row population is therefore split into a `text` node, +//! deduplicated by buffer address, and a `highlights` node that is not. +//! +//! The other half of the story is retention. A [`crate::DisplayCache`] entry +//! keeps the rendered rows *and* the [`FileDiff`] they were rendered from, so +//! the cache's bound of fifty entries is really a bound on two populations +//! that scale differently: the source lines, and the spans syntect produced +//! for them. The walk names them separately for the same reason. + +use std::ops::Range; +use std::sync::Arc; + +use gpui::HighlightStyle; +use rgitui_git::FileDiff; +use rgitui_perf::{Census, HeapSize}; + +use crate::{ + CachedDisplayRows, DiffSource, DiffViewer, DisplayCache, DisplayCacheKey, DisplayRow, + SideBySideRow, StyledLine, ThreeWayRow, +}; + +/// Bytes a line's highlight spans occupy. +/// +/// The vector is priced arithmetically rather than by walking its elements +/// because [`HighlightStyle`] belongs to `gpui`: the orphan rule puts a +/// `HeapSize` impl for it out of reach of this crate, and of the type's own +/// crate too. Arithmetic is exact here rather than a concession — every field +/// of a `HighlightStyle` is an `Option` of `Copy` plain data (colours as four +/// `f32`s, a font weight, a thickness, a flag), so a span owns nothing beyond +/// the inline bytes the vector's buffer already accounts for. +fn highlight_bytes(line: &StyledLine) -> usize { + line.highlights.capacity() * size_of::<(Range, HighlightStyle)>() +} + +/// A styled line is a shared text handle plus a private span vector, and +/// telling those apart is what this module exists for: the text is charged to +/// the first row that reaches it, the spans to every row holding a copy. +impl HeapSize for StyledLine { + fn heap_size(&self, census: &mut Census) -> usize { + self.text.heap_size(census) + highlight_bytes(self) + } +} + +/// A rendered row seen as the parts the census prices separately. +/// +/// The three row types differ only in how many styled lines they carry, so +/// this is what lets one measurement serve all of them and keeps the plain +/// [`HeapSize`] impls below from drifting away from the labelled walk. +trait StyledRow { + /// Bytes owned by fields that are not styled lines: the hunk header and + /// the enclosing context name, which are `String`s of their own rather + /// than handles into shared text. + fn header_bytes(&self, census: &mut Census) -> usize; + + /// The row's styled lines — one for a unified row, two for a side-by-side + /// pair, three for a three-way triple. + fn styled_lines(&self) -> [Option<&StyledLine>; 3]; +} + +impl StyledRow for DisplayRow { + fn header_bytes(&self, census: &mut Census) -> usize { + match self { + DisplayRow::HunkHeader { + header, + context_name, + .. + } => header.heap_size(census) + context_name.heap_size(census), + DisplayRow::Line { .. } => 0, + } + } + + fn styled_lines(&self) -> [Option<&StyledLine>; 3] { + match self { + DisplayRow::HunkHeader { .. } => [None, None, None], + DisplayRow::Line { styled, .. } => [Some(styled), None, None], + } + } +} + +impl StyledRow for SideBySideRow { + fn header_bytes(&self, census: &mut Census) -> usize { + match self { + SideBySideRow::HunkHeader { + header, + context_name, + .. + } => header.heap_size(census) + context_name.heap_size(census), + SideBySideRow::Pair { .. } => 0, + } + } + + fn styled_lines(&self) -> [Option<&StyledLine>; 3] { + match self { + SideBySideRow::HunkHeader { .. } => [None, None, None], + SideBySideRow::Pair { + left_styled, + right_styled, + .. + } => [Some(left_styled), Some(right_styled), None], + } + } +} + +impl StyledRow for ThreeWayRow { + fn header_bytes(&self, census: &mut Census) -> usize { + match self { + ThreeWayRow::HunkHeader { + header, + context_name, + .. + } => header.heap_size(census) + context_name.heap_size(census), + ThreeWayRow::Triple { .. } => 0, + } + } + + fn styled_lines(&self) -> [Option<&StyledLine>; 3] { + match self { + ThreeWayRow::HunkHeader { .. } => [None, None, None], + ThreeWayRow::Triple { + left_styled, + mid_styled, + right_styled, + .. + } => [Some(left_styled), Some(mid_styled), Some(right_styled)], + } + } +} + +/// A row's own strings plus everything its styled lines own. +fn row_bytes(row: &R, census: &mut Census) -> usize { + let mut bytes = row.header_bytes(census); + for line in row.styled_lines().into_iter().flatten() { + bytes += line.heap_size(census); + } + bytes +} + +impl HeapSize for DisplayRow { + fn heap_size(&self, census: &mut Census) -> usize { + row_bytes(self, census) + } +} + +/// A side-by-side row is where the duplication shows up: both of its styled +/// lines are clones of unified rows, so its text costs nothing extra and its +/// highlight vectors cost their full size again. +impl HeapSize for SideBySideRow { + fn heap_size(&self, census: &mut Census) -> usize { + row_bytes(self, census) + } +} + +impl HeapSize for ThreeWayRow { + fn heap_size(&self, census: &mut Census) -> usize { + row_bytes(self, census) + } +} + +/// Which half of a set of [`StyledLine`]s a node is measuring. +#[derive(Clone, Copy)] +enum LinePart { + /// The text, whose buffer every display mode shares. + Text, + /// The highlight spans, which every display mode allocates for itself. + Highlights, +} + +/// One part of the styled lines held by a set of row collections. +struct RowPart<'a, R>(&'a [&'a Arc>], LinePart); + +impl HeapSize for RowPart<'_, R> { + fn heap_size(&self, census: &mut Census) -> usize { + let mut bytes = 0; + for rows in self.0 { + for row in rows.iter() { + for line in row.styled_lines().into_iter().flatten() { + bytes += match self.1 { + LinePart::Text => line.text.heap_size(census), + LinePart::Highlights => highlight_bytes(line), + }; + } + } + } + bytes + } +} + +/// A set of `Arc`-shared row collections measured as one population, split +/// into the text they share and the highlights they do not. +/// +/// The deduplication has to happen before that split rather than inside it. +/// The viewer's live rows are `Arc` clones of a cache entry's, so a collection +/// reached twice would have its text correctly charged once — the +/// `SharedString` impl sees to that — but its highlight vectors charged twice, +/// because those really are separate allocations and the census has no way to +/// tell a genuine second copy from a second visit to the first one. Skipping +/// an already-charged collection whole is what keeps both halves honest. +struct RowPopulation<'a, R>(Vec<&'a Arc>>); + +impl HeapSize for RowPopulation<'_, R> { + fn heap_size(&self, census: &mut Census) -> usize { + let fresh: Vec<&Arc>> = self + .0 + .iter() + .copied() + .filter(|rows| { + let id = Arc::as_ptr(rows) as *const () as usize; + census.visit_shared(id, &()).is_some() + }) + .collect(); + + let mut bytes = 0; + for rows in &fresh { + // The `Vec` header lives inside the `Arc`'s allocation, so it is + // this population's cost rather than its holder's. + bytes += size_of::>() + rows.capacity() * size_of::(); + for row in rows.iter() { + bytes += row.header_bytes(census); + } + } + + bytes + + census.enter("text", &RowPart(&fresh, LinePart::Text)) + + census.enter("highlights", &RowPart(&fresh, LinePart::Highlights)) + } + + /// Every row the node names, including collections another path was + /// charged for. A node reporting zero bytes against a large row count is + /// the report saying "these rows exist and are counted elsewhere", which + /// is more useful than a count that silently drops them. + fn heap_count(&self) -> Option { + Some(self.0.iter().map(|rows| rows.len()).sum()) + } +} + +impl HeapSize for DiffSource { + fn heap_size(&self, census: &mut Census) -> usize { + match self { + DiffSource::Worktree | DiffSource::Index => 0, + DiffSource::Commit(oid) | DiffSource::Stash(oid) => oid.heap_size(census), + DiffSource::Compare { from, to } => from.heap_size(census) + to.heap_size(census), + } + } +} + +impl HeapSize for DisplayCacheKey { + fn heap_size(&self, census: &mut Census) -> usize { + self.file_path.heap_size(census) + self.source.heap_size(census) + } +} + +/// A whole cache entry: the rows it rendered and the diff it rendered them +/// from. The census walks the cache by population rather than entry by entry — +/// fifty entries would mean fifty near-identical node paths — so this is the +/// measure for anyone asking what a single entry costs. +impl HeapSize for CachedDisplayRows { + fn heap_size(&self, census: &mut Census) -> usize { + self.display_rows.heap_size(census) + + self.sbs_rows.heap_size(census) + + self.source_diff.heap_size(census) + } + + fn heap_count(&self) -> Option { + Some(self.display_rows.len() + self.sbs_rows.len()) + } +} + +/// The cache's keys: the table itself, the key strings, and the recency queue +/// that holds a second full clone of every key. +struct CacheKeys<'a>(&'a DisplayCache); + +impl HeapSize for CacheKeys<'_> { + fn heap_size(&self, census: &mut Census) -> usize { + // hashbrown keeps `(key, value)` pairs in one bucket array with a + // control byte per bucket, so the table costs the whole pair even + // though only the key half is charged here; the value half is charged + // by the sibling nodes. + let mut bytes = + self.0.entries.capacity() * (size_of::<(DisplayCacheKey, CachedDisplayRows)>() + 1); + for key in self.0.entries.keys() { + bytes += key.heap_size(census); + } + // The recency queue clones its keys, so those strings are a second + // allocation rather than a shared one. + bytes + self.0.recency.heap_size(census) + } + + fn heap_count(&self) -> Option { + Some(self.0.entries.len()) + } +} + +/// The source diffs the cache retains alongside the rows it rendered. +/// +/// They are kept so a fingerprint collision is a miss rather than stale +/// content, which means every cached file's lines are held twice: once as +/// `String`s here and again as rendered text. Naming this separately is what +/// lets someone see that trade rather than infer it. +struct CachedSourceDiffs<'a>(&'a DisplayCache); + +impl HeapSize for CachedSourceDiffs<'_> { + fn heap_size(&self, census: &mut Census) -> usize { + let mut bytes = 0; + for entry in self.0.entries.values() { + bytes += entry.source_diff.heap_size(census); + } + bytes + } + + /// Lines rather than files, matching how `FileDiff` counts itself. + fn heap_count(&self) -> Option { + Some( + self.0 + .entries + .values() + .filter_map(|entry| entry.source_diff.heap_count()) + .sum(), + ) + } +} + +/// The cache is walked as four populations rather than entry by entry. The +/// question a report has to answer is not which file is cached but whether the +/// retained weight is the source diffs, the rendered rows, or the spans +/// painted over them — and that answer is the same shape whether the cache +/// holds two entries or fifty. +impl HeapSize for DisplayCache { + fn heap_size(&self, census: &mut Census) -> usize { + let unified: Vec<&Arc>> = self + .entries + .values() + .map(|entry| &entry.display_rows) + .collect(); + let side_by_side: Vec<&Arc>> = + self.entries.values().map(|entry| &entry.sbs_rows).collect(); + + census.enter("keys", &CacheKeys(self)) + + census.enter("source_diffs", &CachedSourceDiffs(self)) + + census.enter("unified_rows", &RowPopulation(unified)) + + census.enter("side_by_side_rows", &RowPopulation(side_by_side)) + } + + fn heap_count(&self) -> Option { + Some(self.entries.len()) + } +} + +/// The parts of a [`DiffViewer`] the census measures. +/// +/// Held apart from the view so the node labels can be checked without opening +/// a window: the labels are the report's primary key — a run-to-run comparison +/// matches nodes by path — so one that changes silently turns a comparison +/// into two unrelated reports. +struct DiffViewerCensus<'a> { + diff: &'a Option>, + display_rows: &'a Arc>, + sbs_rows: &'a Arc>, + three_way_rows: &'a Arc>, + display_cache: &'a DisplayCache, +} + +impl<'a> From<&'a DiffViewer> for DiffViewerCensus<'a> { + fn from(viewer: &'a DiffViewer) -> Self { + Self { + diff: &viewer.diff, + display_rows: &viewer.display_rows, + sbs_rows: &viewer.sbs_rows, + three_way_rows: &viewer.three_way_rows, + display_cache: &viewer.display_cache, + } + } +} + +impl HeapSize for DiffViewerCensus<'_> { + fn heap_size(&self, census: &mut Census) -> usize { + census.enter("display_cache", self.display_cache) + + census.enter("diff", self.diff) + + census.enter("unified_rows", &RowPopulation(vec![self.display_rows])) + + census.enter("side_by_side_rows", &RowPopulation(vec![self.sbs_rows])) + + census.enter("three_way_rows", &RowPopulation(vec![self.three_way_rows])) + } +} + +impl DiffViewer { + /// Records this view's retained memory under the current census path, + /// returning the bytes attributed so an enclosing scope can roll them up. + /// + /// The cache is walked before the live fields deliberately. What is on + /// screen is an `Arc` clone of a cache entry, so exactly one of the two + /// paths can be charged for it; charging the cache means `display_cache` + /// reports what the cache is really holding — the figure someone would act + /// on by lowering its bound — rather than that figure minus whichever file + /// happens to be open. The live nodes then read zero bytes against a + /// non-zero row count, which is the report saying "shared, counted above", + /// and go non-zero exactly when the displayed rows are *not* in the cache: + /// a three-way conflict view, or a diff too large for the cache to accept. + /// + /// Two things the viewer holds stay outside the census. The three-way + /// source diff's type belongs to `rgitui_git`, which puts a `HeapSize` + /// impl for it beyond the orphan rule's reach from here, and + /// `wrap_list_state`'s per-row measurement tree is private to gpui. Both + /// scale with the file on screen rather than with history, so the gap they + /// leave is bounded by one file. + pub fn census(&self, census: &mut Census) -> usize { + DiffViewerCensus::from(self).heap_size(census) + } +} + +#[cfg(test)] +mod tests { + /// Comfortably past `SharedString`'s inline threshold, so the text these + /// fixtures build genuinely lives on the heap and genuinely is shared + /// between clones. A short literal here silently asserts against a buffer + /// that was never allocated. + const LONG_LINE: &str = "let total = values.iter().copied().sum::();"; + + use gpui::SharedString; + use rgitui_git::{DiffHunk, DiffLine, FileChangeKind}; + + use super::*; + use crate::{DisplayLineKind, SideBySideLineKind}; + + /// Bytes one highlight span occupies inside its vector. + fn span_bytes() -> usize { + size_of::<(Range, HighlightStyle)>() + } + + /// A line with exactly one highlight span, so its vector's capacity is + /// known rather than left to the allocator. + fn styled(text: &SharedString) -> StyledLine { + StyledLine { + text: text.clone(), + highlights: vec![(0..2, HighlightStyle::default())], + } + } + + fn unified_row(text: &SharedString) -> DisplayRow { + DisplayRow::Line { + old_num: Some(1), + new_num: Some(1), + styled: styled(text), + kind: DisplayLineKind::Context, + } + } + + /// The side-by-side twin of [`unified_row`]: both halves show the same + /// context line, exactly as the real renderer builds them. + fn side_by_side_row(text: &SharedString) -> SideBySideRow { + SideBySideRow::Pair { + left_num: Some(1), + left_styled: styled(text), + left_kind: SideBySideLineKind::Context, + right_num: Some(1), + right_styled: styled(text), + right_kind: SideBySideLineKind::Context, + } + } + + fn file_diff() -> FileDiff { + FileDiff { + path: std::path::PathBuf::from("src/main.rs"), + hunks: vec![DiffHunk { + old_start: 1, + old_lines: 1, + new_start: 1, + new_lines: 1, + header: "@@ -1 +1 @@".to_string(), + lines: vec![DiffLine::Context("fn main() {".to_string())], + }], + additions: 0, + deletions: 0, + kind: FileChangeKind::Modified, + } + } + + fn cache_key(diff: &FileDiff) -> DisplayCacheKey { + DisplayCacheKey::new( + diff.path.display().to_string(), + true, + DiffSource::Worktree, + diff, + ) + } + + /// A cache holding one entry whose rows are the same allocations the + /// viewer would be displaying. + fn populated_cache( + diff: &Arc, + display_rows: &Arc>, + sbs_rows: &Arc>, + ) -> DisplayCache { + let mut cache = DisplayCache::default(); + cache.insert( + cache_key(diff), + CachedDisplayRows { + display_rows: Arc::clone(display_rows), + sbs_rows: Arc::clone(sbs_rows), + display_longest_row_ix: 0, + source_diff: Arc::clone(diff), + }, + ); + cache + } + + fn node<'a>(nodes: &'a [rgitui_perf::CensusNode], path: &str) -> &'a rgitui_perf::CensusNode { + nodes + .iter() + .find(|node| node.path == path) + .unwrap_or_else(|| panic!("{path} is missing from the census")) + } + + #[test] + fn a_styled_lines_highlights_are_charged_their_capacity() { + let mut census = Census::new(); + let text = LONG_LINE; + let mut highlights = Vec::with_capacity(8); + highlights.push((0..3, HighlightStyle::default())); + + let line = StyledLine { + text: SharedString::from(text.to_string()), + highlights, + }; + + assert_eq!( + line.heap_size(&mut census), + text.len() + 8 * span_bytes(), + "a vector that grew and was not filled is still holding its buffer" + ); + } + + #[test] + fn shared_text_is_charged_once_while_duplicated_highlights_are_charged_twice() { + let text = SharedString::from(LONG_LINE.to_string()); + let unified = unified_row(&text); + let side_by_side = side_by_side_row(&text); + + let mut census = Census::new(); + let unified_bytes = unified.heap_size(&mut census); + let side_by_side_bytes = side_by_side.heap_size(&mut census); + + assert_eq!( + unified_bytes, + text.len() + span_bytes(), + "the first row to reach the text is charged for it" + ); + assert_eq!( + side_by_side_bytes, + 2 * span_bytes(), + "the twin shares the text buffer but owns both of its own span vectors" + ); + assert_eq!( + unified_bytes + side_by_side_bytes, + text.len() + 3 * span_bytes(), + "three span vectors exist and one text buffer does" + ); + } + + #[test] + fn a_populated_cache_reports_its_entries_and_a_non_zero_size() { + let diff = Arc::new(file_diff()); + let text = SharedString::from(LONG_LINE.to_string()); + let display_rows = Arc::new(vec![unified_row(&text)]); + let sbs_rows = Arc::new(vec![side_by_side_row(&text)]); + let cache = populated_cache(&diff, &display_rows, &sbs_rows); + + let mut census = Census::new(); + let bytes = census.enter("display_cache", &cache); + let nodes = census.into_nodes(); + + assert_eq!(node(&nodes, "display_cache").count, Some(1)); + assert!(bytes > 0, "a cache holding an entry cannot cost nothing"); + assert!(node(&nodes, "display_cache/source_diffs").bytes > 0); + assert!(node(&nodes, "display_cache/unified_rows/text").bytes > 0); + assert_eq!( + node(&nodes, "display_cache/side_by_side_rows/text").bytes, + 0, + "the side-by-side rows clone the unified rows' text" + ); + assert_eq!( + node(&nodes, "display_cache/side_by_side_rows/highlights").bytes, + 2 * span_bytes(), + "they do not clone its highlights" + ); + } + + #[test] + fn rows_shared_with_the_cache_are_charged_to_the_cache_alone() { + let diff = Arc::new(file_diff()); + let text = SharedString::from(LONG_LINE.to_string()); + let display_rows = Arc::new(vec![unified_row(&text)]); + let sbs_rows = Arc::new(vec![side_by_side_row(&text)]); + let cache = populated_cache(&diff, &display_rows, &sbs_rows); + + let viewer = DiffViewerCensus { + diff: &Some(Arc::clone(&diff)), + display_rows: &display_rows, + sbs_rows: &sbs_rows, + three_way_rows: &Arc::new(Vec::new()), + display_cache: &cache, + }; + + let mut census = Census::new(); + census.enter("diff_viewer", &viewer); + let nodes = census.into_nodes(); + + assert!(node(&nodes, "diff_viewer/display_cache").bytes > 0); + assert_eq!( + node(&nodes, "diff_viewer/diff").bytes, + 0, + "the displayed diff is the entry's own source diff" + ); + assert_eq!(node(&nodes, "diff_viewer/unified_rows").bytes, 0); + assert_eq!( + node(&nodes, "diff_viewer/unified_rows").count, + Some(1), + "the rows are still named, so the report can say where they went" + ); + } + + #[test] + fn rows_the_cache_refused_are_charged_to_the_viewer() { + let text = SharedString::from(LONG_LINE.to_string()); + let three_way_rows = Arc::new(vec![ThreeWayRow::Triple { + left_num: Some(1), + left_styled: styled(&text), + left_kind: crate::ThreeWayLineKind::Unchanged, + mid_num: Some(1), + mid_styled: styled(&text), + mid_kind: crate::ThreeWayLineKind::Unchanged, + right_num: Some(1), + right_styled: styled(&text), + right_kind: crate::ThreeWayLineKind::Unchanged, + }]); + + let viewer = DiffViewerCensus { + diff: &None, + display_rows: &Arc::new(Vec::new()), + sbs_rows: &Arc::new(Vec::new()), + three_way_rows: &three_way_rows, + display_cache: &DisplayCache::default(), + }; + + let mut census = Census::new(); + census.enter("diff_viewer", &viewer); + let nodes = census.into_nodes(); + + assert_eq!( + node(&nodes, "diff_viewer/three_way_rows/text").bytes, + text.len() + ); + assert_eq!( + node(&nodes, "diff_viewer/three_way_rows/highlights").bytes, + 3 * span_bytes() + ); + } + + #[test] + fn the_viewer_census_emits_one_node_per_measured_population() { + let viewer = DiffViewerCensus { + diff: &None, + display_rows: &Arc::new(Vec::new()), + sbs_rows: &Arc::new(Vec::new()), + three_way_rows: &Arc::new(Vec::new()), + display_cache: &DisplayCache::default(), + }; + + let mut census = Census::new(); + census.enter("diff_viewer", &viewer); + + let mut paths: Vec = census + .into_nodes() + .into_iter() + .map(|node| node.path) + .collect(); + paths.sort(); + + assert_eq!( + paths, + [ + "diff_viewer", + "diff_viewer/diff", + "diff_viewer/display_cache", + "diff_viewer/display_cache/keys", + "diff_viewer/display_cache/side_by_side_rows", + "diff_viewer/display_cache/side_by_side_rows/highlights", + "diff_viewer/display_cache/side_by_side_rows/text", + "diff_viewer/display_cache/source_diffs", + "diff_viewer/display_cache/unified_rows", + "diff_viewer/display_cache/unified_rows/highlights", + "diff_viewer/display_cache/unified_rows/text", + "diff_viewer/side_by_side_rows", + "diff_viewer/side_by_side_rows/highlights", + "diff_viewer/side_by_side_rows/text", + "diff_viewer/three_way_rows", + "diff_viewer/three_way_rows/highlights", + "diff_viewer/three_way_rows/text", + "diff_viewer/unified_rows", + "diff_viewer/unified_rows/highlights", + "diff_viewer/unified_rows/text", + ] + ); + } +} diff --git a/third_party/rgitui/crates/rgitui_diff/src/lib.rs b/third_party/rgitui/crates/rgitui_diff/src/lib.rs new file mode 100644 index 000000000..33fb7b34f --- /dev/null +++ b/third_party/rgitui/crates/rgitui_diff/src/lib.rs @@ -0,0 +1,7315 @@ +#[cfg(feature = "perf")] +mod heap_size; + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::hash::{Hash, Hasher}; +use std::ops::Range; +use std::path::Path; +use std::sync::{Arc, OnceLock}; + +use similar::{capture_diff_slices, Algorithm}; + +use gpui::prelude::*; +use gpui::{ + div, list, px, uniform_list, AnyElement, App, ClickEvent, ClipboardItem, Context, CursorStyle, + ElementId, EventEmitter, FocusHandle, FontStyle, FontWeight, HighlightStyle, ListAlignment, + ListHorizontalSizingBehavior, ListSizingBehavior, ListState, MouseButton, MouseDownEvent, + MouseMoveEvent, MouseUpEvent, Render, ScrollStrategy, SharedString, StyledText, + UniformListScrollHandle, WeakEntity, Window, +}; +use rgitui_git::{ + ConflictSnapshot, DiffLine, FileDiff, MergeSection, ThreeWayFileDiff, WorktreePatchDirection, + WorktreePatchScope, WorktreePatchSource, +}; +use rgitui_theme::{ActiveTheme, Appearance, Color, StyledExt, ThemeState}; +use rgitui_ui::{ + Badge, Button, ButtonSize, ButtonStyle, EstimatedListScroll, Icon, IconName, IconSize, Label, + LabelSize, Scrollbar, Spinner, +}; +use syntect::highlighting::{ + FontStyle as SyntectFontStyle, HighlightIterator, HighlightState, Highlighter, Theme, ThemeSet, +}; +use syntect::parsing::{ParseState, ScopeStack, SyntaxReference, SyntaxSet}; + +/// Line numbers for a single selected line: (old_file_line, new_file_line). +/// old_file_line is None for additions (new lines), new_file_line is None for deletions. +pub type LineSelection = Vec<(Option, Option)>; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DiffViewerEvent { + /// The displayed file changed. Workspace listeners use this to start + /// blame/history prefetching for every path into the diff viewer. + DiffChanged { + path: String, + commit_id: Option, + generation: u64, + }, + HunkStageRequested(usize), + HunkUnstageRequested(usize), + /// Request to stage only the given lines within the current file's diff. + /// Args: (old_file_line_nums, new_file_line_nums) — one entry per selected line. + LineStageRequested(Vec<(Option, Option)>), + /// Request to unstage only the given lines within the current staged diff. + LineUnstageRequested(Vec<(Option, Option)>), + /// Request to write the displayed source's content into the working tree + /// (`Apply`) or take it back out (`Revert`), over `scope`. + /// + /// `scope` carries all three granularities — hunk, line selection, whole + /// file — because the handler treats them alike: resolve the source to a + /// tree pair and hand the scope to the git layer. + WorktreePatchRequested { + /// Always [`DiffOperation::Apply`] or [`DiffOperation::Revert`]. + operation: DiffOperation, + scope: WorktreePatchScope, + }, + /// A conflict resolver action. Unlike ordinary staging requests, each + /// variant carries the exact path and/or repository snapshot it was built + /// from so the git layer can reject stale work. + ConflictResolutionRequested(ConflictResolution), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConflictResolution { + SaveMerged { + path: String, + content: Vec, + mode: u32, + snapshot: ConflictSnapshot, + }, + UseOurs { + path: String, + snapshot: ConflictSnapshot, + }, + UseTheirs { + path: String, + snapshot: ConflictSnapshot, + }, + StageWorkingTree { + path: String, + snapshot: ConflictSnapshot, + }, + OpenInEditor { + path: String, + }, +} + +impl DiffViewerEvent { + /// The request `operation` raises against hunk `index`. + /// + /// The single mapping from operation to event, so a hunk-header button and a + /// key binding for the same operation cannot disagree. + pub fn for_hunk(operation: DiffOperation, index: usize) -> Self { + match operation { + DiffOperation::Stage => DiffViewerEvent::HunkStageRequested(index), + DiffOperation::Unstage => DiffViewerEvent::HunkUnstageRequested(index), + DiffOperation::Apply | DiffOperation::Revert => { + DiffViewerEvent::WorktreePatchRequested { + operation, + scope: WorktreePatchScope::Hunk(index), + } + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DiffDisplayMode { + #[default] + Unified, + SideBySide, + ThreeWay, +} + +/// An operation the diff viewer can offer over the content it is showing. +/// +/// Which of these are available is a property of the content's provenance +/// alone, and [`DiffSource::operations`] is the single place that decides it. +/// A mutable working-tree source can be staged or unstaged; content from +/// anywhere else — a commit, a stash, a comparison of two revisions — cannot, +/// but its changes can be applied to or reverted in the working tree, which are +/// the hunk-level equivalents of a cherry-pick and a revert. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DiffOperation { + /// Move working-tree content into the index. + Stage, + /// Move index content back out to the working tree. + Unstage, + /// Write this source's version of the content into the working tree. + Apply, + /// Take this source's change back out of the working tree. + Revert, +} + +impl DiffOperation { + /// Label for the per-hunk button in the diff viewer's hunk headers. + pub fn hunk_button_label(self) -> &'static str { + match self { + DiffOperation::Stage => "Stage Hunk", + DiffOperation::Unstage => "Unstage Hunk", + DiffOperation::Apply => "Apply Hunk", + DiffOperation::Revert => "Revert Hunk", + } + } + + /// Short label used inside the already-scoped hunk header. + pub fn compact_hunk_button_label(self) -> &'static str { + match self { + DiffOperation::Stage => "Stage", + DiffOperation::Unstage => "Unstage", + DiffOperation::Apply => "Apply", + DiffOperation::Revert => "Revert", + } + } + + /// Label for the whole-file entry in the diff viewer's file menu. + pub fn file_menu_label(self) -> &'static str { + match self { + DiffOperation::Stage => "Stage File", + DiffOperation::Unstage => "Unstage File", + DiffOperation::Apply => "Apply File to Working Tree", + DiffOperation::Revert => "Revert File in Working Tree", + } + } + + /// The *default* key that invokes this operation while the diff viewer has + /// focus, for hints and element ids. + /// + /// A hint rather than a lookup: the binding lives in the `commands!` registry + /// in `rgitui_workspace`, which sits above this crate and so cannot be named + /// from here, and the user may have rebound it. + pub fn key(self) -> &'static str { + match self { + DiffOperation::Stage => "s", + DiffOperation::Unstage => "u", + DiffOperation::Apply => "a", + DiffOperation::Revert => "r", + } + } + + /// True for the two operations that move content between the working tree + /// and the index without editing any file. + pub fn is_staging(self) -> bool { + matches!(self, DiffOperation::Stage | DiffOperation::Unstage) + } + + /// True for the two operations that rewrite files on disk. Callers must put + /// these on the undo stack, since nothing else records the previous contents. + pub fn writes_files(self) -> bool { + matches!(self, DiffOperation::Apply | DiffOperation::Revert) + } + + /// The git-layer direction this operation runs in, or `None` for the two + /// staging operations, which do not go through the patch machinery. + pub fn patch_direction(self) -> Option { + match self { + DiffOperation::Stage | DiffOperation::Unstage => None, + DiffOperation::Apply => Some(WorktreePatchDirection::Apply), + DiffOperation::Revert => Some(WorktreePatchDirection::Revert), + } + } +} + +/// Where the content currently shown in the diff viewer came from. +/// +/// Staged versus unstaged is a property of the two mutable sources only. A +/// commit, stash or revision pair has no such distinction — its content is +/// already recorded — so it carries the revision it came from instead, and +/// offers no staging. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum DiffSource { + /// Unstaged working-tree changes (index → workdir). Can be staged. + Worktree, + /// Staged changes (HEAD → index). Can be unstaged. + Index, + /// A file inside a commit, identified by its hex OID. + Commit(String), + /// A file inside a stash entry, identified by the stash commit's hex OID. + Stash(String), + /// The difference between two arbitrary revisions, `from` → `to`, as any + /// branch-comparison view would produce. Applying brings `to`'s content + /// into the working tree; reverting restores `from`'s. + /// + /// A commit diff carries [`Self::Commit`] rather than this, so that it can be + /// identified by OID for caching and blame; both resolve to a tree pair in + /// [`Self::patch_source`], so apply and revert treat them identically. + Compare { from: String, to: String }, +} + +impl DiffSource { + /// Build the matching mutable source from the sidebar's `staged` flag. + pub fn working_tree(staged: bool) -> Self { + if staged { + DiffSource::Index + } else { + DiffSource::Worktree + } + } + + /// Every operation this content supports, in the order the UI offers them. + pub fn operations(&self) -> &'static [DiffOperation] { + match self { + DiffSource::Worktree => &[DiffOperation::Stage], + DiffSource::Index => &[DiffOperation::Unstage], + DiffSource::Commit(_) | DiffSource::Stash(_) | DiffSource::Compare { .. } => { + &[DiffOperation::Apply, DiffOperation::Revert] + } + } + } + + /// Whether `operation` is available for this content. + pub fn offers(&self, operation: DiffOperation) -> bool { + self.operations().contains(&operation) + } + + /// The staging operation this content supports, or `None` when it is not + /// mutable working-tree content. + pub fn staging_action(&self) -> Option { + self.operations() + .iter() + .copied() + .find(|operation| operation.is_staging()) + } + + /// True when the content is a snapshot from outside the working tree, so it + /// has no staged/unstaged distinction and cannot be staged or unstaged. + pub fn is_historical(&self) -> bool { + self.staging_action().is_none() + } + + /// The commit-like OID backing this content, if any. Used for the display + /// cache key, the `DiffChanged` payload, and blame/history view caching. + /// + /// A comparison has no single commit to attribute lines to — its endpoints + /// may be branch names rather than OIDs — so it reports `None` and is + /// treated like working-tree content by the blame/history prefetch. + pub fn commit_id(&self) -> Option<&str> { + match self { + DiffSource::Worktree | DiffSource::Index | DiffSource::Compare { .. } => None, + DiffSource::Commit(oid) | DiffSource::Stash(oid) => Some(oid), + } + } + + /// Short label naming the revision (or pair) this content came from, for + /// menu entries and tooltips. `None` for working-tree content, which has no + /// revision to name. + pub fn revision_label(&self) -> Option { + match self { + DiffSource::Worktree | DiffSource::Index => None, + DiffSource::Commit(oid) | DiffSource::Stash(oid) => { + Some(oid[..7.min(oid.len())].to_string()) + } + DiffSource::Compare { from, to } => Some(format!("{from}...{to}")), + } + } + + /// The tree pair an apply or revert should be generated from, or `None` for + /// working-tree content, which supports staging instead. + pub fn patch_source(&self) -> Option { + match self { + DiffSource::Worktree | DiffSource::Index => None, + DiffSource::Commit(oid) | DiffSource::Stash(oid) => git2::Oid::from_str(oid) + .ok() + .map(WorktreePatchSource::Commit), + DiffSource::Compare { from, to } => Some(WorktreePatchSource::Compare { + from: from.clone(), + to: to.clone(), + }), + } + } + + /// Why `requested` cannot be performed on this content, as an actionable + /// sentence, or `None` when the request is valid. + /// + /// The viewer already hides the affordances that would produce an invalid + /// request, so this is a backstop for the case where the displayed content + /// changes between a click being painted and being delivered. + pub fn reject_operation(&self, requested: DiffOperation) -> Option { + if self.offers(requested) { + return None; + } + match (self, requested) { + (DiffSource::Worktree, DiffOperation::Unstage) => { + Some("These changes are not staged yet — press s to stage them first.".to_string()) + } + (DiffSource::Index, DiffOperation::Stage) => { + Some("These changes are already staged — press u to unstage them.".to_string()) + } + // A mutable source asked to apply or revert: that content is + // already in the working tree, so there is nothing to bring in. + (DiffSource::Worktree | DiffSource::Index, _) => Some( + "These changes are already in your working tree — use s or u to move them \ + between the index and the working tree." + .to_string(), + ), + (DiffSource::Commit(oid), _) => Some(format!( + "Commit {} is already committed and cannot be staged — press a to apply its \ + changes to your working tree, or select the file under Staged or Unstaged in \ + the sidebar.", + &oid[..7.min(oid.len())] + )), + (DiffSource::Stash(_), _) => Some( + "Stashed changes cannot be staged directly — press a to apply them to your \ + working tree, or pop the stash and stage it from the sidebar." + .to_string(), + ), + (DiffSource::Compare { from, to }, _) => Some(format!( + "{from}...{to} is a comparison of two revisions and cannot be staged — press a \ + to apply its changes to your working tree instead." + )), + } + } + + /// Badge shown in the diff viewer header. Content from outside the working + /// tree is labelled by its origin rather than by a staged/unstaged state it + /// does not have. + pub fn badge(&self) -> (&'static str, Color) { + match self { + DiffSource::Worktree => ("Unstaged", Color::Modified), + DiffSource::Index => ("Staged", Color::Added), + DiffSource::Commit(_) => ("Committed", Color::Muted), + DiffSource::Stash(_) => ("Stashed", Color::Muted), + DiffSource::Compare { .. } => ("Compared", Color::Muted), + } + } +} + +#[derive(Clone)] +enum DisplayRow { + HunkHeader { + header: String, + context_name: String, + hunk_index: usize, + }, + Line { + old_num: Option, + new_num: Option, + styled: StyledLine, + kind: DisplayLineKind, + }, +} + +#[derive(Clone)] +enum SideBySideRow { + HunkHeader { + header: String, + context_name: String, + hunk_index: usize, + }, + Pair { + left_num: Option, + left_styled: StyledLine, + left_kind: SideBySideLineKind, + right_num: Option, + right_styled: StyledLine, + right_kind: SideBySideLineKind, + }, +} + +#[derive(Clone, Copy, PartialEq)] +enum SideBySideLineKind { + Context, + Addition, + Deletion, + Empty, +} + +#[derive(Clone, Copy, PartialEq)] +enum ThreeWayLineKind { + Unchanged, + Conflict, + Resolved, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConflictChoice { + Ours, + Theirs, + BothOursFirst, + BothTheirsFirst, +} + +fn assemble_text_resolution( + diff: &ThreeWayFileDiff, + choices: &[Option], +) -> Option> { + if !diff.supports_text_resolution() || choices.len() != diff.conflict_count() { + return None; + } + + let mut content = Vec::new(); + let mut conflict_index = 0; + for section in &diff.sections { + match section { + MergeSection::Resolved(bytes) => content.extend_from_slice(bytes), + MergeSection::Conflict { ours, theirs, .. } => { + match choices.get(conflict_index).copied().flatten()? { + ConflictChoice::Ours => content.extend_from_slice(ours), + ConflictChoice::Theirs => content.extend_from_slice(theirs), + ConflictChoice::BothOursFirst => { + content.extend_from_slice(ours); + content.extend_from_slice(theirs); + } + ConflictChoice::BothTheirsFirst => { + content.extend_from_slice(theirs); + content.extend_from_slice(ours); + } + } + conflict_index += 1; + } + } + } + Some(content) +} + +fn conflict_progress_label(unresolved: usize, total: usize) -> String { + match (unresolved, total) { + (0, total) => format!("All {total} resolved"), + (unresolved, total) if unresolved == total => format!("{total} unresolved"), + (unresolved, total) => { + format!("{} resolved · {unresolved} remaining", total - unresolved) + } + } +} + +fn next_line_number(counter: &mut usize, present: bool) -> Option { + present.then(|| { + let number = *counter; + *counter += 1; + number + }) +} + +#[derive(Clone)] +enum ThreeWayRow { + HunkHeader { + header: String, + context_name: String, + conflict_index: usize, + resolved: bool, + allow_both: bool, + }, + Triple { + left_num: Option, + left_styled: StyledLine, + left_kind: ThreeWayLineKind, + mid_num: Option, + mid_styled: StyledLine, + mid_kind: ThreeWayLineKind, + right_num: Option, + right_styled: StyledLine, + right_kind: ThreeWayLineKind, + }, +} + +#[derive(Clone, Copy)] +enum DisplayLineKind { + Context, + Addition, + Deletion, +} + +#[derive(Clone, Default)] +struct StyledLine { + text: SharedString, + highlights: Vec<(Range, HighlightStyle)>, +} + +impl StyledLine { + fn plain(text: impl Into) -> Self { + Self { + text: text.into(), + highlights: Vec::new(), + } + } + + /// Merge word-level highlight spans into the existing highlights list. + /// `deletion_spans` are byte ranges of deleted words (highlighted red). + /// `addition_spans` are byte ranges of inserted words (highlighted green). + /// + /// Spans are clipped to `self.text.len()` and then merged into the + /// existing (sorted, non-overlapping) syntect highlights. GPUI's + /// `compute_runs` requires the final list to be sorted by position and + /// non-overlapping; simply appending would violate that invariant when + /// syntect already covers the full text, causing a panic. + /// + /// For overlapping regions the syntect foreground colour is preserved + /// alongside the word-level background colour. + fn apply_word_highlights( + &mut self, + deletion_spans: Vec>, + addition_spans: Vec>, + deleted_word_bg: HighlightStyle, + added_word_bg: HighlightStyle, + ) { + let text_len = self.text.len(); + if text_len == 0 { + return; + } + + // Collect all word spans with their styles, clipped to text bounds. + let mut word_spans: Vec<(Range, HighlightStyle)> = Vec::new(); + for span in deletion_spans { + let start = span.start.min(text_len); + let end = span.end.min(text_len); + if start < end { + word_spans.push((start..end, deleted_word_bg)); + } + } + for span in addition_spans { + let start = span.start.min(text_len); + let end = span.end.min(text_len); + if start < end { + word_spans.push((start..end, added_word_bg)); + } + } + + if word_spans.is_empty() { + return; + } + + word_spans.sort_by_key(|s| s.0.start); + + if self.highlights.is_empty() { + // No existing syntax highlights — use word spans directly. + self.highlights = word_spans; + return; + } + + // Merge word spans into existing syntect highlights by splitting + // syntect spans at word-span boundaries and combining styles for + // overlapping regions. + let existing = std::mem::take(&mut self.highlights); + let mut merged: Vec<(Range, HighlightStyle)> = Vec::new(); + let mut wi = 0; + + for (syn_range, syn_style) in &existing { + let mut pos = syn_range.start; + + while pos < syn_range.end { + // Advance past word spans already fully consumed. + while wi < word_spans.len() && word_spans[wi].0.end <= pos { + wi += 1; + } + + if wi >= word_spans.len() || word_spans[wi].0.start >= syn_range.end { + // No more word spans overlap this remainder. + merged.push((pos..syn_range.end, *syn_style)); + break; + } + + let (ref w_range, w_style) = word_spans[wi]; + + if w_range.start > pos { + // Syntect-only gap before the word span. + let gap_end = w_range.start.min(syn_range.end); + merged.push((pos..gap_end, *syn_style)); + pos = gap_end; + } else { + // Overlap: combine syntect style with word background. + let overlap_end = w_range.end.min(syn_range.end); + let mut combined = *syn_style; + if w_style.background_color.is_some() { + combined.background_color = w_style.background_color; + } + if w_style.color.is_some() { + combined.color = w_style.color; + } + merged.push((pos..overlap_end, combined)); + pos = overlap_end; + } + } + } + + self.highlights = merged; + } +} + +struct SyntaxAssets { + syntax_set: SyntaxSet, + theme_set: ThemeSet, +} + +enum SyntaxLineHighlighter { + Plain, + Syntect { + syntax_set: &'static SyntaxSet, + syntax: &'static SyntaxReference, + /// Built once per theme rather than per line. + /// + /// `HighlightLines::new` looks cheap and is not: it constructs a + /// `Highlighter`, which walks every scope selector in the theme into two + /// vectors and sorts one of them. Doing that per diff line put a + /// 100-200 element sort in front of every line of every diff. The + /// `Highlighter` is a pure function of the theme, so it is shared; only + /// the parse and highlight state have to be per line. + highlighter: &'static Highlighter<'static>, + }, +} + +/// Cache key for pre-computed display rows. The `source` distinguishes the two +/// mutable working-tree variants from each other and from any commit or stash; +/// mutable content additionally carries a fingerprint of the full `FileDiff`. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct DisplayCacheKey { + file_path: String, + is_dark: bool, + source: DiffSource, + content_fingerprint: u64, +} + +impl DisplayCacheKey { + fn new(file_path: String, is_dark: bool, source: DiffSource, diff: &FileDiff) -> Self { + Self { + file_path, + is_dark, + source, + content_fingerprint: file_diff_fingerprint(diff), + } + } +} + +/// Hash every field that affects rendered diff rows. The cache also stores and +/// compares the source `FileDiff`, so a theoretical hash collision is a cache +/// miss rather than stale rendered content. +fn file_diff_fingerprint(diff: &FileDiff) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + diff.path.hash(&mut hasher); + diff.kind.hash(&mut hasher); + diff.additions.hash(&mut hasher); + diff.deletions.hash(&mut hasher); + diff.hunks.len().hash(&mut hasher); + for hunk in &diff.hunks { + hunk.old_start.hash(&mut hasher); + hunk.old_lines.hash(&mut hasher); + hunk.new_start.hash(&mut hasher); + hunk.new_lines.hash(&mut hasher); + hunk.header.hash(&mut hasher); + hunk.lines.len().hash(&mut hasher); + for line in &hunk.lines { + std::mem::discriminant(line).hash(&mut hasher); + match line { + DiffLine::Context(text) | DiffLine::Addition(text) | DiffLine::Deletion(text) => { + text.hash(&mut hasher); + } + } + } + } + hasher.finish() +} + +/// Pre-computed display rows cached to avoid redundant syntax highlighting. +struct CachedDisplayRows { + display_rows: Arc>, + sbs_rows: Arc>, + display_longest_row_ix: usize, + source_diff: Arc, +} + +struct CachedDisplayRowsHit { + display_rows: Arc>, + sbs_rows: Arc>, + display_longest_row_ix: usize, +} + +struct PreparedDisplayRows { + display_rows: Arc>, + sbs_rows: Arc>, + display_longest_row_ix: usize, +} + +const DISPLAY_CACHE_MAX_ENTRIES: usize = 50; +/// Approximate memory bound expressed in prepared rows. It complements the +/// entry cap so a handful of unusually large diffs cannot dominate the cache. +const DISPLAY_CACHE_MAX_ROWS: usize = 200_000; + +#[derive(Default)] +struct DisplayCache { + entries: HashMap, + recency: VecDeque, + total_rows: usize, +} + +impl DisplayCache { + fn clear(&mut self) { + self.entries.clear(); + self.recency.clear(); + self.total_rows = 0; + } + + fn get(&mut self, key: &DisplayCacheKey, diff: &FileDiff) -> Option { + let matches = self + .entries + .get(key) + .is_some_and(|cached| cached.source_diff.as_ref() == diff); + if !matches { + // Remove a theoretical fingerprint collision rather than allowing + // it to replace or return rows for different source content. + self.remove(key); + return None; + } + + let cached = self.entries.get(key)?; + let result = CachedDisplayRowsHit { + display_rows: Arc::clone(&cached.display_rows), + sbs_rows: Arc::clone(&cached.sbs_rows), + display_longest_row_ix: cached.display_longest_row_ix, + }; + self.touch(key); + Some(result) + } + + fn insert(&mut self, key: DisplayCacheKey, value: CachedDisplayRows) { + let row_cost = Self::row_cost(&value); + self.remove(&key); + + // An entry larger than the entire budget is still displayed by the + // viewer, but retaining it would immediately evict the useful cache. + if row_cost > DISPLAY_CACHE_MAX_ROWS { + return; + } + + while self.entries.len() >= DISPLAY_CACHE_MAX_ENTRIES + || self.total_rows.saturating_add(row_cost) > DISPLAY_CACHE_MAX_ROWS + { + let Some(oldest) = self.recency.pop_front() else { + break; + }; + if let Some(evicted) = self.entries.remove(&oldest) { + self.total_rows = self.total_rows.saturating_sub(Self::row_cost(&evicted)); + } + } + + self.total_rows += row_cost; + self.recency.push_back(key.clone()); + self.entries.insert(key, value); + } + + fn remove(&mut self, key: &DisplayCacheKey) { + if let Some(removed) = self.entries.remove(key) { + self.total_rows = self.total_rows.saturating_sub(Self::row_cost(&removed)); + } + self.recency.retain(|existing| existing != key); + } + + fn touch(&mut self, key: &DisplayCacheKey) { + self.recency.retain(|existing| existing != key); + self.recency.push_back(key.clone()); + } + + fn row_cost(value: &CachedDisplayRows) -> usize { + value.display_rows.len() + value.sbs_rows.len() + } +} + +/// A diff viewer panel that displays file diffs with syntax coloring. +pub struct DiffViewer { + diff: Option>, + display_mode: DiffDisplayMode, + file_path: Option, + /// Provenance of the displayed content. Gates every staging affordance. + source: DiffSource, + display_rows: Arc>, + sbs_rows: Arc>, + three_way_rows: Arc>, + display_longest_row_ix: usize, + three_way_diff: Option>, + conflict_choices: Vec>, + scroll_handle: UniformListScrollHandle, + /// Variable-height virtualized list state used by wrap-mode rendering. + /// `gpui::list` caches per-row measurements in a SumTree so only the + /// visible (plus overdraw) rows render per frame. + wrap_list_state: ListState, + focus_handle: FocusHandle, + highlighted_row: Option, + selected_lines: Option>, + /// When true, selection tracks individual lines for partial hunk staging. + partial_mode: bool, + selection_anchor: Option, + mouse_selecting: bool, + /// Tracks the current theme appearance to drive syntax highlighting. + current_appearance: Appearance, + /// Top item index to restore after a display mode switch. + pending_scroll_top: Option, + /// True while a diff is being fetched in the background. Drives the loading + /// spinner in place of the content/placeholder states. + loading: bool, + /// Set when a diff fetch fails so the viewer can surface the error instead of + /// silently falling back to the empty placeholder. + error: Option, + /// Bounded LRU cache for computed display rows. + display_cache: DisplayCache, + /// Whether the header's file-level operations menu is open. It is the mouse + /// route to whole-file apply/revert, the granularity neither the hunk headers + /// nor a line selection can express. + file_menu_open: bool, + /// Bumped when the selected diff content changes. Workspace-level diff + /// refreshes use this to reject stale repository results. + generation: u64, + /// Independently guards background row preparation. Theme changes and + /// loading/error states invalidate presentation work without pretending the + /// selected repository diff changed. + preparation_generation: u64, +} + +/// Whether two file diffs would render identically. Used to skip a refresh that +/// would only churn viewer state (cleared selection / reset scroll) with no +/// visible change. Compares the actual hunk content — not just hunk/line counts — +/// so an in-place edit that preserves counts is still detected as a change. +fn file_diffs_render_equal(a: &FileDiff, b: &FileDiff) -> bool { + a.kind == b.kind + && a.additions == b.additions + && a.deletions == b.deletions + && a.hunks == b.hunks +} + +fn should_apply_prepared(current_generation: u64, prepared_generation: u64) -> bool { + current_generation == prepared_generation +} + +impl EventEmitter for DiffViewer {} + +impl DiffViewer { + pub fn new(cx: &mut Context) -> Self { + cx.observe_global::(|this: &mut Self, cx| { + this.rehighlight(cx); + }) + .detach(); + + Self { + diff: None, + display_mode: DiffDisplayMode::Unified, + file_path: None, + source: DiffSource::Worktree, + display_rows: Arc::new(Vec::new()), + sbs_rows: Arc::new(Vec::new()), + three_way_rows: Arc::new(Vec::new()), + display_longest_row_ix: 0, + three_way_diff: None, + conflict_choices: Vec::new(), + scroll_handle: UniformListScrollHandle::new(), + wrap_list_state: ListState::new(0, ListAlignment::Top, px(200.)), + focus_handle: cx.focus_handle(), + highlighted_row: None, + selected_lines: None, + partial_mode: false, + selection_anchor: None, + mouse_selecting: false, + current_appearance: Appearance::Dark, + pending_scroll_top: None, + loading: false, + error: None, + display_cache: DisplayCache::default(), + file_menu_open: false, + generation: 0, + preparation_generation: 0, + } + } + + /// Re-compute syntax-highlighted display rows when the theme appearance changes. + fn rehighlight(&mut self, cx: &mut Context) { + log::debug!("DiffViewer::rehighlight: appearance changed"); + let appearance = cx.theme().appearance; + if appearance == self.current_appearance { + return; + } + // Clear the display cache since the appearance changed. + self.display_cache.clear(); + self.current_appearance = appearance; + // Three-way rows are plain text and theme-independent. In particular, + // do not invalidate an in-flight three-way preparation, which would + // otherwise leave its loading state waiting for a discarded result. + if self.three_way_diff.is_some() { + cx.notify(); + return; + } + self.preparation_generation = self.preparation_generation.wrapping_add(1); + let colors = cx.colors(); + let added_word_bg = HighlightStyle { + background_color: Some(gpui::Hsla { + a: 0.25, + ..colors.vc_added + }), + ..Default::default() + }; + let deleted_word_bg = HighlightStyle { + background_color: Some(gpui::Hsla { + a: 0.25, + ..colors.vc_deleted + }), + ..Default::default() + }; + if let (Some(diff), Some(path)) = (self.diff.clone(), self.file_path.clone()) { + let cache_key = DisplayCacheKey::new( + path.clone(), + appearance == Appearance::Dark, + self.source.clone(), + &diff, + ); + self.spawn_display_preparation( + diff, + path, + cache_key, + appearance, + added_word_bg, + deleted_word_bg, + cx, + ); + } + cx.notify(); + } + + fn spawn_display_preparation( + &mut self, + diff: Arc, + path: String, + cache_key: DisplayCacheKey, + appearance: Appearance, + added_word_bg: HighlightStyle, + deleted_word_bg: HighlightStyle, + cx: &mut Context, + ) { + let generation = self.preparation_generation; + cx.spawn(async move |this, cx: &mut gpui::AsyncApp| { + let diff_for_prepare = Arc::clone(&diff); + let path_for_prepare = path.clone(); + let prepared = cx + .background_executor() + .spawn(async move { + Self::prepare_display_rows( + &diff_for_prepare, + &path_for_prepare, + appearance, + added_word_bg, + deleted_word_bg, + ) + }) + .await; + + this.update(cx, |this, cx| { + if !should_apply_prepared(this.preparation_generation, generation) { + return; + } + + this.display_cache.insert( + cache_key, + CachedDisplayRows { + display_rows: Arc::clone(&prepared.display_rows), + sbs_rows: Arc::clone(&prepared.sbs_rows), + display_longest_row_ix: prepared.display_longest_row_ix, + source_diff: Arc::clone(&diff), + }, + ); + this.display_rows = prepared.display_rows; + this.sbs_rows = prepared.sbs_rows; + this.display_longest_row_ix = prepared.display_longest_row_ix; + this.loading = false; + this.sync_wrap_list_state(); + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + pub fn focus(&self, window: &mut Window, cx: &mut Context) { + self.focus_handle.focus(window, cx); + cx.notify(); + } + + pub fn is_focused(&self, window: &Window) -> bool { + self.focus_handle.is_focused(window) + } + + pub fn set_diff( + &mut self, + diff: FileDiff, + path: String, + source: DiffSource, + cx: &mut Context, + ) { + log::debug!("DiffViewer::set_diff: path={} source={:?}", path, source); + self.generation = self.generation.wrapping_add(1); + self.preparation_generation = self.preparation_generation.wrapping_add(1); + self.error = None; + // Showing a standard diff supersedes any active conflict view. Clearing + // the three-way state here (before the cache-hit early-return) prevents the + // render match from routing to the stale ThreeWay arm after switching from a + // conflict file to a normal one. + if self.three_way_diff.is_some() { + self.three_way_diff = None; + self.three_way_rows = Arc::new(Vec::new()); + self.conflict_choices.clear(); + } + if self.display_mode == DiffDisplayMode::ThreeWay { + self.display_mode = DiffDisplayMode::Unified; + } + let appearance = cx.theme().appearance; + let is_dark = appearance == Appearance::Dark; + let diff = Arc::new(diff); + let cache_key = DisplayCacheKey::new(path.clone(), is_dark, source.clone(), &diff); + + self.current_appearance = appearance; + self.diff = Some(Arc::clone(&diff)); + self.file_path = Some(path.clone()); + self.source = source; + self.highlighted_row = None; + self.selected_lines = None; + self.partial_mode = false; + self.selection_anchor = None; + self.mouse_selecting = false; + self.file_menu_open = false; + + cx.emit(DiffViewerEvent::DiffChanged { + path: path.clone(), + commit_id: self.source.commit_id().map(str::to_string), + generation: self.generation, + }); + + // Check display rows cache + if let Some(cached) = self.display_cache.get(&cache_key, &diff) { + log::debug!("DiffViewer: display_cache hit for path={}", path); + self.display_rows = cached.display_rows; + self.sbs_rows = cached.sbs_rows; + self.display_longest_row_ix = cached.display_longest_row_ix; + self.loading = false; + self.sync_wrap_list_state(); + cx.notify(); + return; + } + + log::debug!("DiffViewer: display_cache miss for path={}", path); + let colors = cx.colors(); + let added_word_bg = HighlightStyle { + background_color: Some(gpui::Hsla { + a: 0.25, + ..colors.vc_added + }), + ..Default::default() + }; + let deleted_word_bg = HighlightStyle { + background_color: Some(gpui::Hsla { + a: 0.25, + ..colors.vc_deleted + }), + ..Default::default() + }; + self.loading = true; + self.display_rows = Arc::new(Vec::new()); + self.sbs_rows = Arc::new(Vec::new()); + self.display_longest_row_ix = 0; + self.sync_wrap_list_state(); + cx.notify(); + self.spawn_display_preparation( + diff, + path, + cache_key, + appearance, + added_word_bg, + deleted_word_bg, + cx, + ); + } + + pub fn clear(&mut self, cx: &mut Context) { + log::debug!("DiffViewer::clear"); + self.generation = self.generation.wrapping_add(1); + self.preparation_generation = self.preparation_generation.wrapping_add(1); + self.loading = false; + self.error = None; + self.diff = None; + self.file_path = None; + self.source = DiffSource::Worktree; + self.display_mode = DiffDisplayMode::Unified; + self.display_rows = Arc::new(Vec::new()); + self.sbs_rows = Arc::new(Vec::new()); + self.three_way_rows = Arc::new(Vec::new()); + self.display_longest_row_ix = 0; + self.three_way_diff = None; + self.conflict_choices.clear(); + self.highlighted_row = None; + self.selected_lines = None; + self.partial_mode = false; + self.selection_anchor = None; + self.mouse_selecting = false; + self.file_menu_open = false; + self.sync_wrap_list_state(); + cx.notify(); + } + + /// Set a 3-way conflict diff to display. + pub fn set_three_way_diff(&mut self, diff: ThreeWayFileDiff, cx: &mut Context) { + self.generation = self.generation.wrapping_add(1); + self.loading = true; + self.error = None; + let appearance = cx.theme().appearance; + self.current_appearance = appearance; + self.diff = None; // No standard FileDiff when showing 3-way + self.file_path = Some(diff.path.display().to_string()); + // A conflicted file lives in the working tree; the three-way renderer + // has no per-hunk staging of its own, but the source must not claim + // the content is historical. + self.source = DiffSource::Worktree; + let diff = Arc::new(diff); + self.conflict_choices = vec![None; diff.conflict_count()]; + self.three_way_diff = Some(Arc::clone(&diff)); + self.three_way_rows = Arc::new(Vec::new()); + // Switch to the three-way renderer. This must precede `sync_wrap_list_state`, + // which sizes the wrap list off `row_count()` for the active `display_mode`. + self.display_mode = DiffDisplayMode::ThreeWay; + self.highlighted_row = None; + self.selected_lines = None; + self.partial_mode = false; + self.selection_anchor = None; + self.mouse_selecting = false; + self.file_menu_open = false; + cx.emit(DiffViewerEvent::DiffChanged { + path: self.file_path.clone().unwrap_or_default(), + commit_id: None, + generation: self.generation, + }); + self.sync_wrap_list_state(); + cx.notify(); + + let choices = self.conflict_choices.clone(); + self.prepare_three_way_rows(diff, choices, cx); + } + + fn prepare_three_way_rows( + &mut self, + diff: Arc, + choices: Vec>, + cx: &mut Context, + ) { + self.preparation_generation = self.preparation_generation.wrapping_add(1); + let generation = self.preparation_generation; + cx.spawn(async move |this, cx: &mut gpui::AsyncApp| { + let rows = cx + .background_executor() + .spawn(async move { Self::compute_three_way_rows(&diff, &choices) }) + .await; + this.update(cx, |this, cx| { + if !should_apply_prepared(this.preparation_generation, generation) { + return; + } + this.three_way_rows = Arc::new(rows); + this.loading = false; + this.sync_wrap_list_state(); + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + /// Begin loading a conflict and return the generation that owns the request. + /// Callers compare it again before applying the background result. + pub fn set_conflict_loading(&mut self, path: String, cx: &mut Context) -> u64 { + self.generation = self.generation.wrapping_add(1); + self.preparation_generation = self.preparation_generation.wrapping_add(1); + self.loading = true; + self.error = None; + self.diff = None; + self.file_path = Some(path.clone()); + self.source = DiffSource::Worktree; + self.display_mode = DiffDisplayMode::ThreeWay; + self.three_way_diff = None; + self.three_way_rows = Arc::new(Vec::new()); + self.conflict_choices.clear(); + self.highlighted_row = None; + self.selected_lines = None; + self.partial_mode = false; + self.selection_anchor = None; + self.mouse_selecting = false; + self.file_menu_open = false; + self.sync_wrap_list_state(); + cx.emit(DiffViewerEvent::DiffChanged { + path, + commit_id: None, + generation: self.generation, + }); + cx.notify(); + self.generation + } + + /// Begin loading an ordinary file diff and return the generation that owns + /// the request. A later file or conflict selection invalidates this token, + /// preventing an older background result from replacing the newer view. + pub fn set_diff_loading( + &mut self, + path: String, + source: DiffSource, + cx: &mut Context, + ) -> u64 { + self.generation = self.generation.wrapping_add(1); + self.preparation_generation = self.preparation_generation.wrapping_add(1); + self.loading = true; + self.error = None; + self.diff = None; + self.file_path = Some(path.clone()); + self.source = source; + self.display_mode = DiffDisplayMode::Unified; + self.three_way_diff = None; + self.three_way_rows = Arc::new(Vec::new()); + self.conflict_choices.clear(); + self.display_rows = Arc::new(Vec::new()); + self.sbs_rows = Arc::new(Vec::new()); + self.highlighted_row = None; + self.selected_lines = None; + self.partial_mode = false; + self.selection_anchor = None; + self.mouse_selecting = false; + self.file_menu_open = false; + self.sync_wrap_list_state(); + cx.emit(DiffViewerEvent::DiffChanged { + path, + commit_id: self.source.commit_id().map(str::to_string), + generation: self.generation, + }); + cx.notify(); + self.generation + } + + /// Claim ownership of a diff request whose file path is not known until + /// its repository query completes (for example a commit or stash diff). + /// Every later selection invalidates the returned generation. + pub fn begin_diff_request(&mut self, cx: &mut Context) -> u64 { + self.generation = self.generation.wrapping_add(1); + self.preparation_generation = self.preparation_generation.wrapping_add(1); + self.loading = true; + self.error = None; + self.diff = None; + self.file_path = None; + self.source = DiffSource::Worktree; + self.display_mode = DiffDisplayMode::Unified; + self.display_rows = Arc::new(Vec::new()); + self.sbs_rows = Arc::new(Vec::new()); + self.three_way_diff = None; + self.three_way_rows = Arc::new(Vec::new()); + self.conflict_choices.clear(); + self.highlighted_row = None; + self.selected_lines = None; + self.partial_mode = false; + self.selection_anchor = None; + self.mouse_selecting = false; + self.file_menu_open = false; + self.sync_wrap_list_state(); + cx.notify(); + self.generation + } + + /// Mark that a diff fetch is in progress so the viewer shows a loading + /// spinner instead of stale content or the empty placeholder. + pub fn set_loading(&mut self, cx: &mut Context) { + self.preparation_generation = self.preparation_generation.wrapping_add(1); + self.loading = true; + self.error = None; + cx.notify(); + } + + /// Surface a diff-fetch failure in the viewer instead of silently leaving + /// the previous file's content (or the generic placeholder) on screen. + pub fn set_error(&mut self, message: impl Into, cx: &mut Context) { + self.preparation_generation = self.preparation_generation.wrapping_add(1); + self.loading = false; + self.error = Some(message.into()); + cx.notify(); + } + + fn range_from_anchor(anchor: usize, row_ix: usize) -> Range { + anchor.min(row_ix)..anchor.max(row_ix) + 1 + } + + fn handle_line_click(&mut self, row_ix: usize, shift: bool, cx: &mut Context) { + if shift { + if let Some(anchor) = self.selection_anchor { + self.selected_lines = Some(Self::range_from_anchor(anchor, row_ix)); + } else { + self.selection_anchor = Some(row_ix); + self.selected_lines = Some(row_ix..row_ix + 1); + } + } else { + self.selection_anchor = Some(row_ix); + self.selected_lines = Some(row_ix..row_ix + 1); + } + cx.notify(); + } + + fn begin_mouse_selection(&mut self, row_ix: usize, shift: bool, cx: &mut Context) { + self.mouse_selecting = true; + self.handle_line_click(row_ix, shift, cx); + } + + fn extend_mouse_selection(&mut self, row_ix: usize, cx: &mut Context) { + if !self.mouse_selecting { + return; + } + let anchor = self.selection_anchor.unwrap_or(row_ix); + self.selection_anchor = Some(anchor); + let range = Self::range_from_anchor(anchor, row_ix); + if self.selected_lines.as_ref() != Some(&range) { + self.selected_lines = Some(range); + cx.notify(); + } + } + + fn end_mouse_selection( + &mut self, + _event: &MouseUpEvent, + _window: &mut Window, + cx: &mut Context, + ) { + if self.mouse_selecting { + self.mouse_selecting = false; + cx.notify(); + } + } + + fn copy_selected_lines(&self, cx: &mut Context) { + let range = match &self.selected_lines { + Some(r) => r.clone(), + None => return, + }; + + let mut text = String::new(); + match self.display_mode { + DiffDisplayMode::Unified => { + for i in range { + if i < self.display_rows.len() { + match &self.display_rows[i] { + DisplayRow::HunkHeader { header, .. } => { + text.push_str(header); + text.push('\n'); + } + DisplayRow::Line { styled, .. } => { + text.push_str(styled.text.as_ref()); + text.push('\n'); + } + } + } + } + } + DiffDisplayMode::SideBySide => { + for i in range { + if i < self.sbs_rows.len() { + match &self.sbs_rows[i] { + SideBySideRow::HunkHeader { header, .. } => { + text.push_str(header); + text.push('\n'); + } + SideBySideRow::Pair { + left_styled, + left_kind, + right_styled, + right_kind, + .. + } => { + if *left_kind != SideBySideLineKind::Empty { + text.push_str(left_styled.text.as_ref()); + } + if *left_kind != SideBySideLineKind::Empty + && *right_kind != SideBySideLineKind::Empty + && *left_kind != SideBySideLineKind::Context + { + text.push('\t'); + } + if *right_kind != SideBySideLineKind::Empty + && *right_kind != SideBySideLineKind::Context + { + text.push_str(right_styled.text.as_ref()); + } + text.push('\n'); + } + } + } + } + } + DiffDisplayMode::ThreeWay => { + for i in range { + if i < self.three_way_rows.len() { + match &self.three_way_rows[i] { + ThreeWayRow::HunkHeader { header, .. } => { + text.push_str(header); + text.push('\n'); + } + ThreeWayRow::Triple { + left_styled, + mid_styled, + right_styled, + .. + } => { + text.push_str(left_styled.text.as_ref()); + text.push('\n'); + text.push_str(mid_styled.text.as_ref()); + text.push('\n'); + text.push_str(right_styled.text.as_ref()); + text.push('\n'); + } + } + } + } + } + } + + if !text.is_empty() { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } + } + + fn row_count(&self) -> usize { + match self.display_mode { + DiffDisplayMode::Unified => self.display_rows.len(), + DiffDisplayMode::SideBySide => self.sbs_rows.len(), + DiffDisplayMode::ThreeWay => self.three_way_rows.len(), + } + } + + /// Resize the virtualized wrap-mode list so its SumTree matches the current + /// row set. Call this after any mutation that changes the number or + /// identity of rows for the active `display_mode`. + /// + /// `ListState::reset` discards `logical_scroll_top`, which would snap the wrap + /// view back to the top on every refresh (e.g. after staging a hunk or a theme + /// re-highlight). The no-wrap path keeps its position because its + /// `UniformListScrollHandle` is untouched here; to stay consistent we capture + /// the logical scroll offset and restore it (clamped to the new row count) so a + /// content refresh preserves the user's place. Callers that want a specific + /// position (`toggle_display_mode`, `scroll_to_line`) set it afterward. + fn sync_wrap_list_state(&self) { + let row_count = self.row_count(); + let prev = self.wrap_list_state.logical_scroll_top(); + self.wrap_list_state.reset(row_count); + if row_count == 0 { + return; + } + let mut restored = prev; + if restored.item_ix >= row_count { + restored.item_ix = row_count - 1; + restored.offset_in_item = px(0.); + } + self.wrap_list_state.scroll_to(restored); + } + + /// Whether the active layout renders through the wrap-mode `gpui::list` + /// (backed by `wrap_list_state`) rather than the uniform list. Side-by-side and + /// three-way always wrap so their columns stay aligned, regardless of the + /// `diff_wrap_lines` setting; unified follows the setting. + fn wrap_active(&self, cx: &App) -> bool { + cx.global::() + .settings() + .diff_wrap_lines + || matches!( + self.display_mode, + DiffDisplayMode::SideBySide | DiffDisplayMode::ThreeWay + ) + } + + /// Reveal row `ix` in whichever scrollable container is currently active. + /// The uniform list scroll handle is used for no-wrap rendering, while + /// wrap mode lives inside a `gpui::list` backed by `wrap_list_state`. + fn scroll_row_into_view(&self, ix: usize, cx: &App) { + if self.wrap_active(cx) { + self.wrap_list_state.scroll_to_reveal_item(ix); + } else { + self.scroll_handle.scroll_to_item(ix, ScrollStrategy::Top); + } + } + + /// Moves the diff cursor down one row, scrolling it into view. + /// + /// The workspace drives this and the methods below from the `diff::*` + /// actions: this crate sits below `rgitui_workspace` in the dependency graph + /// and so cannot name them. + pub fn select_next_row(&mut self, cx: &mut Context) { + let row_count = self.row_count(); + if row_count == 0 { + return; + } + let next = match self.highlighted_row { + Some(i) if i + 1 < row_count => i + 1, + None => 0, + Some(i) => i, + }; + self.highlight_row(next, cx); + } + + /// Moves the diff cursor up one row, scrolling it into view. + pub fn select_prev_row(&mut self, cx: &mut Context) { + if self.row_count() == 0 { + return; + } + let next = match self.highlighted_row { + Some(i) if i > 0 => i - 1, + Some(i) => i, + None => 0, + }; + self.highlight_row(next, cx); + } + + /// Moves the diff cursor to the first row. + pub fn select_first_row(&mut self, cx: &mut Context) { + if self.row_count() > 0 { + self.highlight_row(0, cx); + } + } + + /// Moves the diff cursor to the last row. + pub fn select_last_row(&mut self, cx: &mut Context) { + let row_count = self.row_count(); + if row_count > 0 { + self.highlight_row(row_count - 1, cx); + } + } + + fn highlight_row(&mut self, row: usize, cx: &mut Context) { + self.highlighted_row = Some(row); + self.scroll_row_into_view(row, cx); + cx.notify(); + } + + /// Whether the row at `index` in the current display mode is a hunk header. + fn is_hunk_header(&self, index: usize) -> bool { + match self.display_mode { + DiffDisplayMode::Unified => { + matches!(self.display_rows[index], DisplayRow::HunkHeader { .. }) + } + DiffDisplayMode::SideBySide => { + matches!(self.sbs_rows[index], SideBySideRow::HunkHeader { .. }) + } + DiffDisplayMode::ThreeWay => { + matches!(self.three_way_rows[index], ThreeWayRow::HunkHeader { .. }) + } + } + } + + /// Moves the cursor to the next hunk header, wrapping at the end. + pub fn select_next_hunk(&mut self, cx: &mut Context) { + let row_count = self.row_count(); + if row_count == 0 { + return; + } + let start = self.highlighted_row.map(|r| r + 1).unwrap_or(0); + let next = (start..row_count) + .find(|&i| self.is_hunk_header(i)) + .or_else(|| (0..start).find(|&i| self.is_hunk_header(i))); + if let Some(pos) = next { + self.highlight_row(pos, cx); + } + } + + /// Moves the cursor to the previous hunk header, wrapping at the start. + pub fn select_prev_hunk(&mut self, cx: &mut Context) { + let row_count = self.row_count(); + if row_count == 0 { + return; + } + let end = self.highlighted_row.unwrap_or(row_count); + let prev = (0..end) + .rev() + .find(|&i| self.is_hunk_header(i)) + .or_else(|| (end..row_count).rev().find(|&i| self.is_hunk_header(i))); + if let Some(pos) = prev { + self.highlight_row(pos, cx); + } + } + + /// Toggles line-level selection, clearing any selection when leaving it. + pub fn toggle_partial_mode(&mut self, cx: &mut Context) { + // Partial mode exists to scope the source's line-level operation — + // staging for the working tree, apply/revert for anything else. The + // three-way conflict view has no line-level operation, so there is + // nothing for a line selection to act on. + if self.display_mode == DiffDisplayMode::ThreeWay { + return; + } + self.partial_mode = !self.partial_mode; + if !self.partial_mode { + self.selected_lines = None; + self.selection_anchor = None; + } + cx.notify(); + } + + /// Selects every row in the diff. + pub fn select_all_lines(&mut self, cx: &mut Context) { + let row_count = self.row_count(); + if row_count == 0 { + return; + } + self.selection_anchor = Some(0); + self.selected_lines = Some(0..row_count); + cx.notify(); + } + + /// Copies the selected diff lines to the clipboard. + pub fn copy_selection(&self, cx: &mut Context) { + self.copy_selected_lines(cx); + } + + /// Requests staging of the hunks — or, in partial mode, the individual + /// lines — under the current selection, falling back to the cursor's hunk. + pub fn stage_selection(&mut self, cx: &mut Context) { + // Only the working tree can be staged; committed and stashed content is + // historical and has nothing to stage. + if !self.source.offers(DiffOperation::Stage) { + return; + } + if self.partial_mode { + // Line-level staging: emit the selected change lines (additions and + // deletions). Each pair carries its old/new line number; the git + // layer matches additions on the new side and deletions on the old + // side, so deletions must flow through unfiltered. + let line_pairs = self.change_lines_under_selection(); + if !line_pairs.is_empty() { + cx.emit(DiffViewerEvent::LineStageRequested(line_pairs)); + } + // TODO(audit): BUG-15 surface a "no stageable lines" toast when a + // partial selection yields nothing — needs a new DiffViewerEvent + // variant + a handler arm in rgitui_workspace events.rs (the toast + // system lives there), which can't be added from this crate alone. + return; + } + for idx in self.hunks_to_act_on() { + cx.emit(DiffViewerEvent::HunkStageRequested(idx)); + } + } + + /// Requests unstaging of the hunks — or lines — under the current selection. + pub fn unstage_selection(&mut self, cx: &mut Context) { + // Only the index can be unstaged. + if !self.source.offers(DiffOperation::Unstage) { + return; + } + if self.partial_mode { + // Deletions must flow through so the git layer can match them on the + // old side; filtering to additions would make unstaging a pure + // deletion a silent no-op. + let line_pairs = self.change_lines_under_selection(); + if !line_pairs.is_empty() { + cx.emit(DiffViewerEvent::LineUnstageRequested(line_pairs)); + } + return; + } + for idx in self.hunks_to_act_on() { + cx.emit(DiffViewerEvent::HunkUnstageRequested(idx)); + } + } + + /// Requests staging of just the hunk under the cursor. + pub fn stage_current_hunk(&mut self, cx: &mut Context) { + if !self.source.offers(DiffOperation::Stage) { + return; + } + if let Some(idx) = self.current_hunk_index() { + cx.emit(DiffViewerEvent::HunkStageRequested(idx)); + } + } + + /// Requests unstaging of just the hunk under the cursor. + pub fn unstage_current_hunk(&mut self, cx: &mut Context) { + if !self.source.offers(DiffOperation::Unstage) { + return; + } + if let Some(idx) = self.current_hunk_index() { + cx.emit(DiffViewerEvent::HunkUnstageRequested(idx)); + } + } + + /// Requests that the displayed source's version of the hunks — or, in partial + /// mode, the lines — under the current selection be written into the working + /// tree. + /// + /// The counterpart of [`Self::stage_selection`] for content that has no + /// staging route: a commit, a stash entry, or a comparison of two revisions. + pub fn apply_selection(&mut self, cx: &mut Context) { + self.request_worktree_patch(DiffOperation::Apply, cx); + } + + /// Requests that the displayed source's change be taken back out of the + /// working tree, over the same granularity [`Self::apply_selection`] uses. + pub fn revert_selection(&mut self, cx: &mut Context) { + self.request_worktree_patch(DiffOperation::Revert, cx); + } + + /// Requests that the whole file be brought to the displayed source's version, + /// ignoring the current selection. + pub fn apply_file(&mut self, cx: &mut Context) { + self.request_whole_file_patch(DiffOperation::Apply, cx); + } + + /// Requests that the displayed source's change be taken out of the whole + /// file, ignoring the current selection. + pub fn revert_file(&mut self, cx: &mut Context) { + self.request_whole_file_patch(DiffOperation::Revert, cx); + } + + /// The change lines under the selection, or the cursor hunk's if there is none. + fn change_lines_under_selection(&self) -> Vec<(Option, Option)> { + match &self.selected_lines { + Some(selection) => self + .lines_under_selection(selection.clone()) + .into_iter() + .filter(Self::is_change_line) + .collect(), + None => self.current_hunk_changes(), + } + } + + /// The hunks under the selection, or the cursor's hunk if there is none. + fn hunks_to_act_on(&self) -> Vec { + match &self.selected_lines { + Some(selection) => self.hunks_under_selection(selection.clone()), + None => self + .current_hunk_index() + .map(|i| vec![i]) + .unwrap_or_default(), + } + } + + /// The operations the file-level menu offers: the ones that act on the whole + /// file rather than on a hunk or a line selection. + /// + /// Whole-file staging is reached from the sidebar's Staged/Unstaged lists, so + /// the menu carries apply/revert only. + fn file_menu_operations(&self) -> Vec { + if self.display_mode == DiffDisplayMode::ThreeWay { + return Vec::new(); + } + self.source + .operations() + .iter() + .copied() + .filter(|operation| operation.writes_files()) + .collect() + } + + /// Ask the workspace to apply or revert `operation` over the whole file. + fn request_whole_file_patch(&mut self, operation: DiffOperation, cx: &mut Context) { + self.file_menu_open = false; + if !self.source.offers(operation) { + cx.notify(); + return; + } + cx.emit(DiffViewerEvent::WorktreePatchRequested { + operation, + scope: WorktreePatchScope::File, + }); + cx.notify(); + } + + /// Ask the workspace to apply or revert `operation` over the granularity the + /// current selection implies. Silently does nothing when the displayed + /// content does not support the operation, so the key is inert rather than + /// wrong on a working-tree diff. + fn request_worktree_patch(&mut self, operation: DiffOperation, cx: &mut Context) { + if !self.source.offers(operation) { + return; + } + if let Some(scope) = self.selected_patch_scope() { + cx.emit(DiffViewerEvent::WorktreePatchRequested { operation, scope }); + } + } + + /// The scope an apply/revert should cover: line-level when the user has turned + /// on partial mode, otherwise the hunk under the cursor, or the hunks the row + /// selection spans. + /// + /// Several selected hunks collapse into one `Lines` scope. These operations + /// read the file off disk and write it back, so two requests in flight over + /// the same file would race; one request covering every selected line cannot. + fn selected_patch_scope(&self) -> Option { + if self.partial_mode { + let lines = match &self.selected_lines { + Some(selection) => self + .lines_under_selection(selection.clone()) + .into_iter() + .filter(Self::is_change_line) + .collect(), + None => self.current_hunk_changes(), + }; + if lines.is_empty() { + return None; + } + return Some(WorktreePatchScope::Lines(lines)); + } + + let hunks = match &self.selected_lines { + Some(selection) => self.hunks_under_selection(selection.clone()), + None => self + .current_hunk_index() + .map(|i| vec![i]) + .unwrap_or_default(), + }; + match hunks.as_slice() { + [] => None, + [only] => Some(WorktreePatchScope::Hunk(*only)), + several => { + let lines = self.changes_in_hunks(several); + if lines.is_empty() { + None + } else { + Some(WorktreePatchScope::Lines(lines)) + } + } + } + } + + /// Returns the hunk index at or before the currently highlighted row. + /// If the highlighted row itself is a hunk header, returns its index. + /// Otherwise searches backwards to find the nearest preceding hunk. + fn current_hunk_index(&self) -> Option { + let pos = self.highlighted_row?; + match self.display_mode { + DiffDisplayMode::Unified => { + // Check current position first. `.get` guards against a stale + // `highlighted_row` left over from a different mode's longer row set. + if let Some(DisplayRow::HunkHeader { hunk_index, .. }) = self.display_rows.get(pos) + { + return Some(*hunk_index); + } + // Search backwards for nearest hunk header + (0..pos.min(self.display_rows.len())).rev().find_map(|i| { + if let DisplayRow::HunkHeader { hunk_index, .. } = &self.display_rows[i] { + Some(*hunk_index) + } else { + None + } + }) + } + DiffDisplayMode::SideBySide => { + if let Some(SideBySideRow::HunkHeader { hunk_index, .. }) = self.sbs_rows.get(pos) { + return Some(*hunk_index); + } + (0..pos.min(self.sbs_rows.len())).rev().find_map(|i| { + if let SideBySideRow::HunkHeader { hunk_index, .. } = &self.sbs_rows[i] { + Some(*hunk_index) + } else { + None + } + }) + } + DiffDisplayMode::ThreeWay => { + // Check current position first + if let Some(ThreeWayRow::HunkHeader { .. }) = self.three_way_rows.get(pos) { + return Some(pos); // In 3-way mode, row index = hunk index + } + (0..pos.min(self.three_way_rows.len())) + .rev() + .find(|&i| matches!(self.three_way_rows[i], ThreeWayRow::HunkHeader { .. })) + } + } + } + + /// Returns all unique hunk indices that have at least one row within `range`. + /// Works by tracking the current hunk as we iterate through display rows, + /// emitting the hunk index each time we encounter a hunk header within the range. + fn hunks_under_selection(&self, range: Range) -> Vec { + let mut hunks = Vec::new(); + let mut seen = HashSet::new(); + let start = range.start; + let end = range.end.min(self.row_count()); + + match &self.display_mode { + DiffDisplayMode::Unified => { + let rows = &self.display_rows; + let mut current_hunk: Option = None; + for i in start..end { + if let DisplayRow::HunkHeader { hunk_index, .. } = &rows[i] { + current_hunk = Some(*hunk_index); + } else if let Some(h) = current_hunk { + if seen.insert(h) { + hunks.push(h); + } + } + } + } + DiffDisplayMode::SideBySide => { + let rows = &self.sbs_rows; + let mut current_hunk: Option = None; + for i in start..end { + if let SideBySideRow::HunkHeader { hunk_index, .. } = &rows[i] { + current_hunk = Some(*hunk_index); + } else if let Some(h) = current_hunk { + if seen.insert(h) { + hunks.push(h); + } + } + } + } + DiffDisplayMode::ThreeWay => { + let rows = &self.three_way_rows; + let mut current_hunk: Option = None; + for i in start..end { + if let ThreeWayRow::HunkHeader { .. } = &rows[i] { + current_hunk = Some(i); + } else if let Some(h) = current_hunk { + if seen.insert(h) { + hunks.push(h); + } + } + } + } + } + hunks + } + + /// Returns the (old_file_line, new_file_line) pairs for all rows in the given range. + /// Used for line-level partial staging. + fn lines_under_selection(&self, range: Range) -> Vec<(Option, Option)> { + let start = range.start; + let end = range.end.min(self.row_count()); + let mut lines = Vec::new(); + + match &self.display_mode { + DiffDisplayMode::Unified => { + let rows = &self.display_rows; + for i in start..end { + if let DisplayRow::Line { + old_num, new_num, .. + } = &rows[i] + { + lines.push((*old_num, *new_num)); + } + } + } + DiffDisplayMode::SideBySide => { + let rows = &self.sbs_rows; + for i in start..end { + if let SideBySideRow::Pair { + left_num, + left_kind, + right_num, + right_kind, + .. + } = &rows[i] + { + // A pair can carry a deletion on the left and an addition on + // the right (a modification row). Split it into its constituent + // change lines — keyed on the side's kind — so each side is + // staged against the correct (old / new) target set. Context + // pairs contribute nothing. + if *left_kind == SideBySideLineKind::Deletion { + lines.push((*left_num, None)); + } + if *right_kind == SideBySideLineKind::Addition { + lines.push((None, *right_num)); + } + } + } + } + DiffDisplayMode::ThreeWay => { + // ThreeWay doesn't support partial line staging yet + } + } + lines + } + + /// True for an addition `(None, Some)` or deletion `(Some, None)` line; false + /// for context `(Some, Some)` and empty `(None, None)` rows. Used to keep + /// non-change rows out of a partial-staging selection. + fn is_change_line(pair: &(Option, Option)) -> bool { + pair.0.is_some() ^ pair.1.is_some() + } + + /// Returns the change lines (additions and deletions) in the current hunk as + /// (old_num, new_num) pairs. Deletions are emitted as (Some, None) and + /// additions as (None, Some) so the git layer can stage either side. + fn current_hunk_changes(&self) -> Vec<(Option, Option)> { + match self.current_hunk_index() { + Some(index) => self.changes_in_hunks(&[index]), + None => Vec::new(), + } + } + + /// The change lines of every hunk in `hunks`, as (old_num, new_num) pairs in + /// file order. Used to express a multi-hunk selection as a single line-scoped + /// request. + fn changes_in_hunks(&self, hunks: &[usize]) -> Vec<(Option, Option)> { + // Line numbers only come off the unified rows; the side-by-side rows + // split a modification across two columns. + if self.display_mode == DiffDisplayMode::ThreeWay { + return Vec::new(); + } + let wanted: HashSet = hunks.iter().copied().collect(); + let mut lines = Vec::new(); + let mut current_hunk: Option = None; + for row in self.display_rows.iter() { + match row { + DisplayRow::HunkHeader { hunk_index, .. } => current_hunk = Some(*hunk_index), + DisplayRow::Line { + old_num, + new_num, + kind, + .. + } => { + let in_wanted_hunk = current_hunk.is_some_and(|hunk| wanted.contains(&hunk)); + if in_wanted_hunk + && matches!(kind, DisplayLineKind::Addition | DisplayLineKind::Deletion) + { + lines.push((*old_num, *new_num)); + } + } + } + } + lines + } + + pub fn toggle_display_mode(&mut self, cx: &mut Context) { + // A three-way conflict has no unified/side-by-side representation, so the + // toggle is inert while one is shown — flipping to Unified would render an + // empty diff. + if self.three_way_diff.is_some() { + return; + } + + // Capture the current top item so we can restore scroll position after the + // switch. No-wrap reads the uniform list's base handle; wrap mode lives in a + // `gpui::list` whose `logical_scroll_top` is the authoritative position (the + // uniform handle is never rendered in wrap mode and stays at 0). + let top_item = if self.wrap_active(cx) { + self.wrap_list_state.logical_scroll_top().item_ix + } else { + self.scroll_handle.0.borrow().base_handle.top_item() + }; + self.pending_scroll_top = Some(top_item); + + self.display_mode = match self.display_mode { + DiffDisplayMode::Unified => DiffDisplayMode::SideBySide, + DiffDisplayMode::SideBySide => DiffDisplayMode::Unified, + DiffDisplayMode::ThreeWay => DiffDisplayMode::Unified, + }; + self.sync_wrap_list_state(); + + // The new mode's row set differs in length (a unified modification pair maps + // to one side-by-side row), so clamp any highlighted row back into bounds to + // keep later index lookups from panicking. + let row_count = self.row_count(); + self.highlighted_row = match self.highlighted_row { + Some(_) if row_count == 0 => None, + Some(r) => Some(r.min(row_count - 1)), + None => None, + }; + + cx.notify(); + } + + pub fn diff(&self) -> Option<&FileDiff> { + self.diff.as_deref() + } + + pub fn file_path(&self) -> Option<&str> { + self.file_path.as_deref() + } + + /// Provenance of the displayed content. Callers use this to decide whether + /// staging is meaningful and to recover the backing commit/stash OID. + pub fn source(&self) -> &DiffSource { + &self.source + } + + /// `None` for working-tree and index diffs; the hex OID for commit and + /// stash diffs. + pub fn commit_id(&self) -> Option<&str> { + self.source.commit_id() + } + + /// Monotonic counter bumped whenever the displayed content changes + /// A workspace diff refresh captures this before computing and re-checks it + /// before applying, so stale repository results cannot clobber a newer diff. + pub fn generation(&self) -> u64 { + self.generation + } + + pub fn has_three_way_diff(&self) -> bool { + self.three_way_diff.is_some() + } + + /// True from the moment a conflict is selected until its three-way view is + /// explicitly replaced or cleared. Unlike `has_three_way_diff`, this also + /// covers the background-load window before the conflict model arrives. + pub fn is_conflict_view_active(&self) -> bool { + self.display_mode == DiffDisplayMode::ThreeWay + } + + pub fn unresolved_conflict_count(&self) -> usize { + self.conflict_choices + .iter() + .filter(|choice| choice.is_none()) + .count() + } + + fn selected_conflict_index(&self) -> Option { + let row_index = self.highlighted_row?; + match self.three_way_rows.get(row_index) { + Some(ThreeWayRow::HunkHeader { conflict_index, .. }) => Some(*conflict_index), + Some(ThreeWayRow::Triple { .. }) => self.three_way_rows[..row_index] + .iter() + .rev() + .find_map(|row| match row { + ThreeWayRow::HunkHeader { conflict_index, .. } => Some(*conflict_index), + ThreeWayRow::Triple { .. } => None, + }), + None => None, + } + } + + fn target_conflict_index(&self) -> Option { + self.selected_conflict_index() + .or_else(|| self.conflict_choices.iter().position(Option::is_none)) + .or_else(|| (!self.conflict_choices.is_empty()).then_some(0)) + } + + fn set_conflict_choice( + &mut self, + conflict_index: usize, + choice: Option, + cx: &mut Context, + ) { + let Some(slot) = self.conflict_choices.get_mut(conflict_index) else { + return; + }; + *slot = choice; + self.rebuild_three_way_rows(cx); + } + + fn rebuild_three_way_rows(&mut self, cx: &mut Context) { + let Some(diff) = self.three_way_diff.as_ref().map(Arc::clone) else { + return; + }; + // Styling and laying out a large conflict is expensive. Keep the last + // complete rows visible while this choice is prepared off the UI + // thread; the generation guard coalesces rapid choices and prevents an + // older preparation from replacing the newest state. + let choices = self.conflict_choices.clone(); + self.prepare_three_way_rows(diff, choices, cx); + cx.notify(); + } + + pub fn use_current_for_conflict(&mut self, cx: &mut Context) { + if let Some(index) = self.target_conflict_index() { + self.set_conflict_choice(index, Some(ConflictChoice::Ours), cx); + } + } + + pub fn use_incoming_for_conflict(&mut self, cx: &mut Context) { + if let Some(index) = self.target_conflict_index() { + self.set_conflict_choice(index, Some(ConflictChoice::Theirs), cx); + } + } + + pub fn use_both_for_conflict(&mut self, incoming_first: bool, cx: &mut Context) { + let Some(diff) = self.three_way_diff.as_ref() else { + return; + }; + if !diff.supports_text_resolution() { + return; + } + if let Some(index) = self.target_conflict_index() { + let choice = if incoming_first { + ConflictChoice::BothTheirsFirst + } else { + ConflictChoice::BothOursFirst + }; + self.set_conflict_choice(index, Some(choice), cx); + } + } + + pub fn reset_conflict_choice(&mut self, cx: &mut Context) { + if let Some(index) = self.target_conflict_index() { + self.set_conflict_choice(index, None, cx); + } + } + + pub fn use_current_for_all_conflicts(&mut self, cx: &mut Context) { + for choice in &mut self.conflict_choices { + choice.get_or_insert(ConflictChoice::Ours); + } + self.rebuild_three_way_rows(cx); + } + + pub fn use_incoming_for_all_conflicts(&mut self, cx: &mut Context) { + for choice in &mut self.conflict_choices { + choice.get_or_insert(ConflictChoice::Theirs); + } + self.rebuild_three_way_rows(cx); + } + + pub fn save_conflict_result(&mut self, cx: &mut Context) { + let Some(diff) = self.three_way_diff.as_ref() else { + return; + }; + if self.unresolved_conflict_count() > 0 { + return; + } + let path = diff.path.display().to_string(); + if !diff.supports_text_resolution() { + match self.conflict_choices.first().copied().flatten() { + Some(ConflictChoice::Ours) => cx.emit( + DiffViewerEvent::ConflictResolutionRequested(ConflictResolution::UseOurs { + path, + snapshot: diff.snapshot.clone(), + }), + ), + Some(ConflictChoice::Theirs) => cx.emit( + DiffViewerEvent::ConflictResolutionRequested(ConflictResolution::UseTheirs { + path, + snapshot: diff.snapshot.clone(), + }), + ), + _ => {} + } + return; + } + + let Some(content) = assemble_text_resolution(diff, &self.conflict_choices) else { + return; + }; + cx.emit(DiffViewerEvent::ConflictResolutionRequested( + ConflictResolution::SaveMerged { + path, + content, + mode: diff.result_mode, + snapshot: diff.snapshot.clone(), + }, + )); + } + + pub fn stage_conflict_working_copy(&mut self, cx: &mut Context) { + if let Some(diff) = self.three_way_diff.as_ref() { + cx.emit(DiffViewerEvent::ConflictResolutionRequested( + ConflictResolution::StageWorkingTree { + path: diff.path.display().to_string(), + snapshot: diff.snapshot.clone(), + }, + )); + } + } + + pub fn open_conflict_in_editor(&mut self, cx: &mut Context) { + if let Some(path) = self + .three_way_diff + .as_ref() + .map(|diff| diff.path.display().to_string()) + .or_else(|| self.file_path.clone()) + { + cx.emit(DiffViewerEvent::ConflictResolutionRequested( + ConflictResolution::OpenInEditor { path }, + )); + } + } + + /// True iff `set_diff` with these inputs would produce identical content to + /// what's already shown. Lets callers skip a refresh that would only churn + /// state (cleared selection / scroll reset) without visible benefit. + pub fn matches_current_diff(&self, path: &str, source: &DiffSource, diff: &FileDiff) -> bool { + if self.has_three_way_diff() { + return false; + } + let Some(existing_path) = self.file_path.as_deref() else { + return false; + }; + if existing_path != path || &self.source != source { + return false; + } + let Some(existing) = self.diff.as_deref() else { + return false; + }; + file_diffs_render_equal(existing, diff) + } + + /// Scroll to and highlight the given line number (1-indexed) in the new/right side + /// of the diff. Used by global search to navigate from grep results to the + /// corresponding location in the diff viewer. Returns true if the line was found. + pub fn scroll_to_line(&mut self, line_number: usize, cx: &Context) -> bool { + // A three-way conflict has no `display_rows` to navigate; bail before the + // mode-switch logic below acts on a stale unified row set. + if self.three_way_diff.is_some() || self.display_rows.is_empty() { + return false; + } + let target = self.display_rows.iter().position(|row| { + matches!( + row, + DisplayRow::Line { + new_num: Some(n), + .. + } if *n == line_number + ) + }); + if let Some(idx) = target { + // The target index addresses `display_rows`, so `highlighted_row` is only + // valid against the unified row set. Switch to unified first (search + // navigation is a unified-view concept) so the highlight and scroll land + // on the right row regardless of the prior mode. + if self.display_mode != DiffDisplayMode::Unified { + self.display_mode = DiffDisplayMode::Unified; + self.sync_wrap_list_state(); + } + self.highlighted_row = Some(idx); + self.scroll_row_into_view(idx, cx); + return true; + } + false + } + + fn count_changes(rows: &[DisplayRow]) -> (usize, usize) { + let mut additions = 0usize; + let mut deletions = 0usize; + for row in rows { + if let DisplayRow::Line { kind, .. } = row { + match kind { + DisplayLineKind::Addition => additions += 1, + DisplayLineKind::Deletion => deletions += 1, + DisplayLineKind::Context => {} + } + } + } + (additions, deletions) + } + + fn icon_for_path(path: &str) -> IconName { + if let Some(ext) = path.rsplit('.').next() { + match ext { + "rs" | "py" | "js" | "ts" | "tsx" | "jsx" | "mjs" | "cjs" | "mts" | "cts" | "c" + | "cpp" | "h" | "hpp" | "go" | "java" | "rb" | "sh" | "lua" | "zig" | "swift" + | "kt" | "kts" | "cs" | "fs" | "ex" | "exs" | "hs" | "ml" | "elm" | "dart" + | "r" | "scala" | "clj" | "erl" | "v" | "odin" | "proto" | "sql" | "vue" + | "svelte" | "astro" | "php" | "pl" | "d" => IconName::File, + "toml" | "yaml" | "yml" | "json" | "jsonc" | "json5" | "xml" | "ini" | "conf" + | "cfg" | "env" | "hcl" | "tf" | "graphql" | "gql" | "prisma" => IconName::Settings, + "css" | "scss" | "sass" | "less" | "styl" | "pcss" => IconName::File, + "html" | "htm" | "hbs" | "ejs" | "njk" => IconName::File, + "md" | "txt" | "rst" | "org" => IconName::File, + "lock" => IconName::Pin, + _ => IconName::File, + } + } else { + IconName::File + } + } + + /// Extract the function/context name from a hunk header. + /// Hunk headers look like `@@ -10,5 +10,7 @@ fn some_function(...)` + /// This returns the part after the closing `@@`. + fn extract_context_name(header: &str) -> String { + if let Some(pos) = header.find("@@") { + let after_first = &header[pos + 2..]; + if let Some(pos2) = after_first.find("@@") { + let context = after_first[pos2 + 2..].trim(); + if !context.is_empty() { + return context.to_string(); + } + } + } + String::new() + } + + /// Extract the line range portion from a hunk header (the `@@ -x,y +x,y @@` part). + fn extract_line_range(header: &str) -> String { + if let Some(start) = header.find("@@") { + let after_first = &header[start..]; + if let Some(end) = after_first[2..].find("@@") { + return after_first[..end + 4].to_string(); + } + } + header.to_string() + } + + fn syntax_assets() -> &'static SyntaxAssets { + static ASSETS: OnceLock = OnceLock::new(); + ASSETS.get_or_init(|| { + log::info!("DiffViewer: initializing SyntaxAssets (one-time cost)"); + SyntaxAssets { + syntax_set: SyntaxSet::load_defaults_newlines(), + theme_set: ThemeSet::load_defaults(), + } + }) + } + + fn syntax_theme(appearance: Appearance) -> &'static Theme { + let assets = Self::syntax_assets(); + let preferred_name = match appearance { + Appearance::Dark => "base16-ocean.dark", + Appearance::Light => "base16-ocean.light", + }; + assets + .theme_set + .themes + .get(preferred_name) + .or_else(|| { + // Graceful fallback: for light pick any non-dark theme, else any + if appearance == Appearance::Light { + assets + .theme_set + .themes + .iter() + .find(|(k, _)| k.contains("light") || k.contains("Light")) + .map(|(_, v)| v) + } else { + None + } + }) + .or_else(|| assets.theme_set.themes.values().next()) + .expect("syntect theme set should contain at least one theme") + } + + fn syntax_for_path(path: &str) -> Option<&'static SyntaxReference> { + let assets = Self::syntax_assets(); + assets + .syntax_set + .find_syntax_for_file(Path::new(path)) + .ok() + .flatten() + .or_else(|| { + Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| assets.syntax_set.find_syntax_by_token(name)) + }) + .or_else(|| { + // Fallback: map common extensions that syntect's defaults don't cover + // to the closest available syntax grammar. + let ext = Path::new(path).extension()?.to_str()?; + let fallback_name = match ext { + // TypeScript / JSX / TSX → JavaScript + "ts" | "tsx" | "jsx" | "mjs" | "cjs" | "mts" | "cts" => "JavaScript", + // Markup variants → HTML + "vue" | "svelte" | "astro" | "hbs" | "ejs" | "njk" | "liquid" => "HTML", + // Style variants → CSS + "scss" | "sass" | "less" | "styl" | "pcss" | "postcss" => "CSS", + // Config / data → JSON or YAML + "jsonc" | "json5" | "geojson" | "webmanifest" | "eslintrc" | "prettierrc" + | "babelrc" => "JSON", + "toml" | "ini" | "cfg" | "conf" | "env" | "properties" | "editorconfig" => { + // TOML/INI are closest to YAML in structure for basic highlighting + "YAML" + } + // Shell variants → Bash + "ps1" | "psm1" | "psd1" | "nu" => "Bourne Again Shell (bash)", + // Docker / container + "dockerfile" => "Makefile", + // Systems languages → C/C++ + "zig" | "odin" | "v" => "C", + "kt" | "kts" => "Java", + "swift" => "Objective-C", + "dart" => "Java", + // Functional languages + "ex" | "exs" | "eex" | "heex" | "leex" => "Ruby", + "elm" | "hs" | "purs" => "Haskell", + "fs" | "fsx" | "fsi" => "C#", + "ml" | "mli" | "re" | "rei" => "OCaml", + // Other + "proto" | "protobuf" => "C", + "graphql" | "gql" => "JavaScript", + "tf" | "hcl" => "JSON", + "cmake" => "Makefile", + "r" | "rmd" => "R", + "sql" | "pgsql" | "mysql" | "tsql" => "SQL", + _ => return None, + }; + assets.syntax_set.find_syntax_by_name(fallback_name) + }) + .or_else(|| { + // Final fallback: match known filenames without extensions + let filename = Path::new(path).file_name()?.to_str()?; + let fallback_name = match filename { + "Dockerfile" | "Containerfile" | "Justfile" | "Brewfile" => "Makefile", + ".bashrc" | ".zshrc" | ".profile" | ".bash_profile" | ".zprofile" | ".env" + | ".envrc" => "Bourne Again Shell (bash)", + ".gitignore" | ".dockerignore" | ".prettierignore" | ".eslintignore" => { + "Bourne Again Shell (bash)" + } + "tsconfig.json" | "package.json" | "composer.json" | ".swcrc" => "JSON", + "CMakeLists.txt" => "Makefile", + _ => return None, + }; + assets.syntax_set.find_syntax_by_name(fallback_name) + }) + } + + fn syntax_line_highlighter(path: &str, appearance: Appearance) -> SyntaxLineHighlighter { + let assets = Self::syntax_assets(); + let Some(syntax) = Self::syntax_for_path(path) else { + return SyntaxLineHighlighter::Plain; + }; + + SyntaxLineHighlighter::Syntect { + syntax_set: &assets.syntax_set, + syntax, + highlighter: Self::syntax_highlighter(appearance), + } + } + + /// The shared [`Highlighter`] for an appearance. + /// + /// There are only ever two, and each is derived entirely from a `'static` + /// theme, so they are built once and handed out by reference. + fn syntax_highlighter(appearance: Appearance) -> &'static Highlighter<'static> { + static DARK: OnceLock> = OnceLock::new(); + static LIGHT: OnceLock> = OnceLock::new(); + + let cell = match appearance { + Appearance::Dark => &DARK, + Appearance::Light => &LIGHT, + }; + cell.get_or_init(|| Highlighter::new(Self::syntax_theme(appearance))) + } + + fn syntect_style_to_highlight(style: syntect::highlighting::Style) -> HighlightStyle { + let foreground = style.foreground; + let mut highlight = HighlightStyle { + color: Some(rgitui_theme::rgba_u8_to_hsla( + foreground.r, + foreground.g, + foreground.b, + foreground.a, + )), + ..Default::default() + }; + + if style.font_style.contains(SyntectFontStyle::BOLD) { + highlight.font_weight = Some(FontWeight::BOLD); + } + if style.font_style.contains(SyntectFontStyle::ITALIC) { + highlight.font_style = Some(FontStyle::Italic); + } + + highlight + } + + fn highlight_text(text: &str, highlighter: &mut SyntaxLineHighlighter) -> StyledLine { + let trimmed = text.trim_end(); + if trimmed.is_empty() { + return StyledLine::plain(trimmed.to_string()); + } + + match highlighter { + SyntaxLineHighlighter::Plain => StyledLine::plain(trimmed.to_string()), + SyntaxLineHighlighter::Syntect { + syntax_set, + syntax, + highlighter, + } => { + // Highlight each line with a fresh parse state to prevent + // block-comment / multi-line string state from bleeding across + // diff lines (additions, deletions, context are interleaved + // from different file versions so stateful highlighting + // produces incorrect results -- e.g. a `/*` in a deleted line + // would color all subsequent additions as comments). + // + // The state is what has to be fresh; the highlighter behind it + // does not, so it is shared rather than rebuilt here. + let mut parse_state = ParseState::new(syntax); + let mut highlight_state = HighlightState::new(highlighter, ScopeStack::new()); + let Ok(operations) = parse_state.parse_line(trimmed, syntax_set) else { + return StyledLine::plain(trimmed.to_string()); + }; + let ranges: Vec<(syntect::highlighting::Style, &str)> = + HighlightIterator::new(&mut highlight_state, &operations, trimmed, highlighter) + .collect(); + + let text_len = trimmed.len(); + let mut highlights = Vec::new(); + let mut cursor = 0usize; + for (style, segment) in ranges.into_iter() { + let segment: &str = segment; + let len = segment.len(); + if len == 0 { + continue; + } + // Clip to text bounds. GPUI panics if StyledText runs exceed + // text length — syntect can return spans extending into + // trailing whitespace that trim_end() removed. + let start = cursor.min(text_len); + let end = (cursor + len).min(text_len); + if start < end { + highlights.push((start..end, Self::syntect_style_to_highlight(style))); + } + cursor += len; + } + + StyledLine { + text: trimmed.to_string().into(), + highlights, + } + } + } + } + + /// Returns the row index of the longest line for the given mode, used to + /// seed `UniformList::with_width_from_item` so horizontal scroll range + /// covers the widest line in the diff. Byte length is a good-enough proxy + /// for code diffs, which are overwhelmingly ASCII. + fn longest_row_ix( + mode: DiffDisplayMode, + display_rows: &[DisplayRow], + sbs_rows: &[SideBySideRow], + three_way_rows: &[ThreeWayRow], + ) -> usize { + match mode { + DiffDisplayMode::Unified => display_rows + .iter() + .enumerate() + .map(|(i, row)| { + let len = match row { + DisplayRow::Line { styled, .. } => styled.text.len(), + DisplayRow::HunkHeader { header, .. } => header.len(), + }; + (i, len) + }) + .max_by_key(|(_, len)| *len) + .map(|(i, _)| i) + .unwrap_or(0), + DiffDisplayMode::SideBySide => sbs_rows + .iter() + .enumerate() + .map(|(i, row)| { + let len = match row { + SideBySideRow::Pair { + left_styled, + right_styled, + .. + } => left_styled.text.len().max(right_styled.text.len()), + SideBySideRow::HunkHeader { header, .. } => header.len(), + }; + (i, len) + }) + .max_by_key(|(_, len)| *len) + .map(|(i, _)| i) + .unwrap_or(0), + DiffDisplayMode::ThreeWay => three_way_rows + .iter() + .enumerate() + .map(|(i, row)| { + let len = match row { + ThreeWayRow::Triple { + left_styled, + mid_styled, + right_styled, + .. + } => left_styled + .text + .len() + .max(mid_styled.text.len()) + .max(right_styled.text.len()), + ThreeWayRow::HunkHeader { header, .. } => header.len(), + }; + (i, len) + }) + .max_by_key(|(_, len)| *len) + .map(|(i, _)| i) + .unwrap_or(0), + } + } + + fn render_styled_text( + window: &Window, + text: &StyledLine, + default_color: gpui::Hsla, + row_height: f32, + wrap: bool, + ) -> gpui::AnyElement { + // Runs (colors / font) are built from this style. StyledText's layout + // reads `window.text_style()` — NOT this local — for white_space, + // line_height, etc., so we also push those onto the ancestor div below. + let mut run_style = window.text_style(); + run_style.color = default_color; + run_style.line_height = px(row_height).into(); + + let styled_text = StyledText::new(text.text.clone()); + // Guard: if text is empty but highlights exist, GPUI panics on + // StyledText::with_default_highlights (Text: '', run: len: N). + // This can happen with word-level diff on whitespace-only lines. + // Pass highlights as a borrowed-iter (cloned per-element) to skip the + // Vec allocation per row per frame. + let styled = if text.text.is_empty() { + styled_text + .with_default_highlights(&run_style, std::iter::empty()) + .into_any_element() + } else { + styled_text + .with_default_highlights(&run_style, text.highlights.iter().cloned()) + .into_any_element() + }; + + // The text layouter reads `window.text_style()` at its request_layout + // time, so white_space and line_height must be on an ancestor div via + // `with_text_style` — setting them on a local TextStyle that is only + // handed to `with_default_highlights` does nothing for layout. + if wrap { + // `overflow_hidden` is also required so Taffy passes a definite + // width down to the text layouter inside a flex container (per + // zed's text.rs storybook example). + div() + .overflow_hidden() + .line_height(px(row_height)) + .whitespace_normal() + .child(styled) + .into_any_element() + } else { + // No-wrap: ancestor `overflow_x_scroll` scroll area handles + // horizontal scrolling; the StyledText extends to its natural + // width inside it. + div() + .line_height(px(row_height)) + .whitespace_nowrap() + .child(styled) + .into_any_element() + } + } + + fn prepare_display_rows( + diff: &FileDiff, + path: &str, + appearance: Appearance, + added_word_bg: HighlightStyle, + deleted_word_bg: HighlightStyle, + ) -> PreparedDisplayRows { + // Syntax highlighting and word-level LCS are computed once for the + // unified representation. Side-by-side rows reuse those StyledLines + // instead of running a second syntect/LCS pass over the same diff. + let display_rows = Arc::new(Self::compute_display_rows( + diff, + path, + appearance, + added_word_bg, + deleted_word_bg, + )); + let sbs_rows = Arc::new(Self::compute_sbs_rows(&display_rows)); + let display_longest_row_ix = + Self::longest_row_ix(DiffDisplayMode::Unified, &display_rows, &[], &[]); + + PreparedDisplayRows { + display_rows, + sbs_rows, + display_longest_row_ix, + } + } + + fn compute_sbs_rows(display_rows: &[DisplayRow]) -> Vec { + let mut rows = Vec::new(); + let mut pending_dels: Vec<(usize, StyledLine)> = Vec::new(); + let mut pending_adds: Vec<(usize, StyledLine)> = Vec::new(); + + let flush = |rows: &mut Vec, + dels: &mut Vec<(usize, StyledLine)>, + adds: &mut Vec<(usize, StyledLine)>| { + let max_len = dels.len().max(adds.len()); + for i in 0..max_len { + let (left_num, left_styled, left_kind) = dels.get(i).map_or_else( + || (None, StyledLine::plain(""), SideBySideLineKind::Empty), + |(num, styled)| (Some(*num), styled.clone(), SideBySideLineKind::Deletion), + ); + let (right_num, right_styled, right_kind) = adds.get(i).map_or_else( + || (None, StyledLine::plain(""), SideBySideLineKind::Empty), + |(num, styled)| (Some(*num), styled.clone(), SideBySideLineKind::Addition), + ); + rows.push(SideBySideRow::Pair { + left_num, + left_styled, + left_kind, + right_num, + right_styled, + right_kind, + }); + } + dels.clear(); + adds.clear(); + }; + + for row in display_rows { + match row { + DisplayRow::HunkHeader { + header, + context_name, + hunk_index, + } => { + flush(&mut rows, &mut pending_dels, &mut pending_adds); + rows.push(SideBySideRow::HunkHeader { + header: header.clone(), + context_name: context_name.clone(), + hunk_index: *hunk_index, + }); + } + DisplayRow::Line { + old_num, + new_num, + styled, + kind: DisplayLineKind::Context, + } => { + flush(&mut rows, &mut pending_dels, &mut pending_adds); + rows.push(SideBySideRow::Pair { + left_num: *old_num, + left_styled: styled.clone(), + left_kind: SideBySideLineKind::Context, + right_num: *new_num, + right_styled: styled.clone(), + right_kind: SideBySideLineKind::Context, + }); + } + DisplayRow::Line { + old_num: Some(old_num), + styled, + kind: DisplayLineKind::Deletion, + .. + } => pending_dels.push((*old_num, styled.clone())), + DisplayRow::Line { + new_num: Some(new_num), + styled, + kind: DisplayLineKind::Addition, + .. + } => pending_adds.push((*new_num, styled.clone())), + DisplayRow::Line { .. } => {} + } + } + flush(&mut rows, &mut pending_dels, &mut pending_adds); + rows + } + + fn compute_three_way_rows( + diff: &ThreeWayFileDiff, + choices: &[Option], + ) -> Vec { + let mut rows = Vec::new(); + let mut conflict_index = 0; + let mut left_line = 1; + let mut mid_line = 1; + let mut right_line = 1; + let conflict_count = diff.conflict_count(); + let context_name = diff + .path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default(); + + for section in &diff.sections { + match section { + MergeSection::Resolved(content) => { + for line in Self::display_lines(content, false) { + let styled = Self::plain_styled(&line); + rows.push(ThreeWayRow::Triple { + left_num: next_line_number(&mut left_line, true), + left_styled: styled.clone(), + left_kind: ThreeWayLineKind::Unchanged, + mid_num: next_line_number(&mut mid_line, true), + mid_styled: styled.clone(), + mid_kind: ThreeWayLineKind::Unchanged, + right_num: next_line_number(&mut right_line, true), + right_styled: styled, + right_kind: ThreeWayLineKind::Unchanged, + }); + } + } + MergeSection::Conflict { + ancestor: _, + ours, + theirs, + } => { + let choice = choices.get(conflict_index).copied().flatten(); + let allow_both = diff.supports_text_resolution(); + let left_has_real_lines = + diff.ours_exists && !diff.is_binary && !diff.is_special_file; + let right_has_real_lines = + diff.theirs_exists && !diff.is_binary && !diff.is_special_file; + let result_has_real_lines = match choice { + Some(ConflictChoice::Ours) => left_has_real_lines, + Some(ConflictChoice::Theirs) => right_has_real_lines, + Some(ConflictChoice::BothOursFirst | ConflictChoice::BothTheirsFirst) => { + allow_both + } + None => false, + }; + let detail = if diff.is_binary { + "Binary file" + } else if diff.is_special_file { + "Special file" + } else if !diff.ours_exists || !diff.theirs_exists { + "Modify/delete conflict" + } else if choice.is_some() { + "Resolved in draft" + } else { + "Choose a result" + }; + rows.push(ThreeWayRow::HunkHeader { + header: format!( + "Conflict {} of {}: {}", + conflict_index + 1, + conflict_count, + detail + ), + context_name: context_name.clone(), + conflict_index, + resolved: choice.is_some(), + allow_both, + }); + + let mut ours_lines = Self::display_lines(ours, diff.is_binary); + let mut theirs_lines = Self::display_lines(theirs, diff.is_binary); + if !diff.ours_exists { + ours_lines = vec!["".to_string()]; + } + if !diff.theirs_exists { + theirs_lines = vec!["".to_string()]; + } + let mut result_lines = match choice { + Some(ConflictChoice::Ours) => ours_lines.clone(), + Some(ConflictChoice::Theirs) => theirs_lines.clone(), + Some(ConflictChoice::BothOursFirst) => ours_lines + .iter() + .chain(theirs_lines.iter()) + .cloned() + .collect(), + Some(ConflictChoice::BothTheirsFirst) => theirs_lines + .iter() + .chain(ours_lines.iter()) + .cloned() + .collect(), + None => vec!["".to_string()], + }; + if ours_lines.is_empty() { + ours_lines.push(String::new()); + } + if theirs_lines.is_empty() { + theirs_lines.push(String::new()); + } + if result_lines.is_empty() { + result_lines.push(String::new()); + } + let line_count = ours_lines + .len() + .max(result_lines.len()) + .max(theirs_lines.len()); + for line_index in 0..line_count { + rows.push(ThreeWayRow::Triple { + left_num: next_line_number( + &mut left_line, + left_has_real_lines && line_index < ours_lines.len(), + ), + left_styled: Self::plain_styled( + ours_lines.get(line_index).map(String::as_str).unwrap_or(""), + ), + left_kind: ThreeWayLineKind::Conflict, + mid_num: next_line_number( + &mut mid_line, + result_has_real_lines && line_index < result_lines.len(), + ), + mid_styled: Self::plain_styled( + result_lines + .get(line_index) + .map(String::as_str) + .unwrap_or(""), + ), + mid_kind: if choice.is_some() { + ThreeWayLineKind::Resolved + } else { + ThreeWayLineKind::Conflict + }, + right_num: next_line_number( + &mut right_line, + right_has_real_lines && line_index < theirs_lines.len(), + ), + right_styled: Self::plain_styled( + theirs_lines + .get(line_index) + .map(String::as_str) + .unwrap_or(""), + ), + right_kind: ThreeWayLineKind::Conflict, + }); + } + conflict_index += 1; + } + } + } + rows + } + + fn display_lines(content: &[u8], binary: bool) -> Vec { + if binary { + return vec![format!("", content.len())]; + } + if content.is_empty() { + return Vec::new(); + } + String::from_utf8_lossy(content) + .split_inclusive('\n') + .map(|line| line.strip_suffix('\n').unwrap_or(line)) + .map(|line| line.strip_suffix('\r').unwrap_or(line).to_string()) + .collect() + } + + /// Build a plain (no syntax highlighting) StyledLine from a string. + fn plain_styled(text: &str) -> StyledLine { + StyledLine { + text: text.to_string().into(), + highlights: Vec::new(), + } + } + + fn compute_display_rows( + diff: &FileDiff, + path: &str, + appearance: Appearance, + added_word_bg: HighlightStyle, + deleted_word_bg: HighlightStyle, + ) -> Vec { + let mut rows = Vec::new(); + for (i, hunk) in diff.hunks.iter().enumerate() { + let header_str = hunk.header.trim().to_string(); + rows.push(DisplayRow::HunkHeader { + context_name: Self::extract_context_name(&header_str), + header: header_str, + hunk_index: i, + }); + let mut old_line = hunk.old_start as usize; + let mut new_line = hunk.new_start as usize; + let mut highlighter = Self::syntax_line_highlighter(path, appearance); + + let mut pending_dels: Vec<(usize, String, StyledLine)> = Vec::new(); + let mut pending_adds: Vec<(usize, String, StyledLine)> = Vec::new(); + + let flush = |rows: &mut Vec, + dels: &mut Vec<(usize, String, StyledLine)>, + adds: &mut Vec<(usize, String, StyledLine)>, + deleted_word_bg: HighlightStyle, + added_word_bg: HighlightStyle| { + let max_len = dels.len().max(adds.len()); + // Word-diff pairs rows by index, so applying it when the + // deletion and addition counts disagree (reformats, insertions + // that split a line, etc.) produces dense token-coincidence + // noise instead of useful change markers. The row-level red/ + // green coloring alone is clearer in that case. + let pair_word_diff = dels.len() == adds.len(); + for j in 0..max_len { + match (dels.get_mut(j), adds.get_mut(j)) { + ( + Some((del_line, del_text, del_styled)), + Some((_add_line, add_text, add_styled)), + ) => { + if pair_word_diff { + let (del_spans, add_spans) = Self::compute_word_diff( + del_text.trim_end(), + add_text.trim_end(), + ); + del_styled.apply_word_highlights( + del_spans, + Vec::new(), + deleted_word_bg, + added_word_bg, + ); + add_styled.apply_word_highlights( + Vec::new(), + add_spans, + deleted_word_bg, + added_word_bg, + ); + } + rows.push(DisplayRow::Line { + old_num: Some(*del_line), + new_num: None, + styled: del_styled.clone(), + kind: DisplayLineKind::Deletion, + }); + rows.push(DisplayRow::Line { + old_num: None, + new_num: Some(*_add_line), + styled: add_styled.clone(), + kind: DisplayLineKind::Addition, + }); + } + (Some((del_line, _, del_styled)), None) => { + rows.push(DisplayRow::Line { + old_num: Some(*del_line), + new_num: None, + styled: del_styled.clone(), + kind: DisplayLineKind::Deletion, + }); + } + (None, Some((add_line, _, add_styled))) => { + rows.push(DisplayRow::Line { + old_num: None, + new_num: Some(*add_line), + styled: add_styled.clone(), + kind: DisplayLineKind::Addition, + }); + } + (None, None) => {} + } + } + dels.clear(); + adds.clear(); + }; + + for line in &hunk.lines { + match line { + DiffLine::Context(text) => { + flush( + &mut rows, + &mut pending_dels, + &mut pending_adds, + deleted_word_bg, + added_word_bg, + ); + rows.push(DisplayRow::Line { + old_num: Some(old_line), + new_num: Some(new_line), + styled: Self::highlight_text(text, &mut highlighter), + kind: DisplayLineKind::Context, + }); + old_line += 1; + new_line += 1; + } + DiffLine::Deletion(text) => { + pending_dels.push(( + old_line, + text.clone(), + Self::highlight_text(text, &mut highlighter), + )); + old_line += 1; + } + DiffLine::Addition(text) => { + pending_adds.push(( + new_line, + text.clone(), + Self::highlight_text(text, &mut highlighter), + )); + new_line += 1; + } + } + } + flush( + &mut rows, + &mut pending_dels, + &mut pending_adds, + deleted_word_bg, + added_word_bg, + ); + } + rows + } + + /// Compute word-level diff highlights between `old_text` and `new_text`. + /// Returns `(deletion_spans, addition_spans)` as byte-index ranges into + /// each respective string. + /// + /// Uses `Algorithm::Lcs` (Longest Common Subsequence) — the only `similar` + /// algorithm that is fully iterative with no recursive `DiffHook` callbacks. + /// + /// **Why trim before diff?** `highlight_text` stores text with `trim_end()` + /// (trailing whitespace removed). Computing diff on raw text produces spans + /// into trailing whitespace that exceed `StyledLine` bounds → GPUI panic. + /// Trimming both strings first ensures spans always land within the + /// `StyledLine` content. + /// + /// LCS is O(NM) time/space and entirely iterative. Word-level highlighting + /// is skipped when either text exceeds 100 words (10,000 entries ≈ 80 KB). + pub(crate) fn compute_word_diff( + old_text: &str, + new_text: &str, + ) -> (Vec>, Vec>) { + let old_trimmed = old_text.trim_end(); + let new_trimmed = new_text.trim_end(); + + let old_word_ranges = Self::split_word_ranges(old_trimmed); + let new_word_ranges = Self::split_word_ranges(new_trimmed); + + if old_word_ranges.is_empty() && new_word_ranges.is_empty() { + return (Vec::new(), Vec::new()); + } + + // Cap at 100 words per line: LCS is O(NM) in space. + if old_word_ranges.len() > 100 || new_word_ranges.len() > 100 { + return (Vec::new(), Vec::new()); + } + + let old_wv: Vec<&str> = old_word_ranges + .iter() + .map(|r| &old_trimmed[r.clone()]) + .collect(); + let new_wv: Vec<&str> = new_word_ranges + .iter() + .map(|r| &new_trimmed[r.clone()]) + .collect(); + + let ops = capture_diff_slices(Algorithm::Lcs, &old_wv, &new_wv); + + let mut del_spans: Vec> = Vec::new(); + let mut add_spans: Vec> = Vec::new(); + + for op in ops { + for change in op.iter_changes(&old_wv, &new_wv) { + match change.tag() { + similar::ChangeTag::Delete => { + if let Some(idx) = change.old_index() { + if idx < old_word_ranges.len() { + del_spans.push(old_word_ranges[idx].clone()); + } + } + } + similar::ChangeTag::Insert => { + if let Some(idx) = change.new_index() { + if idx < new_word_ranges.len() { + add_spans.push(new_word_ranges[idx].clone()); + } + } + } + similar::ChangeTag::Equal => {} + } + } + } + + let del_spans = Self::merge_nearby_spans(del_spans, 1); + let add_spans = Self::merge_nearby_spans(add_spans, 1); + + // Extend each span to include immediately adjacent punctuation + // (e.g. trailing parens/brackets) so `overflow_y_hidden()` highlights + // as one block instead of stopping before the `()`. + let del_spans = Self::extend_spans_to_punctuation(del_spans, old_trimmed); + let add_spans = Self::extend_spans_to_punctuation(add_spans, new_trimmed); + + (del_spans, add_spans) + } + + /// Split `text` into tokens for word-level diffing. + /// + /// Tokens are runs of alphanumeric/underscore characters OR individual + /// punctuation characters. Whitespace is a separator and not emitted. + /// This gives much finer granularity than splitting on whitespace alone: + /// `foo.bar(x)` → `["foo", ".", "bar", "(", "x", ")"]` instead of one + /// big token. + pub(crate) fn split_word_ranges(text: &str) -> Vec> { + let mut result = Vec::new(); + let mut word_start: Option = None; + + for (i, ch) in text.char_indices() { + if ch.is_whitespace() { + if let Some(start) = word_start.take() { + result.push(start..i); + } + } else if ch.is_alphanumeric() || ch == '_' { + if word_start.is_none() { + word_start = Some(i); + } + } else { + // Punctuation: flush current word, emit punct as own token. + if let Some(start) = word_start.take() { + result.push(start..i); + } + result.push(i..i + ch.len_utf8()); + } + } + if let Some(start) = word_start { + result.push(start..text.len()); + } + result + } + + /// Merge spans that are close together into contiguous blocks. + /// + /// Without this, fine-grained tokenisation produces many small separate + /// highlights (e.g. `compute`, `x`, `y` each individually highlighted). + /// Merging with a small gap tolerance produces cleaner visual blocks + /// (e.g. one highlight covering `compute(x, y)`). + fn merge_nearby_spans(spans: Vec>, max_gap: usize) -> Vec> { + if spans.is_empty() { + return spans; + } + let mut merged = vec![spans[0].clone()]; + for span in &spans[1..] { + let last = merged.last_mut().unwrap(); + if span.start <= last.end + max_gap { + last.end = span.end; + } else { + merged.push(span.clone()); + } + } + merged + } + + /// Extend each span to absorb immediately adjacent punctuation so + /// highlights cover complete expressions like `func()` or `arr[i]`. + fn extend_spans_to_punctuation(spans: Vec>, text: &str) -> Vec> { + spans + .into_iter() + .map(|span| { + let mut end = span.end; + let bytes = text.as_bytes(); + while end < bytes.len() { + let ch = bytes[end]; + if ch == b'(' + || ch == b')' + || ch == b'[' + || ch == b']' + || ch == b'{' + || ch == b'}' + || ch == b'.' + || ch == b',' + || ch == b';' + || ch == b':' + || ch == b'<' + || ch == b'>' + { + end += 1; + } else { + break; + } + } + span.start..end + }) + .collect() + } +} + +/// Height of one row in the diff viewer's file-operations menu, in pixels. +const FILE_MENU_ITEM_HEIGHT: f32 = 28.0; +/// Minimum width of the file-operations menu, in pixels. +const FILE_MENU_WIDTH: f32 = 220.0; +/// Where the menu's dismiss backdrop starts, in pixels from the top of the +/// viewer. Must clear the header so the toggle button stays clickable. +const FILE_MENU_BACKDROP_TOP: f32 = 26.0; + +impl DiffViewer { + /// The header's file-operations toggle. Renders nothing when the displayed + /// content has no whole-file operation. + fn render_file_menu_button(&self, cx: &mut Context) -> AnyElement { + if self.file_menu_operations().is_empty() { + return div().into_any_element(); + } + let tooltip: SharedString = match self.source.revision_label() { + Some(revision) => format!("Apply or revert this whole file from {revision}").into(), + None => "Whole-file operations".into(), + }; + Button::new("diff-file-menu", "File") + .size(ButtonSize::Compact) + .style(ButtonStyle::Subtle) + .tooltip(tooltip) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.file_menu_open = !this.file_menu_open; + cx.notify(); + })) + .into_any_element() + } + + /// True when the file-operations menu is open and has something to show. + /// + /// Both the menu and its dismiss backdrop are gated on this, so the backdrop + /// can never outlive the menu and swallow clicks over the diff body. + fn file_menu_visible(&self) -> bool { + self.file_menu_open && !self.file_menu_operations().is_empty() + } + + /// A backdrop over the diff body that dismisses the open file menu. + /// + /// Starts below the header so the toggle button stays uncovered: a backdrop + /// over the button would eat its mouse-down, and the button would reopen the + /// menu it had just closed. + fn render_file_menu_backdrop(&self, cx: &mut Context) -> Option { + if !self.file_menu_visible() { + return None; + } + Some( + div() + .id("diff-file-menu-backdrop") + .absolute() + .top(px(FILE_MENU_BACKDROP_TOP)) + .left_0() + .right_0() + .bottom_0() + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _: &MouseDownEvent, _, cx| { + this.file_menu_open = false; + cx.notify(); + cx.stop_propagation(); + }), + ) + .into_any_element(), + ) + } + + /// The open file-operations menu, positioned under the header's toggle. + fn render_file_menu(&self, cx: &mut Context) -> Option { + if !self.file_menu_visible() { + return None; + } + let operations = self.file_menu_operations(); + let colors = cx.colors(); + let revision = self.source.revision_label(); + + let mut menu = div() + .id("diff-file-menu-popover") + .absolute() + .top(px(28.)) + .right(px(8.)) + .v_flex() + .min_w(px(FILE_MENU_WIDTH)) + .py(px(4.)) + .bg(colors.elevated_surface_background) + .border_1() + .border_color(colors.border) + .rounded(px(6.)) + .elevation_3(cx) + // Clicking inside the menu must not reach the viewer's own click + // handler, which would steal focus and re-render underneath. + .on_mouse_down( + MouseButton::Left, + |_: &MouseDownEvent, _: &mut Window, cx: &mut App| { + cx.stop_propagation(); + }, + ); + + if let Some(revision) = &revision { + menu = menu.child( + div().px(px(10.)).pb(px(2.)).child( + Label::new(SharedString::from(revision.clone())) + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ); + } + + for operation in operations { + let hover_bg = colors.ghost_element_hover; + let active_bg = colors.ghost_element_active; + let accent = colors.text_accent; + menu = menu.child( + div() + .id(SharedString::from(format!( + "diff-file-menu-{}", + operation.key() + ))) + .h_flex() + .w_full() + .h(px(FILE_MENU_ITEM_HEIGHT)) + .px(px(10.)) + .gap(px(6.)) + .items_center() + .rounded(px(4.)) + .cursor_pointer() + .hover(move |s| s.bg(hover_bg).border_l_2().border_color(accent)) + .active(move |s| s.bg(active_bg)) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + this.request_whole_file_patch(operation, cx); + })) + .child( + Label::new(operation.file_menu_label()) + .size(LabelSize::Small) + .color(Color::Default), + ), + ); + } + + Some(menu.into_any_element()) + } +} + +impl Render for DiffViewer { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + log::trace!( + "DiffViewer::render: path={:?} display_rows={} sbs_rows={}", + self.file_path, + self.display_rows.len(), + self.sbs_rows.len() + ); + let colors = cx.colors(); + + let has_content = self.diff.is_some() || self.three_way_diff.is_some(); + if self.error.is_some() || self.loading || !has_content { + let is_conflict = self.display_mode == DiffDisplayMode::ThreeWay; + let header = div() + .h_flex() + .w_full() + .h(px(26.)) + .px(px(10.)) + .gap(px(4.)) + .items_center() + .bg(colors.toolbar_background) + .border_b_1() + .border_color(colors.border_variant) + .child( + Icon::new(IconName::File) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child( + Label::new(if is_conflict { + "Conflict Resolver" + } else { + "Diff" + }) + .size(LabelSize::XSmall) + .weight(FontWeight::SEMIBOLD) + .color(Color::Muted), + ); + + let card = div() + .v_flex() + .gap(px(12.)) + .items_center() + .px(px(32.)) + .py(px(24.)) + .rounded(px(8.)) + .bg(colors.element_background); + + let card = if let Some(message) = &self.error { + card.child( + Icon::new(IconName::AlertTriangle) + .size(IconSize::Large) + .color(Color::Error), + ) + .child( + Label::new(if is_conflict { + "Couldn't load this conflict" + } else { + "Failed to load diff" + }) + .size(LabelSize::Small) + .color(Color::Error), + ) + .child( + Label::new(message.clone()) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .when(is_conflict, |element| { + element.child( + Button::new("conflict-error-open", "Open File") + .size(ButtonSize::Compact) + .style(ButtonStyle::Outlined) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.open_conflict_in_editor(cx); + })), + ) + }) + } else if self.loading { + card.child(Spinner::new().label(if is_conflict { + "Loading conflict comparison..." + } else { + "Loading diff..." + })) + } else { + card.child( + Icon::new(IconName::File) + .size(IconSize::Large) + .color(Color::Placeholder), + ) + .child( + Label::new("Select a file to view changes") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + Label::new("Click a file in the sidebar or detail panel") + .size(LabelSize::XSmall) + .color(Color::Placeholder), + ) + }; + + return div() + .id("diff-viewer") + .v_flex() + .size_full() + .bg(colors.editor_background) + .child(header) + .child( + div() + .flex_1() + .flex() + .items_center() + .justify_center() + .child(card), + ) + .into_any_element(); + } + + let display_rows = self.display_rows.clone(); + let sbs_rows = self.sbs_rows.clone(); + let three_way_rows = self.three_way_rows.clone(); + // Stage/unstage for working-tree content, apply/revert for everything + // else; the hunk headers below render one button per entry. + let hunk_operations = self.source.operations(); + let display_mode = self.display_mode; + let view: WeakEntity = cx.weak_entity(); + + let editor_bg = colors.editor_background; + let text_color = colors.text; + let text_muted = colors.text_muted; + let text_placeholder_color = colors.text_placeholder; + let border_variant = colors.border_variant; + let vc_added = colors.vc_added; + let vc_deleted = colors.vc_deleted; + let conflict_color = Color::Conflict.color(cx); + let element_bg = colors.element_background; + let toolbar_bg = colors.toolbar_background; + let border_focused = colors.border_focused; + + let added_line_bg = gpui::Hsla { + a: 0.12, + ..vc_added + }; + let deleted_line_bg = gpui::Hsla { + a: 0.12, + ..vc_deleted + }; + + let added_gutter_bg = gpui::Hsla { + a: 0.20, + ..vc_added + }; + let deleted_gutter_bg = gpui::Hsla { + a: 0.20, + ..vc_deleted + }; + + let empty_fill_bg = gpui::Hsla { + a: 0.04, + ..text_color + }; + + let gutter_bg = gpui::Hsla { + a: 0.04, + ..text_color + }; + + let settings_state = cx.global::(); + let compactness = settings_state.settings().compactness; + // Side-by-side and three-way columns scroll horizontally per row with no + // scrollbar, so a line wider than its column is silently clipped and adjacent + // rows fall out of alignment. Force wrap for those layouts so overflow is + // always visible and the columns stay aligned; the unified layout keeps the + // user's wrap preference (it has a global horizontal scrollbar). + let wrap_enabled = settings_state.settings().diff_wrap_lines + || matches!( + self.display_mode, + DiffDisplayMode::SideBySide | DiffDisplayMode::ThreeWay + ); + let row_height = compactness.spacing(20.0); + let hunk_header_height = row_height; + + let highlighted_row = self.highlighted_row; + let highlight_bg = gpui::Hsla { + a: 0.10, + ..colors.text_accent + }; + let row_hover_bg = gpui::Hsla { + a: 0.04, + ..text_color + }; + let selection_bg = gpui::Hsla { + a: 0.15, + ..colors.text_accent + }; + let selected_lines = self.selected_lines.clone(); + let display_longest_row_ix = self.display_longest_row_ix; + + // Restore scroll position after a display mode switch. The deferred scroll is + // applied by GPUI's layout pass before the list content is painted, so the + // user sees the correct scroll position immediately. + if let Some(top_ix) = self.pending_scroll_top.take() { + let target_ix = top_ix.min(self.row_count().saturating_sub(1)); + self.scroll_row_into_view(target_ix, cx); + } + + log::debug!( + target: "rgitui::diff", + "render mode={:?} wrap={} row_height={} rows={}", + display_mode, + wrap_enabled, + row_height, + self.row_count(), + ); + + let list = match display_mode { + DiffDisplayMode::Unified => { + let view = view.clone(); + let row_count = display_rows.len(); + let build_unified = move |range: Range, + window: &mut Window, + _cx: &mut App| + -> Vec { + range + .map(|i| { + let row = &display_rows[i]; + match row { + DisplayRow::HunkHeader { + header, + context_name, + hunk_index, + } => { + let line_range: SharedString = + Self::extract_line_range(header).into(); + let ctx_name: SharedString = context_name.clone().into(); + let has_context = !context_name.is_empty(); + let idx = *hunk_index; + let view_hunk = view.clone(); + let view_hunk_drag = view.clone(); + let is_hunk_selected = + selected_lines.as_ref().is_some_and(|r| r.contains(&i)); + let hunk_bg = if is_hunk_selected { + selection_bg + } else { + element_bg + }; + + let mut hunk_row = div() + .id(ElementId::NamedInteger( + "hunk-header".into(), + i as u64, + )) + .h_flex() + .h(px(hunk_header_height)) + .w_full() + .min_w_0() + .overflow_hidden() + .px(px(8.)) + .py(px(4.)) + .items_center() + .gap(px(6.)) + .bg(hunk_bg) + .border_t_1() + .border_b_1() + .border_color(border_variant) + .cursor(CursorStyle::IBeam) + .on_mouse_down( + MouseButton::Left, + move |event: &MouseDownEvent, + _window: &mut Window, + cx: &mut App| { + let shift = event.modifiers.shift; + view_hunk + .update(cx, |this, cx| { + this.begin_mouse_selection(i, shift, cx); + }) + .ok(); + }, + ) + .on_mouse_move( + move |event: &MouseMoveEvent, + _window: &mut Window, + cx: &mut App| { + if event.dragging() { + view_hunk_drag + .update(cx, |this, cx| { + this.extend_mouse_selection(i, cx); + }) + .ok(); + } + }, + ) + .child( + div() + .flex_shrink_0() + .text_xs() + .font_family("Lilex") + .text_color(text_muted) + .child(line_range), + ); + + if has_context { + hunk_row = hunk_row.child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .text_xs() + .text_color(text_placeholder_color) + .italic() + .child( + Label::new(ctx_name) + .size(LabelSize::XSmall) + .color(Color::Placeholder) + .italic() + .truncate(), + ), + ); + } else { + hunk_row = hunk_row.child(div().flex_1().min_w_0()); + } + + // One button per operation the source + // supports: Stage/Unstage for working-tree + // content, Apply/Revert for a commit, a + // stash, or a comparison. + let mut hunk_actions = + div().h_flex().gap_1().flex_shrink_0().items_center(); + for operation in hunk_operations { + let operation = *operation; + let button_view = view.clone(); + hunk_actions = hunk_actions.child( + Button::new( + SharedString::from(format!( + "hunk-{}-{}", + operation.key(), + idx + )), + operation.compact_hunk_button_label(), + ) + .size(ButtonSize::Compact) + .style(ButtonStyle::Subtle) + .tooltip(operation.hunk_button_label()) + .on_click( + move |_: &ClickEvent, + _: &mut Window, + cx: &mut App| { + button_view + .update(cx, |_this, cx| { + cx.emit(DiffViewerEvent::for_hunk( + operation, idx, + )); + }) + .ok(); + }, + ), + ); + } + hunk_row = hunk_row.child(hunk_actions); + + hunk_row.into_any_element() + } + DisplayRow::Line { + old_num, + new_num, + styled, + kind, + } => { + let (prefix, text_col, line_bg, gutter_accent) = match kind { + DisplayLineKind::Context => { + (" ", text_color, editor_bg, gutter_bg) + } + DisplayLineKind::Addition => { + ("+", vc_added, added_line_bg, added_gutter_bg) + } + DisplayLineKind::Deletion => { + ("-", vc_deleted, deleted_line_bg, deleted_gutter_bg) + } + }; + + let old_str: SharedString = old_num + .map(|n| format!("{:>4}", n)) + .unwrap_or_else(|| " ".to_string()) + .into(); + let new_str: SharedString = new_num + .map(|n| format!("{:>4}", n)) + .unwrap_or_else(|| " ".to_string()) + .into(); + let prefix_str: SharedString = prefix.into(); + let is_highlighted = highlighted_row == Some(i); + let is_selected = + selected_lines.as_ref().is_some_and(|r| r.contains(&i)); + let effective_bg = if is_selected { + selection_bg + } else if is_highlighted { + highlight_bg + } else { + line_bg + }; + + let view_line = view.clone(); + let view_line_drag = view.clone(); + let mut row_div = div() + .id(ElementId::NamedInteger( + "diff-line".into(), + i as u64, + )) + .bg(effective_bg) + .hover(move |s| s.bg(row_hover_bg)) + .cursor(CursorStyle::IBeam) + .on_mouse_down( + MouseButton::Left, + move |event: &MouseDownEvent, + _window: &mut Window, + cx: &mut App| { + let shift = event.modifiers.shift; + view_line + .update(cx, |this, cx| { + this.begin_mouse_selection(i, shift, cx); + }) + .ok(); + }, + ) + .on_mouse_move( + move |event: &MouseMoveEvent, + _window: &mut Window, + cx: &mut App| { + if event.dragging() { + view_line_drag + .update(cx, |this, cx| { + this.extend_mouse_selection(i, cx); + }) + .ok(); + } + }, + ); + if wrap_enabled { + // Wrap mode: w_full makes the row span the list width so + // bg is consistent; flex_shrink_0 keeps the row from being + // squeezed vertically when content totals exceed viewport, + // which would make wrapped lines overlap the row below. + row_div = row_div + .w_full() + .flex_shrink_0() + .h_flex() + .items_start() + .min_h(px(row_height)); + } else { + // No-wrap: w_full spans available width so bg is + // consistent across the scrolled area; the row's min- + // content width (sum of children's min-content widths) + // is what the list uses to determine horizontal scroll + // range. + row_div = row_div.w_full().h_flex().h(px(row_height)); + } + + let make_gutter_cell = |val: SharedString, width: f32| { + div() + .w(px(width)) + .flex_shrink_0() + .min_h(px(row_height)) + .flex() + .items_center() + .justify_end() + .bg(gutter_accent) + .border_r_1() + .border_color(border_variant) + .text_xs() + .font_family("Lilex") + .text_color(text_muted) + .pr(px(4.)) + .child(val) + }; + let prefix_cell = div() + .w(px(18.)) + .flex_shrink_0() + .min_h(px(row_height)) + .flex() + .items_center() + .justify_center() + .text_xs() + .font_family("Lilex") + .font_weight(FontWeight::BOLD) + .text_color(text_col) + .child(prefix_str); + + // Text content. Wrap mode uses a flex child with min_w_0 so + // the text layouter receives a bounded width and can word- + // wrap. No-wrap mode lets the text set its natural width so + // the enclosing list can scroll horizontally. + let content_area: gpui::AnyElement = if wrap_enabled { + // No vertical padding in wrap mode — it adds ~4px + // between logical lines because the row grows with + // the padded content height, not the text box. + div() + .flex_1() + .min_w_0() + .pl(px(6.)) + .text_xs() + .font_family("Lilex") + .text_color(text_col) + .child(Self::render_styled_text( + window, styled, text_col, row_height, true, + )) + .into_any_element() + } else { + div() + .h_flex() + .h_full() + .pl(px(6.)) + .pr(px(12.)) + .text_xs() + .font_family("Lilex") + .text_color(text_col) + .child(Self::render_styled_text( + window, styled, text_col, row_height, false, + )) + .into_any_element() + }; + + row_div + .child(make_gutter_cell(old_str, 44.)) + .child(make_gutter_cell(new_str, 44.)) + .child(prefix_cell) + .child(content_area) + .into_any_element() + } + } + }) + .collect() + }; + + if wrap_enabled { + // Virtualized wrap mode: `gpui::list` caches per-row heights in a + // SumTree so only the visible window (plus overdraw) renders each + // frame. `overflow_x_hidden` on the wrapper prevents any + // non-wrapping long line from bleeding into neighboring panels. + let list_body = list(self.wrap_list_state.clone(), move |ix, window, cx| { + build_unified(ix..ix + 1, window, cx) + .into_iter() + .next() + .expect("build_unified returns exactly one row") + }) + .with_sizing_behavior(gpui::ListSizingBehavior::Auto) + .flex_grow(1.0); + // v_flex parent so `list_body`'s `flex_grow` actually + // gives it a definite height — without it the List is + // 0px tall and renders nothing in wrap mode. + div() + .id("diff-lines-wrap") + .v_flex() + .flex_grow(1.0) + .min_h(px(0.)) + .min_w_0() + .overflow_x_hidden() + .child(list_body) + .into_any_element() + } else { + uniform_list("diff-lines", row_count, build_unified) + .with_sizing_behavior(ListSizingBehavior::Auto) + .with_horizontal_sizing_behavior( + ListHorizontalSizingBehavior::Unconstrained, + ) + .with_width_from_item(Some(display_longest_row_ix)) + .flex_grow(1.0) + .track_scroll(&self.scroll_handle) + .into_any_element() + } + } + DiffDisplayMode::SideBySide => { + let view = view.clone(); + let row_count = sbs_rows.len(); + let build_sbs = move |range: Range, + window: &mut Window, + _cx: &mut App| + -> Vec { + range + .map(|i| { + let row = &sbs_rows[i]; + match row { + SideBySideRow::HunkHeader { + header, + context_name, + hunk_index, + } => { + let line_range: SharedString = + Self::extract_line_range(header).into(); + let ctx_name: SharedString = context_name.clone().into(); + let has_context = !context_name.is_empty(); + let idx = *hunk_index; + let view_sbs_hunk = view.clone(); + let view_sbs_hunk_drag = view.clone(); + let is_sbs_hunk_selected = + selected_lines.as_ref().is_some_and(|r| r.contains(&i)); + let sbs_hunk_bg = if is_sbs_hunk_selected { + selection_bg + } else { + element_bg + }; + + let mut hunk_row = div() + .id(ElementId::NamedInteger( + "sbs-hunk-header".into(), + i as u64, + )) + .h_flex() + .h(px(hunk_header_height)) + .w_full() + .min_w_0() + .overflow_hidden() + .px(px(8.)) + .py(px(4.)) + .items_center() + .gap(px(6.)) + .bg(sbs_hunk_bg) + .border_t_1() + .border_b_1() + .border_color(border_variant) + .cursor(CursorStyle::IBeam) + .on_mouse_down( + MouseButton::Left, + move |event: &MouseDownEvent, + _window: &mut Window, + cx: &mut App| { + let shift = event.modifiers.shift; + view_sbs_hunk + .update(cx, |this, cx| { + this.begin_mouse_selection(i, shift, cx); + }) + .ok(); + }, + ) + .on_mouse_move( + move |event: &MouseMoveEvent, + _window: &mut Window, + cx: &mut App| { + if event.dragging() { + view_sbs_hunk_drag + .update(cx, |this, cx| { + this.extend_mouse_selection(i, cx); + }) + .ok(); + } + }, + ) + .child( + div() + .flex_shrink_0() + .text_xs() + .font_family("Lilex") + .text_color(text_muted) + .child(line_range), + ); + + if has_context { + hunk_row = hunk_row.child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .text_xs() + .text_color(text_placeholder_color) + .italic() + .child( + Label::new(ctx_name) + .size(LabelSize::XSmall) + .color(Color::Placeholder) + .italic() + .truncate(), + ), + ); + } else { + hunk_row = hunk_row.child(div().flex_1().min_w_0()); + } + + // Same operations as the unified header, from + // the same source decision. + let mut hunk_actions = + div().h_flex().gap_1().flex_shrink_0().items_center(); + for operation in hunk_operations { + let operation = *operation; + let button_view = view.clone(); + hunk_actions = hunk_actions.child( + Button::new( + SharedString::from(format!( + "sbs-hunk-{}-{}", + operation.key(), + idx + )), + operation.compact_hunk_button_label(), + ) + .size(ButtonSize::Compact) + .style(ButtonStyle::Subtle) + .tooltip(operation.hunk_button_label()) + .on_click( + move |_: &ClickEvent, + _: &mut Window, + cx: &mut App| { + button_view + .update(cx, |_this, cx| { + cx.emit(DiffViewerEvent::for_hunk( + operation, idx, + )); + }) + .ok(); + }, + ), + ); + } + hunk_row = hunk_row.child(hunk_actions); + + hunk_row.into_any_element() + } + SideBySideRow::Pair { + left_num, + left_styled, + left_kind, + right_num, + right_styled, + right_kind, + } => { + let (left_bg, left_gutter_bg, left_text_col) = match left_kind { + SideBySideLineKind::Context => { + (editor_bg, gutter_bg, text_color) + } + SideBySideLineKind::Deletion => { + (deleted_line_bg, deleted_gutter_bg, vc_deleted) + } + SideBySideLineKind::Addition => { + (added_line_bg, added_gutter_bg, vc_added) + } + SideBySideLineKind::Empty => { + (empty_fill_bg, gutter_bg, text_placeholder_color) + } + }; + let (right_bg, right_gutter_bg, right_text_col) = + match right_kind { + SideBySideLineKind::Context => { + (editor_bg, gutter_bg, text_color) + } + SideBySideLineKind::Addition => { + (added_line_bg, added_gutter_bg, vc_added) + } + SideBySideLineKind::Deletion => { + (deleted_line_bg, deleted_gutter_bg, vc_deleted) + } + SideBySideLineKind::Empty => { + (empty_fill_bg, gutter_bg, text_placeholder_color) + } + }; + + let left_num_str: SharedString = left_num + .map(|n| format!("{:>4}", n)) + .unwrap_or_else(|| " ".to_string()) + .into(); + let right_num_str: SharedString = right_num + .map(|n| format!("{:>4}", n)) + .unwrap_or_else(|| " ".to_string()) + .into(); + let is_highlighted = highlighted_row == Some(i); + let is_sbs_selected = + selected_lines.as_ref().is_some_and(|r| r.contains(&i)); + let effective_left_bg = if is_sbs_selected { + selection_bg + } else if is_highlighted { + highlight_bg + } else { + left_bg + }; + let effective_right_bg = if is_sbs_selected { + selection_bg + } else if is_highlighted { + highlight_bg + } else { + right_bg + }; + + let view_sbs_line = view.clone(); + let view_sbs_line_drag = view.clone(); + let mut row_div = div() + .id(ElementId::NamedInteger( + "sbs-line".into(), + i as u64, + )) + .w_full() + .hover(move |s| s.bg(row_hover_bg)) + .cursor(CursorStyle::IBeam) + .on_mouse_down( + MouseButton::Left, + move |event: &MouseDownEvent, + _window: &mut Window, + cx: &mut App| { + let shift = event.modifiers.shift; + view_sbs_line + .update(cx, |this, cx| { + this.begin_mouse_selection(i, shift, cx); + }) + .ok(); + }, + ) + .on_mouse_move( + move |event: &MouseMoveEvent, + _window: &mut Window, + cx: &mut App| { + if event.dragging() { + view_sbs_line_drag + .update(cx, |this, cx| { + this.extend_mouse_selection(i, cx); + }) + .ok(); + } + }, + ); + if wrap_enabled { + row_div = row_div + .flex_shrink_0() + .h_flex() + .items_start() + .min_h(px(row_height)); + } else { + row_div = row_div.h_flex().h(px(row_height)); + } + + // Build gutter (fixed 44px wide, column-height). + let make_gutter = |num_str: SharedString, + gutter_bg_val: gpui::Hsla| + -> gpui::AnyElement { + let mut g = div() + .w(px(44.)) + .flex_shrink_0() + .flex() + .items_center() + .justify_end() + .bg(gutter_bg_val) + .border_r_1() + .border_color(border_variant) + .text_xs() + .font_family("Lilex") + .text_color(text_muted) + .pr(px(4.)) + .child(num_str); + g = if wrap_enabled { + g.min_h(px(row_height)) + } else { + g.h_full() + }; + g.into_any_element() + }; + + // Build a column: gutter (outside scroll) + text. + // - wrap mode: text_div is a plain block with flex_1 + + // min_w_0 so it inherits column's flex share as its + // max width; render_styled_text returns StyledText + // directly so the text layouter word-wraps. + // - no-wrap mode: an inner scroll area (with id) holds + // the text. The gutter stays OUTSIDE that scroll area + // so it remains visible when the text scrolls right. + let build_column = |side: &'static str, + bg_val: gpui::Hsla, + gutter_bg_val: gpui::Hsla, + num_str: SharedString, + text_col_val: gpui::Hsla, + styled: &StyledLine, + window: &mut Window| + -> gpui::AnyElement { + if wrap_enabled { + let text_div = div() + .flex_1() + .min_w_0() + .pl(px(6.)) + .text_xs() + .font_family("Lilex") + .text_color(text_col_val) + .child(Self::render_styled_text( + window, + styled, + text_col_val, + row_height, + true, + )); + div() + .h_flex() + .flex_1() + .min_w_0() + .items_start() + .min_h(px(row_height)) + .bg(bg_val) + .child(make_gutter(num_str, gutter_bg_val)) + .child(text_div) + .into_any_element() + } else { + // Split view: each column has a fixed flex share + // of row width. Use per-row overflow_x_scroll so + // long lines scroll inside the column instead of + // bleeding into the adjacent column. + let scroll_area = div() + .id(ElementId::NamedInteger( + side.into(), + i as u64, + )) + .h_flex() + .flex_1() + .min_w_0() + .h_full() + .pl(px(6.)) + .text_xs() + .font_family("Lilex") + .text_color(text_col_val) + .overflow_x_scroll() + .child(Self::render_styled_text( + window, + styled, + text_col_val, + row_height, + false, + )); + div() + .h_flex() + .flex_1() + .min_w_0() + .h_full() + .bg(bg_val) + .child(make_gutter(num_str, gutter_bg_val)) + .child(scroll_area) + .into_any_element() + } + }; + + let mut divider = + div().w(px(2.)).flex_shrink_0().bg(border_variant); + divider = if wrap_enabled { + divider.min_h(px(row_height)) + } else { + divider.h_full() + }; + + row_div + .child(build_column( + "sbs-left", + effective_left_bg, + left_gutter_bg, + left_num_str, + left_text_col, + left_styled, + window, + )) + .child(divider) + .child(build_column( + "sbs-right", + effective_right_bg, + right_gutter_bg, + right_num_str, + right_text_col, + right_styled, + window, + )) + .into_any_element() + } + } + }) + .collect() + }; + + if wrap_enabled { + let list_body = list(self.wrap_list_state.clone(), move |ix, window, cx| { + build_sbs(ix..ix + 1, window, cx) + .into_iter() + .next() + .expect("build_sbs returns exactly one row") + }) + .with_sizing_behavior(gpui::ListSizingBehavior::Auto) + .flex_grow(1.0); + div() + .id("diff-lines-sbs-wrap") + .v_flex() + .flex_grow(1.0) + .min_h(px(0.)) + .min_w_0() + .overflow_x_hidden() + .child(list_body) + .into_any_element() + } else { + // Split view: each column has an independent per-row horizontal + // scroll area, so the list itself should be viewport-width + // (no Unconstrained sizing, no global horizontal scrollbar). + uniform_list("diff-lines-sbs", row_count, build_sbs) + .with_sizing_behavior(ListSizingBehavior::Auto) + .flex_grow(1.0) + .track_scroll(&self.scroll_handle) + .into_any_element() + } + } + DiffDisplayMode::ThreeWay => { + let view = view.clone(); + let tw_rows = three_way_rows.clone(); + let row_count = tw_rows.len(); + let build_tw = move |range: Range, + window: &mut Window, + _cx: &mut App| + -> Vec { + range + .map(|i| { + let row = &tw_rows[i]; + match row { + ThreeWayRow::HunkHeader { + header, + context_name, + conflict_index, + resolved, + allow_both, + } => { + let is_selected = selected_lines + .as_ref() + .is_some_and(|range| range.contains(&i)); + let header_bg = if is_selected { + selection_bg + } else { + element_bg + }; + let view_hunk = view.clone(); + let view_hunk_drag = view.clone(); + let current_view = view.clone(); + let incoming_view = view.clone(); + let both_current_view = view.clone(); + let both_incoming_view = view.clone(); + let reset_view = view.clone(); + let conflict_index = *conflict_index; + div() + .id(ElementId::NamedInteger( + "tw-hunk-header".into(), + i as u64, + )) + .h_flex() + .h(px(hunk_header_height)) + .w_full() + .px(px(8.)) + .py(px(4.)) + .items_center() + .gap(px(6.)) + .bg(header_bg) + .border_t_1() + .border_b_1() + .border_color(border_variant) + .cursor(CursorStyle::IBeam) + .on_mouse_down( + MouseButton::Left, + move |event: &MouseDownEvent, + _window: &mut Window, + cx: &mut App| { + let shift = event.modifiers.shift; + view_hunk + .update(cx, |this, cx| { + this.begin_mouse_selection(i, shift, cx); + }) + .ok(); + }, + ) + .on_mouse_move( + move |event: &MouseMoveEvent, + _window: &mut Window, + cx: &mut App| { + if event.dragging() { + view_hunk_drag + .update(cx, |this, cx| { + this.extend_mouse_selection(i, cx); + }) + .ok(); + } + }, + ) + .child( + div() + .text_xs() + .font_family("Lilex") + .text_color(if *resolved { + vc_added + } else { + conflict_color + }) + .font_weight(FontWeight::SEMIBOLD) + .child(header.clone()), + ) + .child( + div() + .text_xs() + .font_family("Lilex") + .text_color(text_muted) + .ml(px(8.)) + .child(format!("[{}]", context_name)), + ) + .child(div().flex_1()) + .child( + Button::new( + ElementId::NamedInteger( + "tw-use-current".into(), + conflict_index as u64, + ), + "Use Current", + ) + .size(ButtonSize::Compact) + .style(ButtonStyle::Subtle) + .tooltip("Put Current into Result") + .on_click(move |_: &ClickEvent, _, cx| { + current_view + .update(cx, |this, cx| { + this.set_conflict_choice( + conflict_index, + Some(ConflictChoice::Ours), + cx, + ); + }) + .ok(); + }), + ) + .when(*allow_both, |element| { + element + .child( + Button::new( + ElementId::NamedInteger( + "tw-use-both-current".into(), + conflict_index as u64, + ), + "Both C→I", + ) + .size(ButtonSize::Compact) + .style(ButtonStyle::Subtle) + .tooltip("Current, then Incoming") + .on_click(move |_: &ClickEvent, _, cx| { + both_current_view + .update(cx, |this, cx| { + this.set_conflict_choice( + conflict_index, + Some( + ConflictChoice::BothOursFirst, + ), + cx, + ); + }) + .ok(); + }), + ) + .child( + Button::new( + ElementId::NamedInteger( + "tw-use-both-incoming".into(), + conflict_index as u64, + ), + "Both I→C", + ) + .size(ButtonSize::Compact) + .style(ButtonStyle::Subtle) + .tooltip("Incoming, then Current") + .on_click(move |_: &ClickEvent, _, cx| { + both_incoming_view + .update(cx, |this, cx| { + this.set_conflict_choice( + conflict_index, + Some( + ConflictChoice::BothTheirsFirst, + ), + cx, + ); + }) + .ok(); + }), + ) + }) + .child( + Button::new( + ElementId::NamedInteger( + "tw-use-incoming".into(), + conflict_index as u64, + ), + "Use Incoming", + ) + .size(ButtonSize::Compact) + .style(ButtonStyle::Subtle) + .tooltip("Put Incoming into Result") + .on_click(move |_: &ClickEvent, _, cx| { + incoming_view + .update(cx, |this, cx| { + this.set_conflict_choice( + conflict_index, + Some(ConflictChoice::Theirs), + cx, + ); + }) + .ok(); + }), + ) + .when(*resolved, |element| { + element.child( + Button::new( + ElementId::NamedInteger( + "tw-reset".into(), + conflict_index as u64, + ), + "Reset", + ) + .size(ButtonSize::Compact) + .style(ButtonStyle::Subtle) + .tooltip("Make this conflict unresolved again") + .on_click(move |_: &ClickEvent, _, cx| { + reset_view + .update(cx, |this, cx| { + this.set_conflict_choice( + conflict_index, + None, + cx, + ); + }) + .ok(); + }), + ) + }) + .into_any_element() + } + ThreeWayRow::Triple { + left_num, + left_styled, + left_kind, + mid_num, + mid_styled, + mid_kind, + right_num, + right_styled, + right_kind, + } => { + let conflict_bg = + |kind: ThreeWayLineKind, base: gpui::Hsla| -> gpui::Hsla { + match kind { + ThreeWayLineKind::Conflict => { + gpui::Hsla { a: 0.2, ..base } + } + ThreeWayLineKind::Resolved => gpui::Hsla { + a: 0.12, + ..vc_added + }, + _ => base, + } + }; + let is_highlighted = highlighted_row == Some(i); + let is_selected = selected_lines + .as_ref() + .is_some_and(|range| range.contains(&i)); + let row_bg = |base: gpui::Hsla| { + if is_selected { + selection_bg + } else if is_highlighted { + highlight_bg + } else { + base + } + }; + let left_bg = row_bg(conflict_bg(*left_kind, editor_bg)); + let mid_bg = row_bg(conflict_bg(*mid_kind, editor_bg)); + let right_bg = row_bg(conflict_bg(*right_kind, editor_bg)); + let left_text_col = match left_kind { + ThreeWayLineKind::Conflict => conflict_color, + _ => text_color, + }; + let right_text_col = match right_kind { + ThreeWayLineKind::Conflict => conflict_color, + _ => text_color, + }; + let left_num_str: SharedString = + left_num.map(|n| n.to_string()).unwrap_or_default().into(); + let mid_num_str: SharedString = + mid_num.map(|n| n.to_string()).unwrap_or_default().into(); + let right_num_str: SharedString = + right_num.map(|n| n.to_string()).unwrap_or_default().into(); + + let view_row = view.clone(); + let view_row_drag = view.clone(); + let mut row_div = div() + .id(ElementId::NamedInteger("tw-row".into(), i as u64)) + .w_full() + .flex() + .cursor(CursorStyle::IBeam) + .border_b_1() + .border_color(gpui::Hsla { + a: 0.5, + ..border_variant + }) + .on_mouse_down( + MouseButton::Left, + move |event: &MouseDownEvent, + _window: &mut Window, + cx: &mut App| { + let shift = event.modifiers.shift; + view_row + .update(cx, |this, cx| { + this.begin_mouse_selection(i, shift, cx); + }) + .ok(); + }, + ) + .on_mouse_move( + move |event: &MouseMoveEvent, + _window: &mut Window, + cx: &mut App| { + if event.dragging() { + view_row_drag + .update(cx, |this, cx| { + this.extend_mouse_selection(i, cx); + }) + .ok(); + } + }, + ); + row_div = if wrap_enabled { + row_div.flex_shrink_0().items_start().min_h(px(row_height)) + } else { + row_div.h(px(row_height)) + }; + + // Same pattern as split view: gutter is OUTSIDE the + // scroll area so the line number stays visible while + // the text scrolls horizontally. + let make_col = |side: &'static str, + bg: gpui::Hsla, + num_str: SharedString, + text_col_val: gpui::Hsla, + styled: &StyledLine, + border_right: bool, + window: &mut Window| + -> gpui::AnyElement { + let mut num_div = div() + .w(px(32.)) + .flex_shrink_0() + .text_xs() + .font_family("Lilex") + .text_color(text_muted) + .justify_end(); + if wrap_enabled { + num_div = num_div.min_h(px(row_height)); + } + if wrap_enabled { + let text_div = div() + .flex_1() + .min_w_0() + .text_xs() + .font_family("Lilex") + .text_color(text_col_val) + .child(Self::render_styled_text( + window, + styled, + text_col_val, + row_height, + true, + )); + let mut col = div() + .flex_1() + .min_w_0() + .flex() + .items_start() + .min_h(px(row_height)) + .px(px(4.)) + .bg(bg) + .gap(px(2.)); + if border_right { + col = col + .border_r_1() + .border_color(border_variant); + } + col.child(num_div.child(num_str)) + .child(text_div) + .into_any_element() + } else { + // Three-way split: each column is 1/3 of row width. + // Per-row overflow_x_scroll clips long lines inside + // their column rather than bleeding across the + // dividers into adjacent versions. + let scroll_area = div() + .id(ElementId::NamedInteger( + side.into(), + i as u64, + )) + .h_flex() + .flex_1() + .min_w_0() + .h_full() + .text_xs() + .font_family("Lilex") + .text_color(text_col_val) + .overflow_x_scroll() + .child(Self::render_styled_text( + window, + styled, + text_col_val, + row_height, + false, + )); + let mut col = div() + .flex_1() + .min_w_0() + .h_flex() + .h_full() + .px(px(4.)) + .bg(bg) + .gap(px(2.)); + if border_right { + col = col + .border_r_1() + .border_color(border_variant); + } + col.child(num_div.child(num_str)) + .child(scroll_area) + .into_any_element() + } + }; + + row_div + .child(make_col( + "tw-left", + left_bg, + left_num_str.clone(), + left_text_col, + left_styled, + true, + window, + )) + .child(make_col( + "tw-mid", + mid_bg, + mid_num_str.clone(), + text_color, + mid_styled, + true, + window, + )) + .child(make_col( + "tw-right", + right_bg, + right_num_str.clone(), + right_text_col, + right_styled, + false, + window, + )) + .into_any_element() + } + } + }) + .collect() + }; + + if wrap_enabled { + let list_body = list(self.wrap_list_state.clone(), move |ix, window, cx| { + build_tw(ix..ix + 1, window, cx) + .into_iter() + .next() + .expect("build_tw returns exactly one row") + }) + .with_sizing_behavior(gpui::ListSizingBehavior::Auto) + .flex_grow(1.0); + div() + .id("diff-lines-tw-wrap") + .v_flex() + .flex_grow(1.0) + .min_h(px(0.)) + .min_w_0() + .overflow_x_hidden() + .child(list_body) + .into_any_element() + } else { + uniform_list("diff-lines-tw", row_count, build_tw) + .with_sizing_behavior(ListSizingBehavior::Auto) + .flex_grow(1.0) + .track_scroll(&self.scroll_handle) + .into_any_element() + } + } + }; + + let is_focused = self.focus_handle.is_focused(window); + let focus_ring_color = if is_focused { + border_focused + } else { + gpui::transparent_black() + }; + let mut container = div() + .id("diff-viewer") + .track_focus(&self.focus_handle) + // Bindings scoped to `DiffViewer` resolve to `diff::*` actions the + // workspace root handles; this crate cannot name them itself. + .key_context(if self.display_mode == DiffDisplayMode::ThreeWay { + "DiffViewer ConflictResolver" + } else { + "DiffViewer" + }) + .on_mouse_up(MouseButton::Left, cx.listener(Self::end_mouse_selection)) + .on_mouse_up_out(MouseButton::Left, cx.listener(Self::end_mouse_selection)) + .on_click(cx.listener(|this, _: &ClickEvent, window, cx| { + this.focus_handle.focus(window, cx); + cx.notify(); + })) + .v_flex() + .size_full() + .overflow_hidden() + // `relative` so the file-operations menu can be positioned against + // this container rather than the window. + .relative() + // Focus ring so j/k navigation is discoverable: an accent border when the + // pane holds keyboard focus, a transparent border of the same width + // otherwise so toggling focus never shifts layout or adds chrome. + .border_1() + .border_color(focus_ring_color) + .bg(editor_bg); + + if let Some(path) = &self.file_path { + let (additions, deletions) = Self::count_changes(&self.display_rows); + let unresolved = self.unresolved_conflict_count(); + let total_conflicts = self + .three_way_diff + .as_ref() + .map(|diff| diff.conflict_count()) + .unwrap_or(0); + let file_icon = Self::icon_for_path(path); + let path_str: SharedString = path.clone().into(); + let mode_label = match self.display_mode { + DiffDisplayMode::Unified => "Split", + DiffDisplayMode::SideBySide => "Unified", + DiffDisplayMode::ThreeWay => "3-Way", + }; + + container = container.child( + div() + .h_flex() + .w_full() + .h(px(26.)) + .px(px(10.)) + .gap(px(6.)) + .items_center() + .bg(toolbar_bg) + .border_b_1() + .border_color(border_variant) + .child( + Icon::new(file_icon) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child( + Label::new(path_str) + .size(LabelSize::Small) + .weight(FontWeight::MEDIUM) + .truncate(), + ) + .child({ + // A commit or stash diff has no staged/unstaged state — + // label it by where it came from instead. + let (badge_label, badge_color) = + if self.display_mode == DiffDisplayMode::ThreeWay { + ("Conflict", Color::Conflict) + } else { + self.source.badge() + }; + Badge::new(badge_label).color(badge_color) + }) + .child(div().flex_1()) + .child(if self.display_mode == DiffDisplayMode::ThreeWay { + Label::new(SharedString::from(conflict_progress_label( + unresolved, + total_conflicts, + ))) + .size(LabelSize::XSmall) + .weight(FontWeight::SEMIBOLD) + .color(if unresolved == 0 { + Color::Added + } else { + Color::Conflict + }) + .into_any_element() + } else { + div() + .h_flex() + .gap(px(4.)) + .child( + div() + .text_xs() + .font_family("Lilex") + .text_color(vc_added) + .font_weight(FontWeight::SEMIBOLD) + .child(SharedString::from(format!("+{}", additions))), + ) + .child( + div() + .text_xs() + .font_family("Lilex") + .text_color(vc_deleted) + .font_weight(FontWeight::SEMIBOLD) + .child(SharedString::from(format!("-{}", deletions))), + ) + .into_any_element() + }) + .child( + div() + .text_xs() + .text_color(text_placeholder_color) + .child("\u{00B7}"), + ) + .child({ + // Always-present partial-mode affordance so the line-level + // entry point is discoverable: emphasized while active, a + // muted "Partial (p)" hint otherwise. The keys it points at + // depend on the source — s/u for working-tree content, a/r + // for anything else. Hidden only for the three-way conflict + // view, which has no line-level operation at all. + let keys = self + .source + .operations() + .iter() + .map(|operation| operation.key()) + .collect::>() + .join(" / "); + let partial_tooltip: SharedString = if self.partial_mode { + format!( + "Line-level changes: select lines, then {keys}. Press p to exit." + ) + } else { + format!("Partial line-level changes ({keys}) — press p to toggle.") + } + .into(); + let (partial_label, partial_color) = if self.partial_mode { + ("Partial", Color::Warning.color(cx)) + } else { + ("Partial (p)", text_placeholder_color) + }; + if self.display_mode == DiffDisplayMode::ThreeWay { + div().into_any_element() + } else { + div() + .id("diff-partial-indicator") + .text_xs() + .text_color(partial_color) + .font_weight(FontWeight::SEMIBOLD) + .child(partial_label) + .tooltip(rgitui_ui::Tooltip::text(partial_tooltip)) + .into_any_element() + } + }) + .when(self.display_mode == DiffDisplayMode::ThreeWay, |element| { + element + .child( + Button::new("conflict-all-current", "All Current") + .size(ButtonSize::Compact) + .style(ButtonStyle::Subtle) + .tooltip("Use Current for every unresolved section") + .on_click(cx.listener( + |this, _: &ClickEvent, _, cx| { + this.use_current_for_all_conflicts(cx); + }, + )), + ) + .child( + Button::new("conflict-all-incoming", "All Incoming") + .size(ButtonSize::Compact) + .style(ButtonStyle::Subtle) + .tooltip("Use Incoming for every unresolved section") + .on_click(cx.listener( + |this, _: &ClickEvent, _, cx| { + this.use_incoming_for_all_conflicts(cx); + }, + )), + ) + .child( + Button::new("conflict-open-editor", "Open File") + .size(ButtonSize::Compact) + .style(ButtonStyle::Subtle) + .tooltip("Open the working file in your configured editor") + .on_click(cx.listener( + |this, _: &ClickEvent, _, cx| { + this.open_conflict_in_editor(cx); + }, + )), + ) + .child( + Button::new("conflict-stage-working", "Stage Working Copy") + .size(ButtonSize::Compact) + .style(ButtonStyle::Outlined) + .tooltip( + "Stage a manually edited working copy after checking for conflict markers", + ) + .on_click(cx.listener( + |this, _: &ClickEvent, _, cx| { + this.stage_conflict_working_copy(cx); + }, + )), + ) + .child( + Button::new("conflict-save", "Save & Stage") + .size(ButtonSize::Compact) + .style(ButtonStyle::Filled) + .disabled(unresolved > 0) + .tooltip(if unresolved > 0 { + "Choose a result for every conflict first" + } else { + "Write the Result and stage this file" + }) + .on_click(cx.listener( + |this, _: &ClickEvent, _, cx| { + this.save_conflict_result(cx); + }, + )), + ) + }) + .child(self.render_file_menu_button(cx)) + .child({ + let toggle_tooltip = match self.display_mode { + DiffDisplayMode::Unified => "Switch to side-by-side view (d)", + DiffDisplayMode::SideBySide => "Switch to unified view (d)", + DiffDisplayMode::ThreeWay => "Three-way conflict view", + }; + Button::new("toggle-diff-mode", mode_label) + .size(ButtonSize::Compact) + .style(ButtonStyle::Subtle) + .disabled(self.display_mode == DiffDisplayMode::ThreeWay) + .tooltip(toggle_tooltip) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.toggle_display_mode(cx); + })) + }), + ); + } + + if self.display_mode == DiffDisplayMode::ThreeWay { + let column = |label: &'static str, detail: &'static str, border: bool| { + div() + .flex_1() + .min_w_0() + .h_flex() + .h(px(24.)) + .px(px(8.)) + .gap(px(5.)) + .items_center() + .when(border, |element| { + element.border_r_1().border_color(border_variant) + }) + .child( + Label::new(label) + .size(LabelSize::XSmall) + .weight(FontWeight::SEMIBOLD), + ) + .child( + Label::new(detail) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + }; + container = container.child( + div() + .w_full() + .h_flex() + .bg(toolbar_bg) + .border_b_1() + .border_color(border_variant) + .child(column("Current", "Git ours", true)) + .child(column("Result", "staged on save", true)) + .child(column("Incoming", "Git theirs", false)), + ); + } + + // Wrap the list in a row container that holds a vertical scrollbar on + // the right, then append a horizontal scrollbar below only for the one + // mode that scrolls the whole list horizontally: no-wrap unified. Split + // and three-way always wrap (their columns never scroll horizontally), so + // they need no horizontal scrollbar. Wrap mode is backed by `gpui::list`, + // so the vertical scrollbar drives `ListState`; no-wrap unified stays on + // the uniform list's base `ScrollHandle`. + let vscroll: AnyElement = if wrap_enabled { + // Wrap mode is backed by `gpui::list`, whose measured content height + // grows as rows scroll into view. Drive the scrollbar from a fixed + // per-row estimate instead so the thumb keeps a constant size. + Scrollbar::vertical( + "diff-vscroll", + EstimatedListScroll::new( + self.wrap_list_state.clone(), + self.row_count(), + px(row_height), + ), + ) + .into_any_element() + } else { + Scrollbar::vertical( + "diff-vscroll", + self.scroll_handle.0.borrow().base_handle.clone(), + ) + .into_any_element() + }; + + let list_row = div() + .flex_grow(1.0) + .h_flex() + .items_stretch() + .min_h(px(0.)) + .child(list) + .child(vscroll); + + let mut body = div().v_flex().flex_grow(1.0).min_h(px(0.)).child(list_row); + let show_hscroll = !wrap_enabled && display_mode == DiffDisplayMode::Unified; + if show_hscroll { + let h_handle = self.scroll_handle.0.borrow().base_handle.clone(); + body = body.child(Scrollbar::horizontal("diff-hscroll", h_handle)); + } + container = container.child(body); + // Backdrop first so the menu paints on top of it. + if let Some(backdrop) = self.render_file_menu_backdrop(cx) { + container = container.child(backdrop); + } + if let Some(menu) = self.render_file_menu(cx) { + container = container.child(menu); + } + + container.into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text_conflict_diff(sections: Vec) -> ThreeWayFileDiff { + ThreeWayFileDiff { + path: std::path::PathBuf::from("conflict.txt"), + sections, + snapshot: ConflictSnapshot { + ancestor_oid: None, + ancestor_mode: None, + ours_oid: None, + ours_mode: None, + theirs_oid: None, + theirs_mode: None, + worktree: rgitui_git::ConflictWorktreeSnapshot::Missing, + }, + ancestor_exists: true, + ours_exists: true, + theirs_exists: true, + is_binary: false, + is_special_file: false, + result_mode: 0o100644, + } + } + + #[test] + fn assembles_independent_conflict_choices_without_normalizing_bytes() { + let diff = text_conflict_diff(vec![ + MergeSection::Resolved(b"prefix\r\n".to_vec()), + MergeSection::Conflict { + ancestor: b"base-a\r\n".to_vec(), + ours: b"current-a\r\n".to_vec(), + theirs: b"incoming-a\r\n".to_vec(), + }, + MergeSection::Resolved(b"middle\r\n".to_vec()), + MergeSection::Conflict { + ancestor: b"base-b".to_vec(), + ours: b"current-b".to_vec(), + theirs: b"incoming-b".to_vec(), + }, + ]); + + let content = assemble_text_resolution( + &diff, + &[ + Some(ConflictChoice::Theirs), + Some(ConflictChoice::BothOursFirst), + ], + ) + .unwrap(); + + assert_eq!( + content, + b"prefix\r\nincoming-a\r\nmiddle\r\ncurrent-bincoming-b" + ); + } + + #[test] + fn unresolved_choice_cannot_be_assembled() { + let diff = text_conflict_diff(vec![MergeSection::Conflict { + ancestor: b"base\n".to_vec(), + ours: b"current\n".to_vec(), + theirs: b"incoming\n".to_vec(), + }]); + assert_eq!(assemble_text_resolution(&diff, &[None]), None); + } + + #[test] + fn conflict_progress_copy_reports_state_directly() { + assert_eq!(conflict_progress_label(2, 2), "2 unresolved"); + assert_eq!(conflict_progress_label(1, 2), "1 resolved · 1 remaining"); + assert_eq!(conflict_progress_label(0, 2), "All 2 resolved"); + } + + #[test] + fn three_way_line_numbers_continue_across_merge_sections() { + let diff = text_conflict_diff(vec![ + MergeSection::Resolved(b"header\n".to_vec()), + MergeSection::Conflict { + ancestor: b"base\n".to_vec(), + ours: b"current one\ncurrent two\n".to_vec(), + theirs: b"incoming\n".to_vec(), + }, + MergeSection::Resolved(b"footer\n".to_vec()), + ]); + let rows = DiffViewer::compute_three_way_rows(&diff, &[Some(ConflictChoice::Ours)]); + let numbers = rows + .iter() + .filter_map(|row| match row { + ThreeWayRow::Triple { + left_num, + mid_num, + right_num, + .. + } => Some((*left_num, *mid_num, *right_num)), + ThreeWayRow::HunkHeader { .. } => None, + }) + .collect::>(); + + assert_eq!( + numbers, + vec![ + (Some(1), Some(1), Some(1)), + (Some(2), Some(2), Some(2)), + (Some(3), Some(3), None), + (Some(4), Some(4), Some(3)), + ] + ); + } + + #[test] + fn mouse_selection_range_extends_forward_inclusively() { + assert_eq!(DiffViewer::range_from_anchor(2, 6), 2..7); + } + + #[test] + fn mouse_selection_range_extends_backward_inclusively() { + assert_eq!(DiffViewer::range_from_anchor(6, 2), 2..7); + } + use rgitui_git::{DiffHunk, FileChangeKind}; + + fn test_hunk(line: DiffLine) -> DiffHunk { + DiffHunk { + old_start: 1, + old_lines: 1, + new_start: 1, + new_lines: 1, + header: "@@ -1 +1 @@".to_string(), + lines: vec![line], + } + } + + fn test_file_diff(hunks: Vec, additions: usize, deletions: usize) -> FileDiff { + FileDiff { + path: std::path::PathBuf::from("foo.txt"), + hunks, + additions, + deletions, + kind: FileChangeKind::Modified, + } + } + + fn test_cache_key(diff: &FileDiff, staged: bool) -> DisplayCacheKey { + DisplayCacheKey::new( + diff.path.display().to_string(), + true, + DiffSource::working_tree(staged), + diff, + ) + } + + fn test_cached_rows(diff: &FileDiff) -> CachedDisplayRows { + CachedDisplayRows { + display_rows: Arc::new(Vec::new()), + sbs_rows: Arc::new(Vec::new()), + display_longest_row_ix: 0, + source_diff: Arc::new(diff.clone()), + } + } + + #[test] + fn file_diffs_render_equal_treats_identical_diffs_as_equal() { + let a = test_file_diff(vec![test_hunk(DiffLine::Addition("hello".into()))], 1, 0); + let b = a.clone(); + assert!(file_diffs_render_equal(&a, &b)); + } + + #[test] + fn file_diffs_render_equal_detects_in_place_content_change() { + // Same hunk count and same +/- totals, but different line text. The old + // count-only heuristic wrongly treated these as equal and skipped the + // refresh, leaving stale text on screen — this is the regression guard. + let a = test_file_diff(vec![test_hunk(DiffLine::Addition("hello".into()))], 1, 0); + let b = test_file_diff(vec![test_hunk(DiffLine::Addition("world".into()))], 1, 0); + assert_eq!(a.additions, b.additions); + assert_eq!(a.deletions, b.deletions); + assert_eq!(a.hunks.len(), b.hunks.len()); + assert!(!file_diffs_render_equal(&a, &b)); + } + + #[test] + fn prepared_generation_guard_rejects_stale_results() { + assert!(should_apply_prepared(7, 7)); + assert!(!should_apply_prepared(8, 7)); + assert!(!should_apply_prepared(7, 8)); + } + + // ── DiffSource provenance ───────────────────────────────────── + // + // Regression guards for the bug where selecting a past commit showed the + // diff as unstaged working-tree content. The viewer used a `staged: bool` + // that every commit call site set to `false`, so a commit diff rendered an + // "Unstaged" badge, a live "Stage Hunk" button on every hunk, and the + // "Partial (p)" staging hint — and `s` really did emit a stage request, + // which the workspace resolved against the *working tree*. + + const OID: &str = "9f2c1ab4d5e6f708192a3b4c5d6e7f8091a2b3c4"; + + #[test] + fn commit_diff_is_never_labelled_staged_or_unstaged() { + let (label, _) = DiffSource::Commit(OID.to_string()).badge(); + assert_ne!( + label, "Unstaged", + "a commit diff must not claim to be unstaged" + ); + assert_ne!(label, "Staged"); + assert_eq!(label, "Committed"); + } + + #[test] + fn stash_diff_is_never_labelled_staged_or_unstaged() { + let (label, _) = DiffSource::Stash(OID.to_string()).badge(); + assert_ne!(label, "Unstaged"); + assert_ne!(label, "Staged"); + assert_eq!(label, "Stashed"); + } + + #[test] + fn working_tree_badges_keep_their_staged_unstaged_labels() { + assert_eq!(DiffSource::Worktree.badge().0, "Unstaged"); + assert_eq!(DiffSource::Index.badge().0, "Staged"); + } + + #[test] + fn historical_sources_offer_no_staging_affordance() { + for source in [ + DiffSource::Commit(OID.to_string()), + DiffSource::Stash(OID.to_string()), + DiffSource::Compare { + from: "main".to_string(), + to: "feature".to_string(), + }, + ] { + assert!(source.is_historical(), "{source:?} should be historical"); + assert_eq!( + source.staging_action(), + None, + "{source:?} must offer no stage/unstage button or key binding" + ); + assert!( + !source.offers(DiffOperation::Stage) && !source.offers(DiffOperation::Unstage), + "{source:?} must offer neither staging operation" + ); + } + } + + #[test] + fn historical_sources_offer_apply_and_revert_instead() { + for source in [ + DiffSource::Commit(OID.to_string()), + DiffSource::Stash(OID.to_string()), + DiffSource::Compare { + from: "main".to_string(), + to: "feature".to_string(), + }, + ] { + assert_eq!( + source.operations(), + &[DiffOperation::Apply, DiffOperation::Revert], + "{source:?} must offer both working-tree operations" + ); + assert!( + source.patch_source().is_some(), + "{source:?} must resolve to a tree pair to generate the patch from" + ); + } + } + + #[test] + fn mutable_sources_offer_exactly_one_staging_action() { + assert!(!DiffSource::Worktree.is_historical()); + assert!(!DiffSource::Index.is_historical()); + assert_eq!( + DiffSource::Worktree.staging_action(), + Some(DiffOperation::Stage) + ); + assert_eq!( + DiffSource::Index.staging_action(), + Some(DiffOperation::Unstage) + ); + } + + #[test] + fn working_tree_constructor_maps_the_staged_flag() { + assert_eq!(DiffSource::working_tree(false), DiffSource::Worktree); + assert_eq!(DiffSource::working_tree(true), DiffSource::Index); + } + + #[test] + fn commit_id_is_carried_only_by_historical_sources() { + assert_eq!(DiffSource::Commit(OID.to_string()).commit_id(), Some(OID)); + assert_eq!(DiffSource::Stash(OID.to_string()).commit_id(), Some(OID)); + assert_eq!(DiffSource::Worktree.commit_id(), None); + assert_eq!(DiffSource::Index.commit_id(), None); + } + + #[test] + fn hunk_button_labels_match_the_action() { + assert_eq!(DiffOperation::Stage.hunk_button_label(), "Stage Hunk"); + assert_eq!(DiffOperation::Unstage.hunk_button_label(), "Unstage Hunk"); + assert_eq!(DiffOperation::Apply.hunk_button_label(), "Apply Hunk"); + assert_eq!(DiffOperation::Revert.hunk_button_label(), "Revert Hunk"); + assert_eq!(DiffOperation::Stage.compact_hunk_button_label(), "Stage"); + assert_eq!( + DiffOperation::Unstage.compact_hunk_button_label(), + "Unstage" + ); + assert_eq!(DiffOperation::Apply.compact_hunk_button_label(), "Apply"); + assert_eq!(DiffOperation::Revert.compact_hunk_button_label(), "Revert"); + } + + #[test] + fn staging_a_commit_diff_is_rejected_with_an_actionable_message() { + // A staging request from a commit diff would reach + // `GitProject::stage_hunk_at`, which resolves the hunk index against the + // working tree — staging unrelated uncommitted edits. + for action in [DiffOperation::Stage, DiffOperation::Unstage] { + let message = DiffSource::Commit(OID.to_string()) + .reject_operation(action) + .expect("staging a commit diff must be rejected"); + assert!( + message.contains("9f2c1ab"), + "message names the commit: {message}" + ); + assert!( + message.contains("sidebar"), + "message tells the user where to go instead: {message}" + ); + assert!(message.ends_with('.'), "message is a sentence: {message}"); + } + } + + #[test] + fn staging_a_stash_diff_is_rejected_with_an_actionable_message() { + for action in [DiffOperation::Stage, DiffOperation::Unstage] { + let message = DiffSource::Stash(OID.to_string()) + .reject_operation(action) + .expect("staging a stash diff must be rejected"); + assert!( + message.contains("pop the stash"), + "message names the way forward: {message}" + ); + assert!(message.ends_with('.')); + } + } + + #[test] + fn a_comparison_cannot_be_staged_but_says_what_to_do_instead() { + let source = DiffSource::Compare { + from: "main".to_string(), + to: "feature".to_string(), + }; + for action in [DiffOperation::Stage, DiffOperation::Unstage] { + let message = source + .reject_operation(action) + .expect("a comparison must not be staged"); + assert!( + message.contains("main...feature"), + "message names the comparison: {message}" + ); + assert!( + message.contains("press a to apply"), + "message points at the operation that does work: {message}" + ); + assert!(message.ends_with('.')); + } + } + + #[test] + fn mutable_sources_cannot_apply_or_revert() { + // Working-tree content is already in the working tree, so there is + // nothing to bring in — and `patch_source` has no tree pair for it. + for source in [DiffSource::Worktree, DiffSource::Index] { + for action in [DiffOperation::Apply, DiffOperation::Revert] { + assert!( + !source.offers(action), + "{source:?} must not offer {action:?}" + ); + let message = source + .reject_operation(action) + .expect("the request must be rejected"); + assert!( + message.contains("already in your working tree"), + "got: {message}" + ); + } + assert!(source.patch_source().is_none()); + } + } + + #[test] + fn every_source_offers_at_least_one_operation() { + // No displayable source may be inert: each one has an affordance to render. + for source in [ + DiffSource::Worktree, + DiffSource::Index, + DiffSource::Commit(OID.to_string()), + DiffSource::Stash(OID.to_string()), + DiffSource::Compare { + from: "main".to_string(), + to: "feature".to_string(), + }, + ] { + assert!( + !source.operations().is_empty(), + "{source:?} offers nothing at all" + ); + for operation in source.operations() { + assert_eq!( + source.reject_operation(*operation), + None, + "{source:?} offers {operation:?} but rejects it" + ); + } + } + } + + #[test] + fn a_commit_source_resolves_to_its_own_change() { + let source = DiffSource::Commit(OID.to_string()); + assert_eq!( + source.patch_source(), + Some(WorktreePatchSource::Commit( + git2::Oid::from_str(OID).unwrap() + )) + ); + } + + #[test] + fn a_comparison_resolves_to_both_of_its_endpoints() { + let source = DiffSource::Compare { + from: "main".to_string(), + to: "origin/feature".to_string(), + }; + assert_eq!( + source.patch_source(), + Some(WorktreePatchSource::Compare { + from: "main".to_string(), + to: "origin/feature".to_string(), + }), + "the endpoints must survive verbatim: their order decides which \ + side an apply pulls in" + ); + } + + #[test] + fn revision_labels_name_the_source() { + assert_eq!(DiffSource::Worktree.revision_label(), None); + assert_eq!(DiffSource::Index.revision_label(), None); + assert_eq!( + DiffSource::Commit(OID.to_string()) + .revision_label() + .unwrap(), + "9f2c1ab" + ); + assert_eq!( + DiffSource::Compare { + from: "main".to_string(), + to: "feature".to_string(), + } + .revision_label() + .unwrap(), + "main...feature" + ); + } + + #[test] + fn operation_labels_and_keys_are_distinct_per_operation() { + let operations = [ + DiffOperation::Stage, + DiffOperation::Unstage, + DiffOperation::Apply, + DiffOperation::Revert, + ]; + let keys: Vec<&str> = operations.iter().map(|o| o.key()).collect(); + let unique: HashSet<&&str> = keys.iter().collect(); + assert_eq!(unique.len(), keys.len(), "keys collide: {keys:?}"); + for operation in operations { + assert!(!operation.hunk_button_label().is_empty()); + assert!(!operation.file_menu_label().is_empty()); + } + assert!(DiffOperation::Stage.is_staging()); + assert!(DiffOperation::Unstage.is_staging()); + assert!(!DiffOperation::Apply.is_staging()); + assert!(!DiffOperation::Revert.is_staging()); + assert!(DiffOperation::Apply.writes_files()); + assert!(DiffOperation::Revert.writes_files()); + assert!(!DiffOperation::Stage.writes_files()); + assert!(!DiffOperation::Unstage.writes_files()); + } + + #[test] + fn valid_staging_requests_are_not_rejected() { + assert_eq!( + DiffSource::Worktree.reject_operation(DiffOperation::Stage), + None + ); + assert_eq!( + DiffSource::Index.reject_operation(DiffOperation::Unstage), + None + ); + } + + #[test] + fn mismatched_working_tree_requests_are_rejected() { + assert!(DiffSource::Worktree + .reject_operation(DiffOperation::Unstage) + .is_some()); + assert!(DiffSource::Index + .reject_operation(DiffOperation::Stage) + .is_some()); + } + + #[test] + fn display_cache_distinguishes_a_commit_from_identical_worktree_content() { + // The same path with byte-identical hunks can legitimately be shown as + // both a commit diff and a working-tree diff. Provenance is part of the + // cache key so the two never share prepared rows (and therefore never + // share a stale badge or button set). + let diff = test_file_diff(vec![test_hunk(DiffLine::Addition("hello".into()))], 1, 0); + let commit_key = DisplayCacheKey::new( + "foo.txt".to_string(), + true, + DiffSource::Commit(OID.to_string()), + &diff, + ); + let worktree_key = + DisplayCacheKey::new("foo.txt".to_string(), true, DiffSource::Worktree, &diff); + let index_key = DisplayCacheKey::new("foo.txt".to_string(), true, DiffSource::Index, &diff); + assert_ne!(commit_key, worktree_key); + assert_ne!(commit_key, index_key); + assert_ne!(worktree_key, index_key); + } + + #[test] + fn side_by_side_rows_reuse_prepared_unified_text() { + let display_rows = vec![ + DisplayRow::HunkHeader { + header: "@@ -1 +1 @@".into(), + context_name: "example".into(), + hunk_index: 0, + }, + DisplayRow::Line { + old_num: Some(1), + new_num: None, + styled: StyledLine::plain("old value"), + kind: DisplayLineKind::Deletion, + }, + DisplayRow::Line { + old_num: None, + new_num: Some(1), + styled: StyledLine::plain("new value"), + kind: DisplayLineKind::Addition, + }, + DisplayRow::Line { + old_num: Some(2), + new_num: Some(2), + styled: StyledLine::plain("context"), + kind: DisplayLineKind::Context, + }, + ]; + + let rows = DiffViewer::compute_sbs_rows(&display_rows); + assert_eq!(rows.len(), 3); + match &rows[1] { + SideBySideRow::Pair { + left_num, + left_styled, + right_num, + right_styled, + .. + } => { + assert_eq!(*left_num, Some(1)); + assert_eq!(left_styled.text.as_ref(), "old value"); + assert_eq!(*right_num, Some(1)); + assert_eq!(right_styled.text.as_ref(), "new value"); + } + SideBySideRow::HunkHeader { .. } => panic!("expected paired change row"), + } + } + + #[test] + fn display_cache_distinguishes_staged_and_unstaged_diffs() { + let diff = test_file_diff(vec![test_hunk(DiffLine::Addition("hello".into()))], 1, 0); + let unstaged_key = test_cache_key(&diff, false); + let staged_key = test_cache_key(&diff, true); + assert_ne!(unstaged_key, staged_key); + + let mut cache = DisplayCache::default(); + cache.insert(unstaged_key, test_cached_rows(&diff)); + assert!(cache.get(&staged_key, &diff).is_none()); + } + + #[test] + fn display_cache_distinguishes_same_count_changed_content() { + let before = test_file_diff(vec![test_hunk(DiffLine::Addition("hello".into()))], 1, 0); + let after = test_file_diff(vec![test_hunk(DiffLine::Addition("world".into()))], 1, 0); + assert_eq!(before.hunks.len(), after.hunks.len()); + assert_eq!(before.additions, after.additions); + assert_eq!(before.deletions, after.deletions); + + let before_key = test_cache_key(&before, false); + let after_key = test_cache_key(&after, false); + assert_ne!(before_key, after_key); + + let mut cache = DisplayCache::default(); + cache.insert(before_key, test_cached_rows(&before)); + assert!(cache.get(&after_key, &after).is_none()); + } + + #[test] + fn display_cache_rejects_fingerprint_collision_with_different_source() { + let before = test_file_diff(vec![test_hunk(DiffLine::Addition("hello".into()))], 1, 0); + let after = test_file_diff(vec![test_hunk(DiffLine::Addition("world".into()))], 1, 0); + let key = test_cache_key(&before, false); + let mut cache = DisplayCache::default(); + cache.insert(key.clone(), test_cached_rows(&before)); + + assert!(cache.get(&key, &after).is_none()); + assert!(!cache.entries.contains_key(&key)); + } + + #[test] + fn display_cache_evicts_least_recently_used_entry() { + let diff = test_file_diff(vec![test_hunk(DiffLine::Addition("hello".into()))], 1, 0); + let mut cache = DisplayCache::default(); + let mut keys = Vec::new(); + + for i in 0..DISPLAY_CACHE_MAX_ENTRIES { + let mut key = test_cache_key(&diff, false); + key.file_path = format!("file-{i}.txt"); + cache.insert(key.clone(), test_cached_rows(&diff)); + keys.push(key); + } + + // Refresh the oldest entry, making the second entry the LRU victim. + assert!(cache.get(&keys[0], &diff).is_some()); + let mut newest = test_cache_key(&diff, false); + newest.file_path = "newest.txt".into(); + cache.insert(newest.clone(), test_cached_rows(&diff)); + + assert!(cache.entries.contains_key(&keys[0])); + assert!(!cache.entries.contains_key(&keys[1])); + assert!(cache.entries.contains_key(&newest)); + assert_eq!(cache.entries.len(), DISPLAY_CACHE_MAX_ENTRIES); + } + + #[test] + fn is_change_line_accepts_additions_and_deletions_only() { + // Addition: (None, Some) — staged on the new side. + assert!(DiffViewer::is_change_line(&(None, Some(3)))); + // Deletion: (Some, None) — staged on the old side. Filtering on + // `new_num.is_some()` would drop these. + assert!(DiffViewer::is_change_line(&(Some(7), None))); + // Context: (Some, Some) — carried by the git layer, never a change target. + assert!(!DiffViewer::is_change_line(&(Some(7), Some(7)))); + // Empty / non-line row. + assert!(!DiffViewer::is_change_line(&(None, None))); + } + + #[test] + fn file_diffs_render_equal_detects_count_and_kind_changes() { + let a = test_file_diff(vec![test_hunk(DiffLine::Addition("hello".into()))], 1, 0); + let emptied = test_file_diff(vec![], 0, 0); + assert!(!file_diffs_render_equal(&a, &emptied)); + + let mut renamed = a.clone(); + renamed.kind = FileChangeKind::Added; + assert!(!file_diffs_render_equal(&a, &renamed)); + } + + #[test] + fn icon_for_path_uses_expected_fallback_icons() { + assert_eq!(DiffViewer::icon_for_path("src/main.rs"), IconName::File); + assert_eq!( + DiffViewer::icon_for_path("config/settings.jsonc"), + IconName::Settings + ); + assert_eq!(DiffViewer::icon_for_path("Cargo.lock"), IconName::Pin); + } + + #[test] + fn syntax_for_path_maps_common_extension_fallbacks() { + let tsx = DiffViewer::syntax_for_path("src/app.tsx") + .expect("tsx fallback should resolve") + .name + .as_str(); + let jsonc = DiffViewer::syntax_for_path("config/biome.jsonc") + .expect("jsonc fallback should resolve") + .name + .as_str(); + let env = DiffViewer::syntax_for_path(".env") + .expect("dot env fallback should resolve") + .name + .as_str(); + let sql = DiffViewer::syntax_for_path("queries/migrate.sql") + .expect("sql fallback should resolve") + .name + .as_str(); + + assert_eq!(tsx, "JavaScript"); + assert_eq!(jsonc, "JSON"); + assert_eq!(env, "Bourne Again Shell (bash)"); + assert_eq!(sql, "SQL"); + } + + #[test] + fn icon_for_path_covers_common_extensions() { + // Code files + assert_eq!(DiffViewer::icon_for_path("main.rs"), IconName::File); + assert_eq!(DiffViewer::icon_for_path("server.go"), IconName::File); + assert_eq!(DiffViewer::icon_for_path("script.py"), IconName::File); + assert_eq!(DiffViewer::icon_for_path("app.js"), IconName::File); + assert_eq!(DiffViewer::icon_for_path("types.ts"), IconName::File); + // Config files + assert_eq!(DiffViewer::icon_for_path("Cargo.toml"), IconName::Settings); + assert_eq!(DiffViewer::icon_for_path("config.yaml"), IconName::Settings); + assert_eq!(DiffViewer::icon_for_path("deploy.yml"), IconName::Settings); + assert_eq!( + DiffViewer::icon_for_path("settings.json"), + IconName::Settings + ); + // Lock files + assert_eq!(DiffViewer::icon_for_path("yarn.lock"), IconName::Pin); + // package-lock.json has extension .json → Settings, not Pin + assert_eq!( + DiffViewer::icon_for_path("package-lock.json"), + IconName::Settings + ); + // Other + assert_eq!(DiffViewer::icon_for_path("styles.css"), IconName::File); + assert_eq!(DiffViewer::icon_for_path("index.html"), IconName::File); + assert_eq!(DiffViewer::icon_for_path("README.md"), IconName::File); + // No extension → File + assert_eq!(DiffViewer::icon_for_path("Makefile"), IconName::File); + assert_eq!(DiffViewer::icon_for_path(".gitignore"), IconName::File); + } + + #[test] + fn extract_context_name_parses_hunk_header() { + // Normal hunk with function name + let header = "@@ -1,5 +1,7 @@ fn main()"; + assert_eq!(DiffViewer::extract_context_name(header), "fn main()"); + + // Multi-line Rust function signature + let header = "@@ -10,5 +10,7 @@ pub fn process_items(items: Vec) where T: Clone"; + assert_eq!( + DiffViewer::extract_context_name(header), + "pub fn process_items(items: Vec) where T: Clone" + ); + + // Empty context after @@ (no function name) + let header = "@@ -0,0 +1,4 @@"; + assert_eq!(DiffViewer::extract_context_name(header), ""); + + // No second @@ + let header = "@@ -1,3 +1,4"; + assert_eq!(DiffViewer::extract_context_name(header), ""); + + // Not a hunk header at all + let header = "just some text"; + assert_eq!(DiffViewer::extract_context_name(header), ""); + + // Class method + let header = "@@ -5,10 +5,12 @@ impl MyStruct {"; + assert_eq!( + DiffViewer::extract_context_name(header), + "impl MyStruct {" + ); + } + + #[test] + fn extract_line_range_parses_hunk_header() { + // Normal multi-line hunk + let header = "@@ -1,5 +1,7 @@ fn main()"; + assert_eq!(DiffViewer::extract_line_range(header), "@@ -1,5 +1,7 @@"); + + // Single-line hunk (deletion only) + let header = "@@ -3,1 +3,0 @@"; + assert_eq!(DiffViewer::extract_line_range(header), "@@ -3,1 +3,0 @@"); + + // Single-line hunk (addition only) + let header = "@@ -0,0 +1,1 @@"; + assert_eq!(DiffViewer::extract_line_range(header), "@@ -0,0 +1,1 @@"); + + // Non-hunk string returns as-is + let header = "no markers here"; + assert_eq!(DiffViewer::extract_line_range(header), "no markers here"); + } + + #[test] + fn syntax_for_path_covers_rust_and_config_extensions() { + let rs = DiffViewer::syntax_for_path("src/lib.rs") + .expect("rust file should resolve") + .name + .as_str(); + assert_eq!(rs, "Rust"); + + let toml = DiffViewer::syntax_for_path("workspace.toml") + .expect("toml file should resolve") + .name + .as_str(); + // Fallback maps .toml → YAML (closest in structure for basic highlighting) + assert_eq!(toml, "YAML"); + + let yaml = DiffViewer::syntax_for_path("ci.yml") + .expect("yaml file should resolve") + .name + .as_str(); + assert_eq!(yaml, "YAML"); + + let css = DiffViewer::syntax_for_path("style.css") + .expect("css file should resolve") + .name + .as_str(); + assert_eq!(css, "CSS"); + } + + // --- Word-level diff tests --- + + #[test] + fn split_word_ranges_simple() { + let words = DiffViewer::split_word_ranges("hello world"); + assert_eq!(words.len(), 2); + assert_eq!(words[0], 0..5); // "hello" + assert_eq!(words[1], 6..11); // "world" + } + + #[test] + fn split_word_ranges_single_word() { + let words = DiffViewer::split_word_ranges("hello"); + assert_eq!(words.len(), 1); + assert_eq!(words[0], 0..5); + } + + #[test] + fn split_word_ranges_empty() { + assert!(DiffViewer::split_word_ranges("").is_empty()); + } + + #[test] + fn split_word_ranges_only_spaces() { + // All-whitespace input yields no word ranges + assert!(DiffViewer::split_word_ranges(" ").is_empty()); + } + + #[test] + fn split_word_ranges_leading_trailing_spaces() { + // Leading/trailing spaces are trimmed; only "foo" is a word + let words = DiffViewer::split_word_ranges(" foo "); + assert_eq!(words.len(), 1); + assert_eq!(words[0], 2..5); + } + + #[test] + fn split_word_ranges_multiple_spaces_between_words() { + // Multiple spaces between words: each gap is a separator + let words = DiffViewer::split_word_ranges("foo bar"); + assert_eq!(words.len(), 2); + assert_eq!(words[0], 0..3); // "foo" + assert_eq!(words[1], 5..8); // "bar" (indices skip the two spaces) + } + + #[test] + fn split_word_ranges_mixed_whitespace() { + let words = DiffViewer::split_word_ranges("foo\tbar\nbaz"); + assert_eq!(words.len(), 3); + assert_eq!(words[0], 0..3); // "foo" + assert_eq!(words[1], 4..7); // "bar" (\t at index 3) + assert_eq!(words[2], 8..11); // "baz" (\n at index 7) + } + + #[test] + fn split_word_ranges_with_unicode() { + // Non-ASCII chars treated as word characters + let words = DiffViewer::split_word_ranges("日本語 English"); + assert_eq!(words.len(), 2); + assert_eq!(words[0], 0..9); // "日本語" = 3 chars × 3 bytes = 9 bytes + assert_eq!(words[1], 10..17); // " English" — space at 9, then 7 chars + } + + #[test] + fn split_word_ranges_punctuation_splits_tokens() { + // Punctuation should produce separate tokens, not stick to adjacent words. + let words = DiffViewer::split_word_ranges("foo.bar(x, y)"); + // "foo" "." "bar" "(" "x" "," "y" ")" + assert_eq!(words.len(), 8); + assert_eq!(words[0], 0..3); // "foo" + assert_eq!(words[1], 3..4); // "." + assert_eq!(words[2], 4..7); // "bar" + assert_eq!(words[3], 7..8); // "(" + assert_eq!(words[4], 8..9); // "x" + assert_eq!(words[5], 9..10); // "," + assert_eq!(words[6], 11..12); // "y" + assert_eq!(words[7], 12..13); // ")" + } + + #[test] + fn split_word_ranges_rust_code() { + let words = DiffViewer::split_word_ranges("let x = foo::bar();"); + // "let" "x" "=" "foo" ":" ":" "bar" "(" ")" ";" + assert_eq!(words.len(), 10); + assert_eq!(words[0], 0..3); // "let" + assert_eq!(words[3], 8..11); // "foo" + assert_eq!(words[6], 13..16); // "bar" + } + + #[test] + fn compute_word_diff_function_arg_change() { + let (del_spans, add_spans) = + DiffViewer::compute_word_diff("compute(x, y)", "compute(a, b)"); + // Changed tokens: "x" → "a" and "y" → "b" reported as separate spans. + // Spans are extended to absorb adjacent punctuation for visual grouping. + assert_eq!(del_spans.len(), 2); + assert_eq!(del_spans[0], 8..10); // "x" extended with trailing comma + assert_eq!(del_spans[1], 11..13); // "y" extended with trailing ) + assert_eq!(add_spans.len(), 2); + assert_eq!(add_spans[0], 8..10); // "a" extended with trailing comma + assert_eq!(add_spans[1], 11..13); // "b" extended with trailing ) + } + + #[test] + fn compute_word_diff_method_name_change() { + // Changing just the method name in a chain. + let (del_spans, add_spans) = DiffViewer::compute_word_diff("foo.bar()", "foo.baz()"); + assert_eq!(del_spans.len(), 1); + assert_eq!(del_spans[0], 4..9); // "bar" extended with trailing () + assert_eq!(add_spans.len(), 1); + assert_eq!(add_spans[0], 4..9); // "baz" extended with trailing () + } + + #[test] + fn compute_word_diff_unchanged() { + let (del_spans, add_spans) = DiffViewer::compute_word_diff("hello world", "hello world"); + assert!(del_spans.is_empty()); + assert!(add_spans.is_empty()); + } + + #[test] + fn compute_word_diff_simple_word_change() { + // "foo" → "bar": both single words, should produce one del + one add + let (del_spans, add_spans) = DiffViewer::compute_word_diff("foo", "bar"); + assert_eq!(del_spans.len(), 1); + assert_eq!(del_spans[0].clone(), 0..3); // "foo" + assert_eq!(add_spans.len(), 1); + assert_eq!(add_spans[0].clone(), 0..3); // "bar" + } + + #[test] + fn compute_word_diff_addition_only() { + // New text has "bar" added + let (del_spans, add_spans) = DiffViewer::compute_word_diff("foo", "foo bar"); + assert!(del_spans.is_empty()); + assert_eq!(add_spans.len(), 1); + assert_eq!(add_spans[0].clone(), 4..7); // "bar" + } + + #[test] + fn compute_word_diff_deletion_only() { + // "bar" deleted from old text + let (del_spans, add_spans) = DiffViewer::compute_word_diff("foo bar", "foo"); + assert_eq!(del_spans.len(), 1); + assert_eq!(del_spans[0].clone(), 4..7); // "bar" + assert!(add_spans.is_empty()); + } + + #[test] + fn compute_word_diff_both_empty() { + let (del_spans, add_spans) = DiffViewer::compute_word_diff("", ""); + assert!(del_spans.is_empty()); + assert!(add_spans.is_empty()); + } + + #[test] + fn compute_word_diff_word_replaced_in_sentence() { + // "The quick brown fox" → "The slow brown fox" + let (del_spans, add_spans) = + DiffViewer::compute_word_diff("The quick brown fox", "The slow brown fox"); + assert_eq!(del_spans.len(), 1); + assert_eq!(del_spans[0].clone(), 4..9); // "quick" + assert_eq!(add_spans.len(), 1); + assert_eq!(add_spans[0].clone(), 4..8); // "slow" + } + + #[test] + fn compute_word_diff_empty_old_new_has_content() { + // Pure addition — all tokens differ, nearby spans merge into one block. + let (del_spans, add_spans) = DiffViewer::compute_word_diff("", "hello world"); + assert!(del_spans.is_empty()); + assert_eq!(add_spans.len(), 1); // merged into single block + assert_eq!(add_spans[0], 0..11); + } + + #[test] + fn compute_word_diff_old_has_content_new_empty() { + // Pure deletion — all tokens differ, nearby spans merge into one block. + let (del_spans, add_spans) = DiffViewer::compute_word_diff("hello world", ""); + assert_eq!(del_spans.len(), 1); // merged into single block + assert_eq!(del_spans[0], 0..11); + assert!(add_spans.is_empty()); + } + + // --- apply_word_highlights tests --- + + #[test] + #[allow(clippy::single_range_in_vec_init)] + fn apply_word_highlights_skips_when_text_empty() { + let mut line = StyledLine::plain(""); + let bg = HighlightStyle::default(); + line.apply_word_highlights([0..5].to_vec(), vec![], bg, bg); + assert!(line.highlights.is_empty()); + } + + #[test] + #[allow(clippy::single_range_in_vec_init)] + fn apply_word_highlights_clips_deletion_spans_to_text_length() { + let mut line = StyledLine::plain("hi"); // len = 2 + let bg = HighlightStyle::default(); + line.apply_word_highlights(vec![0..10], vec![], bg, bg); + assert_eq!(line.highlights.len(), 1); + assert_eq!(line.highlights[0].0.start, 0); + assert_eq!(line.highlights[0].0.end, 2); + } + + #[test] + #[allow(clippy::single_range_in_vec_init)] + fn apply_word_highlights_clips_addition_spans_to_text_length() { + let mut line = StyledLine::plain("hi"); // len = 2 + let bg = HighlightStyle::default(); + line.apply_word_highlights(vec![], vec![0..100], bg, bg); + assert_eq!(line.highlights.len(), 1); + assert_eq!(line.highlights[0].0.end, 2); + } + + #[test] + #[allow(clippy::single_range_in_vec_init)] + fn apply_word_highlights_drops_spans_clipped_to_empty() { + let mut line = StyledLine::plain("hi"); // len = 2 + let bg = HighlightStyle::default(); + line.apply_word_highlights(vec![5..6], vec![], bg, bg); + assert!(line.highlights.is_empty()); + } + + #[test] + #[allow(clippy::single_range_in_vec_init)] + fn apply_word_highlights_preserves_valid_spans() { + let mut line = StyledLine::plain("hello"); + let bg = HighlightStyle::default(); + line.apply_word_highlights(vec![0..2, 3..5], vec![], bg, bg); + assert_eq!(line.highlights.len(), 2); + assert_eq!(line.highlights[0].0, 0..2); + assert_eq!(line.highlights[1].0, 3..5); + } + + #[test] + #[allow(clippy::single_range_in_vec_init)] + fn apply_word_highlights_both_deletion_and_addition_spans() { + let mut line = StyledLine::plain("hello"); + let bg = HighlightStyle::default(); + line.apply_word_highlights(vec![0..1], vec![0..1], bg, bg); + // Both del and add produce word spans at 0..1 (sorted stably). + assert_eq!(line.highlights.len(), 2); + assert_eq!(line.highlights[0].0, 0..1); + assert_eq!(line.highlights[1].0, 0..1); + } + + #[test] + #[allow(clippy::single_range_in_vec_init)] + fn apply_word_highlights_merges_with_syntect_spans() { + // Simulate syntect covering the full text with two spans. + let syn_style_a = HighlightStyle { + color: Some(gpui::hsla(0.0, 1.0, 0.5, 1.0)), + ..Default::default() + }; + let syn_style_b = HighlightStyle { + color: Some(gpui::hsla(0.5, 1.0, 0.5, 1.0)), + ..Default::default() + }; + let word_bg = HighlightStyle { + background_color: Some(gpui::hsla(0.0, 0.5, 0.5, 0.25)), + ..Default::default() + }; + + // Text: "hello world" (11 bytes) + // Syntect: [0..5, syn_a], [5..11, syn_b] + // Word del: [0..5] ("hello" changed) + let mut line = StyledLine { + text: "hello world".into(), + highlights: vec![(0..5, syn_style_a), (5..11, syn_style_b)], + }; + line.apply_word_highlights(Vec::from([0..5]), vec![], word_bg, word_bg); + + // Should produce 3 spans: [0..5 combined], [5..11 syntect-only] + // The combined span has syntect colour + word background. + assert_eq!(line.highlights.len(), 2); + assert_eq!(line.highlights[0].0, 0..5); + assert_eq!(line.highlights[0].1.color, syn_style_a.color); + assert_eq!( + line.highlights[0].1.background_color, + word_bg.background_color + ); + assert_eq!(line.highlights[1].0, 5..11); + assert_eq!(line.highlights[1].1.color, syn_style_b.color); + assert_eq!(line.highlights[1].1.background_color, None); + } + + #[test] + #[allow(clippy::single_range_in_vec_init)] + fn apply_word_highlights_splits_syntect_span_at_word_boundaries() { + let syn = HighlightStyle { + color: Some(gpui::hsla(0.0, 1.0, 0.5, 1.0)), + ..Default::default() + }; + let word_bg = HighlightStyle { + background_color: Some(gpui::hsla(0.0, 0.5, 0.5, 0.25)), + ..Default::default() + }; + + // Text: "hello world!" (12 bytes) + // Syntect: single span covering everything [0..12] + // Word highlight: [6..11] ("world") + let mut line = StyledLine { + text: "hello world!".into(), + highlights: vec![(0..12, syn)], + }; + line.apply_word_highlights(Vec::from([6..11]), vec![], word_bg, word_bg); + + // Should split into: [0..6 syn], [6..11 syn+word], [11..12 syn] + assert_eq!(line.highlights.len(), 3); + assert_eq!(line.highlights[0].0, 0..6); + assert_eq!(line.highlights[0].1.background_color, None); + assert_eq!(line.highlights[1].0, 6..11); + assert_eq!( + line.highlights[1].1.background_color, + word_bg.background_color + ); + assert_eq!(line.highlights[1].1.color, syn.color); + assert_eq!(line.highlights[2].0, 11..12); + assert_eq!(line.highlights[2].1.background_color, None); + } + + #[test] + #[allow(clippy::single_range_in_vec_init)] + fn apply_word_highlights_word_span_crosses_syntect_boundary() { + let syn_a = HighlightStyle { + color: Some(gpui::hsla(0.0, 1.0, 0.5, 1.0)), + ..Default::default() + }; + let syn_b = HighlightStyle { + color: Some(gpui::hsla(0.5, 1.0, 0.5, 1.0)), + ..Default::default() + }; + let word_bg = HighlightStyle { + background_color: Some(gpui::hsla(0.0, 0.5, 0.5, 0.25)), + ..Default::default() + }; + + // Text: "hello world!" (12 bytes) + // Syntect: [0..5, syn_a], [5..12, syn_b] + // Word: [3..7] — crosses the syn_a/syn_b boundary + let mut line = StyledLine { + text: "hello world!".into(), + highlights: vec![(0..5, syn_a), (5..12, syn_b)], + }; + line.apply_word_highlights(Vec::from([3..7]), vec![], word_bg, word_bg); + + // Expected: [0..3 syn_a], [3..5 syn_a+word], [5..7 syn_b+word], [7..12 syn_b] + assert_eq!(line.highlights.len(), 4); + assert_eq!(line.highlights[0].0, 0..3); + assert_eq!(line.highlights[0].1.background_color, None); + assert_eq!(line.highlights[1].0, 3..5); + assert_eq!(line.highlights[1].1.color, syn_a.color); + assert_eq!( + line.highlights[1].1.background_color, + word_bg.background_color + ); + assert_eq!(line.highlights[2].0, 5..7); + assert_eq!(line.highlights[2].1.color, syn_b.color); + assert_eq!( + line.highlights[2].1.background_color, + word_bg.background_color + ); + assert_eq!(line.highlights[3].0, 7..12); + assert_eq!(line.highlights[3].1.background_color, None); + } +} + +/// View-level regression tests for diff provenance. +/// +/// The pure tests above prove `DiffSource` classifies content correctly. These +/// drive a real `DiffViewer` in a headless GPUI window and press the actual +/// staging keys, which is the only way to show that no staging request escapes +/// a source that cannot be staged. A request that did would reach +/// `GitProject::stage_hunk_at`, which resolves the hunk index against the +/// working tree — staging unrelated uncommitted edits. +#[cfg(test)] +mod view_tests { + use gpui::prelude::*; + use gpui::{div, Context, Entity, Render, Window}; + use rgitui_git::{ + ConflictSnapshot, DiffHunk, DiffLine, FileChangeKind, FileDiff, MergeSection, + ThreeWayFileDiff, + }; + use rgitui_test_support::ViewTest; + + use super::{ + ConflictChoice, ConflictResolution, DiffOperation, DiffSource, DiffViewer, DiffViewerEvent, + WorktreePatchScope, + }; + + const OID: &str = "9f2c1ab4d5e6f708192a3b4c5d6e7f8091a2b3c4"; + const PATH: &str = "src/main.rs"; + + /// Hosts a real `DiffViewer` and records everything it emits, so a test can + /// assert that a keystroke produced no staging request at all. + struct StagingProbe { + viewer: Entity, + events: Vec, + } + + impl StagingProbe { + fn new(window: &mut Window, cx: &mut Context) -> Self { + // The viewer reads settings while rendering; install defaults + // without touching the user's config file or the OS keychain. + rgitui_settings::init_test(cx); + let viewer = cx.new(DiffViewer::new); + cx.subscribe( + &viewer, + |probe: &mut Self, _viewer, event: &DiffViewerEvent, _cx| { + probe.events.push(event.clone()); + }, + ) + .detach(); + viewer.update(cx, |viewer, cx| viewer.focus(window, cx)); + Self { + viewer, + events: Vec::new(), + } + } + + /// Emitted requests, ignoring the `DiffChanged` notification that every + /// `set_diff` produces. + fn request_events(&self) -> Vec { + self.events + .iter() + .filter(|event| !matches!(event, DiffViewerEvent::DiffChanged { .. })) + .cloned() + .collect() + } + + /// [`Self::request_events`] as debug strings, for assertions that only + /// care that nothing (or one named thing) came out. + fn requests(&self) -> Vec { + self.request_events() + .iter() + .map(|event| format!("{event:?}")) + .collect() + } + } + + /// Shows `diff` from `source` in the probe's viewer and focuses it. + fn show(probe: &mut ViewTest, diff: FileDiff, source: DiffSource) { + probe.update(|probe, window, cx| { + probe.viewer.update(cx, |viewer, cx| { + viewer.set_diff(diff, PATH.to_string(), source, cx); + viewer.focus(window, cx); + }); + }); + } + + impl Render for StagingProbe { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().size_full().child(self.viewer.clone()) + } + } + + fn one_hunk_diff() -> FileDiff { + FileDiff { + path: std::path::PathBuf::from(PATH), + hunks: vec![DiffHunk { + old_start: 1, + old_lines: 2, + new_start: 1, + new_lines: 3, + header: "@@ -1,2 +1,3 @@ fn main()".to_string(), + lines: vec![ + DiffLine::Context("fn main() {".to_string()), + DiffLine::Addition(" println!(\"hello\");".to_string()), + DiffLine::Context("}".to_string()), + ], + }], + additions: 1, + deletions: 0, + kind: FileChangeKind::Modified, + } + } + + /// Shows `source` in a focused viewer, selects every row so a staging + /// request would have a target, invokes stage-then-unstage, and returns + /// whatever staging requests came back. + /// + /// These go through the methods rather than simulated keystrokes because the + /// `s`/`u` bindings are gpui actions declared in `rgitui_workspace`, which + /// sits above this crate and so cannot be reached from here. The keystrokes + /// are covered by the keymap registry's own tests; what matters here is that + /// the entry points the actions call refuse on historical content. + fn staging_requests_after_stage_then_unstage(source: DiffSource) -> Vec { + let mut probe = ViewTest::open(StagingProbe::new); + show(&mut probe, one_hunk_diff(), source); + + // Guard against a vacuous test: the rows must actually exist, or the + // selection would find no hunk regardless of provenance. + probe.read(|probe, cx| { + let viewer = probe.viewer.read(cx); + assert!( + viewer.row_count() > 0, + "display rows should be prepared before staging is attempted" + ); + }); + + probe.update(|probe, _window, cx| { + probe.viewer.update(cx, |viewer, cx| { + // Select every row, so the request resolves to the whole hunk. + viewer.select_all_lines(cx); + viewer.stage_selection(cx); + viewer.unstage_selection(cx); + }); + }); + + probe.read(|probe, _| probe.requests()) + } + + #[test] + fn pressing_s_or_u_on_a_commit_diff_requests_no_staging() { + let requests = + staging_requests_after_stage_then_unstage(DiffSource::Commit(OID.to_string())); + assert!( + requests.is_empty(), + "a committed diff must not be stageable, but the viewer emitted {requests:?}" + ); + } + + #[test] + fn pressing_s_or_u_on_a_stash_diff_requests_no_staging() { + let requests = + staging_requests_after_stage_then_unstage(DiffSource::Stash(OID.to_string())); + assert!( + requests.is_empty(), + "a stashed diff must not be stageable, but the viewer emitted {requests:?}" + ); + } + + /// The control case. Without it the two tests above would still pass if the + /// keys had simply stopped working everywhere. + #[test] + fn pressing_s_on_a_worktree_diff_still_requests_staging() { + let requests = staging_requests_after_stage_then_unstage(DiffSource::Worktree); + assert_eq!( + requests, + vec!["HunkStageRequested(0)".to_string()], + "an unstaged working-tree diff must still stage on `s` (and ignore `u`)" + ); + } + + /// The mirror control case: the index unstages on `u` and ignores `s`. + #[test] + fn pressing_u_on_an_index_diff_still_requests_unstaging() { + let requests = staging_requests_after_stage_then_unstage(DiffSource::Index); + assert_eq!( + requests, + vec!["HunkUnstageRequested(0)".to_string()], + "a staged diff must still unstage on `u` (and ignore `s`)" + ); + } + + /// Partial mode is available on committed content, whose line-level operation + /// is applying or reverting those lines in the working tree. Staging is not, + /// in partial mode either. + #[test] + fn partial_mode_on_a_commit_diff_still_refuses_to_stage() { + let mut probe = ViewTest::open(StagingProbe::new); + show( + &mut probe, + one_hunk_diff(), + DiffSource::Commit(OID.to_string()), + ); + + probe.update(|probe, _window, cx| { + probe + .viewer + .update(cx, |viewer, cx| viewer.toggle_partial_mode(cx)); + }); + probe.read(|probe, cx| { + assert!( + probe.viewer.read(cx).partial_mode, + "line-level apply/revert needs partial mode, so `p` must work here" + ); + }); + + probe.update(|probe, _window, cx| { + probe.viewer.update(cx, |viewer, cx| { + viewer.select_all_lines(cx); + viewer.stage_selection(cx); + viewer.unstage_selection(cx); + }); + }); + probe.read(|probe, _| { + assert!( + probe.requests().is_empty(), + "partial mode must not open a staging route for committed content: {:?}", + probe.requests() + ); + }); + } + + #[test] + fn partial_staging_mode_still_toggles_on_a_worktree_diff() { + let mut probe = ViewTest::open(StagingProbe::new); + show(&mut probe, one_hunk_diff(), DiffSource::Worktree); + + probe.update(|probe, _window, cx| { + probe + .viewer + .update(cx, |viewer, cx| viewer.toggle_partial_mode(cx)); + }); + probe.read(|probe, cx| assert!(probe.viewer.read(cx).partial_mode)); + probe.update(|probe, _window, cx| { + probe + .viewer + .update(cx, |viewer, cx| viewer.toggle_partial_mode(cx)); + }); + probe.read(|probe, cx| assert!(!probe.viewer.read(cx).partial_mode)); + } + + // ── apply / revert affordances ──────────────────────────────── + + /// Shows `source` in a focused viewer, selects every row, then runs `act` + /// against the viewer and returns the requests that came back. + /// + /// `act` calls the same public methods the `diff::*` actions dispatch to + /// rather than simulating keystrokes: those bindings are gpui actions declared + /// in `rgitui_workspace`, which sits above this crate, so a keystroke pressed + /// here reaches no handler. The keystrokes are pinned by the keymap registry's + /// own tests. + fn requests_after( + source: DiffSource, + act: impl FnOnce(&mut DiffViewer, &mut Context), + ) -> Vec { + let mut probe = ViewTest::open(StagingProbe::new); + show(&mut probe, one_hunk_diff(), source); + probe.read(|probe, cx| { + assert!( + probe.viewer.read(cx).row_count() > 0, + "display rows should be prepared before the command is invoked" + ); + }); + probe.update(|probe, _window, cx| { + probe.viewer.update(cx, |viewer, cx| { + viewer.select_all_lines(cx); + act(viewer, cx); + }); + }); + probe.read(|probe, _| probe.request_events()) + } + + fn historical_sources() -> Vec { + vec![ + DiffSource::Commit(OID.to_string()), + DiffSource::Stash(OID.to_string()), + DiffSource::Compare { + from: "main".to_string(), + to: "feature".to_string(), + }, + ] + } + + #[test] + fn applying_content_from_outside_the_working_tree_requests_an_apply() { + for source in historical_sources() { + let requests = requests_after(source.clone(), |viewer, cx| viewer.apply_selection(cx)); + assert_eq!( + requests, + vec![DiffViewerEvent::WorktreePatchRequested { + operation: DiffOperation::Apply, + scope: WorktreePatchScope::Hunk(0), + }], + "{source:?} should offer to apply its hunk" + ); + } + } + + #[test] + fn reverting_content_from_outside_the_working_tree_requests_a_revert() { + for source in historical_sources() { + let requests = requests_after(source.clone(), |viewer, cx| viewer.revert_selection(cx)); + assert_eq!( + requests, + vec![DiffViewerEvent::WorktreePatchRequested { + operation: DiffOperation::Revert, + scope: WorktreePatchScope::Hunk(0), + }], + "{source:?} should offer to revert its hunk" + ); + } + } + + /// Working-tree content must not offer to apply or revert itself: it is + /// already in the working tree, and the git layer has no tree pair to + /// generate a patch from. + #[test] + fn applying_or_reverting_a_working_tree_diff_requests_nothing() { + for source in [DiffSource::Worktree, DiffSource::Index] { + let requests = requests_after(source.clone(), |viewer, cx| { + viewer.apply_selection(cx); + viewer.revert_selection(cx); + viewer.apply_file(cx); + viewer.revert_file(cx); + }); + assert!( + requests.is_empty(), + "{source:?} must not emit a working-tree patch request, got {requests:?}" + ); + } + } + + #[test] + fn a_line_selection_scopes_the_apply_to_those_lines() { + // Partial mode plus a whole-diff row selection; the one change line in + // the fixture is the addition at new line 2. + let requests = requests_after(DiffSource::Commit(OID.to_string()), |viewer, cx| { + viewer.toggle_partial_mode(cx); + viewer.select_all_lines(cx); + viewer.apply_selection(cx); + }); + assert_eq!( + requests, + vec![DiffViewerEvent::WorktreePatchRequested { + operation: DiffOperation::Apply, + scope: WorktreePatchScope::Lines(vec![(None, Some(2))]), + }], + "a manual line selection must narrow the scope to those lines" + ); + } + + #[test] + fn the_file_menu_carries_apply_and_revert_only_for_content_from_elsewhere() { + for source in historical_sources() { + let mut probe = ViewTest::open(StagingProbe::new); + show(&mut probe, one_hunk_diff(), source.clone()); + probe.read(|probe, cx| { + assert_eq!( + probe.viewer.read(cx).file_menu_operations(), + vec![DiffOperation::Apply, DiffOperation::Revert], + "{source:?} needs a whole-file route; the hunk headers cannot express one" + ); + }); + } + + for source in [DiffSource::Worktree, DiffSource::Index] { + let mut probe = ViewTest::open(StagingProbe::new); + show(&mut probe, one_hunk_diff(), source.clone()); + probe.read(|probe, cx| { + assert!( + probe.viewer.read(cx).file_menu_operations().is_empty(), + "{source:?} stages whole files from the sidebar, so it gets no menu" + ); + }); + } + } + + #[test] + fn the_dismiss_backdrop_only_exists_while_the_menu_does() { + // The backdrop covers the diff body to catch outside clicks, so it must + // never outlive the menu — otherwise it silently swallows clicks on the + // diff itself. `file_menu_open` alone is not enough: a source with no + // whole-file operations renders no menu even when the flag is set. + let mut probe = ViewTest::open(StagingProbe::new); + show(&mut probe, one_hunk_diff(), DiffSource::Worktree); + probe.update(|probe, _, cx| { + probe + .viewer + .update(cx, |viewer, _| viewer.file_menu_open = true); + }); + probe.read(|probe, cx| { + assert!( + !probe.viewer.read(cx).file_menu_visible(), + "the worktree has no menu to dismiss, so it must have no backdrop" + ); + }); + + let mut probe = ViewTest::open(StagingProbe::new); + show( + &mut probe, + one_hunk_diff(), + DiffSource::Commit(OID.to_string()), + ); + probe.read(|probe, cx| { + assert!( + !probe.viewer.read(cx).file_menu_visible(), + "the menu starts closed" + ); + }); + probe.update(|probe, _, cx| { + probe + .viewer + .update(cx, |viewer, _| viewer.file_menu_open = true); + }); + probe.read(|probe, cx| { + assert!(probe.viewer.read(cx).file_menu_visible()); + }); + } + + #[test] + fn choosing_apply_from_the_file_menu_requests_the_whole_file() { + let mut probe = ViewTest::open(StagingProbe::new); + show( + &mut probe, + one_hunk_diff(), + DiffSource::Commit(OID.to_string()), + ); + + probe.update(|probe, _, cx| { + probe.viewer.update(cx, |viewer, cx| { + viewer.file_menu_open = true; + viewer.request_whole_file_patch(DiffOperation::Apply, cx); + }); + }); + + probe.read(|probe, cx| { + assert_eq!( + probe.request_events(), + vec![DiffViewerEvent::WorktreePatchRequested { + operation: DiffOperation::Apply, + scope: WorktreePatchScope::File, + }] + ); + assert!( + !probe.viewer.read(cx).file_menu_open, + "choosing an entry should dismiss the menu" + ); + }); + } + + /// The whole-file commands ignore the row selection entirely, so the menu and + /// the keystroke reach the same scope from opposite starting states. + #[test] + fn the_whole_file_commands_request_file_scope_whatever_is_selected() { + for (operation, act) in [ + ( + DiffOperation::Apply, + &DiffViewer::apply_file as &dyn Fn(&mut DiffViewer, &mut Context), + ), + (DiffOperation::Revert, &DiffViewer::revert_file), + ] { + let requests = requests_after(DiffSource::Commit(OID.to_string()), |viewer, cx| { + act(viewer, cx) + }); + assert_eq!( + requests, + vec![DiffViewerEvent::WorktreePatchRequested { + operation, + scope: WorktreePatchScope::File, + }], + "{operation:?} over the whole file must not be narrowed by the selection" + ); + } + } + + #[test] + fn a_hunk_header_button_and_the_key_raise_the_same_request() { + // Both routes go through `for_hunk`, so neither can drift from the other. + for operation in [DiffOperation::Apply, DiffOperation::Revert] { + assert_eq!( + DiffViewerEvent::for_hunk(operation, 3), + DiffViewerEvent::WorktreePatchRequested { + operation, + scope: WorktreePatchScope::Hunk(3), + } + ); + } + assert_eq!( + DiffViewerEvent::for_hunk(DiffOperation::Stage, 3), + DiffViewerEvent::HunkStageRequested(3) + ); + assert_eq!( + DiffViewerEvent::for_hunk(DiffOperation::Unstage, 3), + DiffViewerEvent::HunkUnstageRequested(3) + ); + } + + #[test] + fn independent_conflict_choices_emit_one_complete_staged_result() { + let mut probe = ViewTest::open(StagingProbe::new); + let snapshot = ConflictSnapshot { + ancestor_oid: None, + ancestor_mode: None, + ours_oid: None, + ours_mode: None, + theirs_oid: None, + theirs_mode: None, + worktree: rgitui_git::ConflictWorktreeSnapshot::Regular { + bytes: b"markers\n".to_vec(), + executable: None, + readonly: false, + }, + }; + let diff = ThreeWayFileDiff { + path: PATH.into(), + sections: vec![ + MergeSection::Conflict { + ancestor: b"base-a\n".to_vec(), + ours: b"current-a\n".to_vec(), + theirs: b"incoming-a\n".to_vec(), + }, + MergeSection::Resolved(b"middle\n".to_vec()), + MergeSection::Conflict { + ancestor: b"base-b\n".to_vec(), + ours: b"current-b\n".to_vec(), + theirs: b"incoming-b\n".to_vec(), + }, + ], + snapshot: snapshot.clone(), + ancestor_exists: true, + ours_exists: true, + theirs_exists: true, + is_binary: false, + is_special_file: false, + result_mode: 0o100644, + }; + + probe.update(|probe, _window, cx| { + probe.viewer.update(cx, |viewer, cx| { + viewer.set_three_way_diff(diff, cx); + }); + }); + probe.read(|probe, cx| { + assert_eq!(probe.viewer.read(cx).unresolved_conflict_count(), 2); + }); + probe.update(|probe, _window, cx| { + probe.viewer.update(cx, |viewer, cx| { + viewer.use_current_for_conflict(cx); + viewer.use_incoming_for_conflict(cx); + viewer.save_conflict_result(cx); + }); + }); + + probe.read(|probe, _| { + assert_eq!( + probe.request_events(), + vec![DiffViewerEvent::ConflictResolutionRequested( + ConflictResolution::SaveMerged { + path: PATH.to_string(), + content: b"current-a\nmiddle\nincoming-b\n".to_vec(), + mode: 0o100644, + snapshot, + } + )] + ); + }); + } + + #[test] + fn conflict_view_is_active_while_its_model_is_loading() { + let mut probe = ViewTest::open(StagingProbe::new); + + probe.update(|probe, _window, cx| { + probe.viewer.update(cx, |viewer, cx| { + viewer.set_conflict_loading(PATH.to_string(), cx); + }); + }); + probe.read(|probe, cx| { + let viewer = probe.viewer.read(cx); + assert!(viewer.is_conflict_view_active()); + assert!(!viewer.has_three_way_diff()); + assert_eq!(viewer.file_path(), Some(PATH)); + }); + + probe.update(|probe, _window, cx| { + probe.viewer.update(cx, |viewer, cx| viewer.clear(cx)); + }); + probe.read(|probe, cx| { + assert!(!probe.viewer.read(cx).is_conflict_view_active()); + }); + } + + #[test] + fn conflict_selection_invalidates_an_older_ordinary_diff_load() { + let mut probe = ViewTest::open(StagingProbe::new); + let ordinary_generation = probe.update(|probe, _window, cx| { + probe.viewer.update(cx, |viewer, cx| { + viewer.set_diff_loading("ordinary.txt".to_string(), DiffSource::Worktree, cx) + }) + }); + + let conflict_generation = probe.update(|probe, _window, cx| { + probe.viewer.update(cx, |viewer, cx| { + viewer.set_conflict_loading(PATH.to_string(), cx) + }) + }); + + probe.read(|probe, cx| { + let viewer = probe.viewer.read(cx); + assert_ne!(ordinary_generation, conflict_generation); + assert_ne!(viewer.generation(), ordinary_generation); + assert_eq!(viewer.file_path(), Some(PATH)); + assert!(viewer.is_conflict_view_active()); + }); + } + + #[test] + fn conflict_selection_invalidates_an_older_pathless_diff_request() { + let mut probe = ViewTest::open(StagingProbe::new); + let historical_generation = probe.update(|probe, _window, cx| { + probe + .viewer + .update(cx, |viewer, cx| viewer.begin_diff_request(cx)) + }); + + probe.update(|probe, _window, cx| { + probe.viewer.update(cx, |viewer, cx| { + viewer.set_conflict_loading(PATH.to_string(), cx); + }); + }); + + probe.read(|probe, cx| { + let viewer = probe.viewer.read(cx); + assert_ne!(viewer.generation(), historical_generation); + assert_eq!(viewer.file_path(), Some(PATH)); + assert!(viewer.is_conflict_view_active()); + }); + } + + #[test] + fn choosing_a_conflict_side_invalidates_initial_row_preparation() { + let mut probe = ViewTest::open(StagingProbe::new); + let diff = ThreeWayFileDiff { + path: PATH.into(), + sections: vec![MergeSection::Conflict { + ancestor: b"base\n".to_vec(), + ours: b"current\n".to_vec(), + theirs: b"incoming\n".to_vec(), + }], + snapshot: ConflictSnapshot { + ancestor_oid: None, + ancestor_mode: None, + ours_oid: None, + ours_mode: None, + theirs_oid: None, + theirs_mode: None, + worktree: rgitui_git::ConflictWorktreeSnapshot::Missing, + }, + ancestor_exists: true, + ours_exists: true, + theirs_exists: true, + is_binary: false, + is_special_file: false, + result_mode: 0o100644, + }; + + let (initial_generation, chosen_generation, rebuilt_inline) = + probe.update(|probe, _window, cx| { + probe.viewer.update(cx, |viewer, cx| { + viewer.set_three_way_diff(diff, cx); + let initial_generation = viewer.preparation_generation; + let rows_before = std::sync::Arc::clone(&viewer.three_way_rows); + viewer.use_current_for_conflict(cx); + ( + initial_generation, + viewer.preparation_generation, + !std::sync::Arc::ptr_eq(&rows_before, &viewer.three_way_rows), + ) + }) + }); + + assert_ne!(initial_generation, chosen_generation); + assert!( + !rebuilt_inline, + "row preparation must not run on the UI thread" + ); + assert!(!super::should_apply_prepared( + chosen_generation, + initial_generation + )); + probe.read(|probe, cx| { + assert_eq!(probe.viewer.read(cx).unresolved_conflict_count(), 0); + }); + } + + #[test] + fn filling_remaining_conflicts_preserves_existing_choices() { + let mut probe = ViewTest::open(StagingProbe::new); + let diff = ThreeWayFileDiff { + path: PATH.into(), + sections: vec![ + MergeSection::Conflict { + ancestor: b"base-a\n".to_vec(), + ours: b"current-a\n".to_vec(), + theirs: b"incoming-a\n".to_vec(), + }, + MergeSection::Conflict { + ancestor: b"base-b\n".to_vec(), + ours: b"current-b\n".to_vec(), + theirs: b"incoming-b\n".to_vec(), + }, + ], + snapshot: ConflictSnapshot { + ancestor_oid: None, + ancestor_mode: None, + ours_oid: None, + ours_mode: None, + theirs_oid: None, + theirs_mode: None, + worktree: rgitui_git::ConflictWorktreeSnapshot::Missing, + }, + ancestor_exists: true, + ours_exists: true, + theirs_exists: true, + is_binary: false, + is_special_file: false, + result_mode: 0o100644, + }; + + probe.update(|probe, _window, cx| { + probe.viewer.update(cx, |viewer, cx| { + viewer.set_three_way_diff(diff, cx); + viewer.use_current_for_conflict(cx); + viewer.use_incoming_for_all_conflicts(cx); + }); + }); + + probe.read(|probe, cx| { + let viewer = probe.viewer.read(cx); + assert_eq!( + viewer.conflict_choices, + vec![Some(ConflictChoice::Ours), Some(ConflictChoice::Theirs)] + ); + assert_eq!(viewer.unresolved_conflict_count(), 0); + }); + } + + #[test] + fn whole_side_resolution_event_carries_the_loaded_snapshot() { + let mut probe = ViewTest::open(StagingProbe::new); + let snapshot = ConflictSnapshot { + ancestor_oid: None, + ancestor_mode: None, + ours_oid: None, + ours_mode: None, + theirs_oid: None, + theirs_mode: None, + worktree: rgitui_git::ConflictWorktreeSnapshot::Regular { + bytes: b"binary markers\0".to_vec(), + executable: None, + readonly: false, + }, + }; + let diff = ThreeWayFileDiff { + path: PATH.into(), + sections: vec![MergeSection::Conflict { + ancestor: b"base\0".to_vec(), + ours: b"current\0".to_vec(), + theirs: b"incoming\0".to_vec(), + }], + snapshot: snapshot.clone(), + ancestor_exists: true, + ours_exists: true, + theirs_exists: true, + is_binary: true, + is_special_file: false, + result_mode: 0o100644, + }; + + probe.update(|probe, _window, cx| { + probe.viewer.update(cx, |viewer, cx| { + viewer.set_three_way_diff(diff, cx); + viewer.use_current_for_conflict(cx); + viewer.save_conflict_result(cx); + }); + }); + + probe.read(|probe, _| { + assert_eq!( + probe.request_events(), + vec![DiffViewerEvent::ConflictResolutionRequested( + ConflictResolution::UseOurs { + path: PATH.to_string(), + snapshot, + } + )] + ); + }); + } +} diff --git a/third_party/rgitui/crates/rgitui_git/Cargo.toml b/third_party/rgitui/crates/rgitui_git/Cargo.toml new file mode 100644 index 000000000..cb80c4ae0 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_git/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "rgitui_git" +version = "0.4.1" +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +gpui.workspace = true +git2.workspace = true +anyhow.workspace = true +log.workspace = true +chrono.workspace = true +parking_lot.workspace = true +notify.workspace = true +serde.workspace = true +serde_json.workspace = true +dirs.workspace = true +smol.workspace = true +rgitui_settings.workspace = true +tempfile.workspace = true +same-file.workspace = true +cap-std.workspace = true +cap-fs-ext.workspace = true + +[features] +perf = [] diff --git a/third_party/rgitui/crates/rgitui_git/src/graph.rs b/third_party/rgitui/crates/rgitui_git/src/graph.rs new file mode 100644 index 000000000..7d84d5735 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_git/src/graph.rs @@ -0,0 +1,1959 @@ +use crate::{CommitInfo, RefLabel}; + +/// The visual representation of a commit's position in the graph. +#[derive(Debug, Clone)] +pub struct GraphRow { + /// Index of the commit in the commit list. + pub commit_index: usize, + /// The lane (column) this commit's node sits in. + pub node_lane: usize, + /// Connections drawn in this row. + pub edges: Vec, + /// Total number of active lanes at this row. + pub lane_count: usize, + /// Color index for this commit's dot (derived from lane color). + pub node_color: usize, + /// Whether there is an incoming edge from the row above (false for branch tips). + pub has_incoming: bool, + /// Whether this commit is the HEAD commit. + pub is_head: bool, + /// Whether this commit is a merge commit (more than one parent). + pub is_merge: bool, +} + +/// An edge connecting two rows in the graph. +#[derive(Debug, Clone)] +pub struct GraphEdge { + /// The lane this edge starts from (in the previous row). + pub from_lane: usize, + /// The lane this edge goes to (in this row). + pub to_lane: usize, + /// Color index for this edge (consistent per branch). + pub color_index: usize, + /// Whether this edge represents a merge (connecting to a non-primary parent). + pub is_merge: bool, +} + +/// Memoized ancestor reachability over the loaded commit list. +/// +/// Each ancestry check is answered from a per-descendant proper-ancestor set, +/// computed once by walking that descendant's parent chain (bounded by +/// `oid_to_idx`) and reused for later queries. Lane assignment queries ancestry +/// once per active lane per commit, so memoizing keeps the pass from re-walking +/// the parent DAG on every check. +/// +/// Ancestors are stored as OIDs, not commit indices, so boundary parents — OIDs +/// referenced as a parent of a loaded commit but not present in `oid_to_idx` — +/// are matched exactly. A topo-order index comparison is deliberately avoided: +/// the commit list isn't guaranteed to satisfy that relationship, and unrelated +/// commits would compare as related. +struct AncestorCache { + sets: std::collections::HashMap>, +} + +impl AncestorCache { + fn new() -> Self { + Self { + sets: std::collections::HashMap::new(), + } + } + + /// Check if `ancestor` is an ancestor of `descendant`. + /// + /// Reflexive: a commit is its own ancestor. For a proper ancestor the + /// lookup consults `descendant`'s cached proper-ancestor set, computing it + /// on first use by walking the parent chain bounded by `oid_to_idx`. + fn is_ancestor_of( + &mut self, + ancestor: git2::Oid, + descendant: git2::Oid, + commits: &[CommitInfo], + oid_to_idx: &std::collections::HashMap, + ) -> bool { + if ancestor == descendant { + return true; + } + self.proper_ancestors(descendant, commits, oid_to_idx) + .contains(&ancestor) + } + + /// Return `descendant`'s proper-ancestor OID set, computing and caching it + /// on first request. A descendant not present in `oid_to_idx` has no known + /// parents and therefore an empty ancestor set. + fn proper_ancestors( + &mut self, + descendant: git2::Oid, + commits: &[CommitInfo], + oid_to_idx: &std::collections::HashMap, + ) -> &std::collections::HashSet { + // Bounded on both axes. Unbounded, this is quadratic in the loaded + // window — a distinct set memoized per row, each holding up to every + // OID before it — and the commit limit goes to 100,000. The fast paths + // in `find_lane` keep that off most repositories, but the input that + // defeats them is many long-lived concurrent branches, which is exactly + // the large repository this needs to survive. + // + // Both bounds trade accuracy for a ceiling, and can only ever make the + // answer "not an ancestor". Lane assignment is a visual heuristic, so + // the cost of being wrong is a slightly less tidy graph, never a wrong + // commit or a wrong edge. + if self.sets.len() >= MAX_ANCESTOR_SETS && !self.sets.contains_key(&descendant) { + self.sets.clear(); + } + + self.sets.entry(descendant).or_insert_with(|| { + let mut ancestors = std::collections::HashSet::new(); + let mut queue = match oid_to_idx.get(&descendant) { + Some(&idx) => commits[idx].parent_oids.clone(), + None => Vec::new(), + }; + while let Some(current) = queue.pop() { + if ancestors.len() >= MAX_ANCESTORS_PER_SET { + break; + } + if ancestors.insert(current) { + if let Some(&idx) = oid_to_idx.get(¤t) { + queue.extend(commits[idx].parent_oids.iter().copied()); + } + } + } + ancestors + }) + } +} + +/// How many ancestor sets are memoized before the cache is dropped wholesale. +/// +/// Cleared rather than evicted one at a time: entries are cheap to rebuild, the +/// queries arrive in topological order so the working set moves steadily down +/// the graph, and a plain `HashMap` clear costs nothing to maintain. +const MAX_ANCESTOR_SETS: usize = 512; + +/// How far back a single ancestor walk will go before giving up. +/// +/// Deep enough that any realistic lane decision is settled long before it, and +/// shallow enough that 512 of them cannot become gigabytes. +const MAX_ANCESTORS_PER_SET: usize = 4096; + +/// Find the main branch tip OID by scanning commit refs for "main" or "master". +/// +/// Commits are in topological order (newest first), so the first matching ref +/// is the most-ahead tip. This correctly handles the case where origin/main is +/// ahead of local main — we pick whichever is furthest ahead. +fn find_main_branch_tip(commits: &[CommitInfo]) -> Option { + let mut main_tip: Option = None; + let mut master_tip: Option = None; + + for commit in commits { + for r in &commit.refs { + let is_main = matches!(r, RefLabel::LocalBranch(name) if name == "main") + || matches!(r, RefLabel::RemoteBranch(name) if name.ends_with("/main")); + let is_master = matches!(r, RefLabel::LocalBranch(name) if name == "master") + || matches!(r, RefLabel::RemoteBranch(name) if name.ends_with("/master")); + + if is_main && main_tip.is_none() { + main_tip = Some(commit.oid); + } + if is_master && master_tip.is_none() { + master_tip = Some(commit.oid); + } + } + } + + main_tip.or(master_tip) +} + +/// Compute the set of OIDs on the main branch's first-parent chain. +/// At merge commits, picks the parent that is most likely the main-line ancestor +/// by preferring parents that don't have a feature branch ref pointing at them. +fn compute_main_chain( + main_tip: git2::Oid, + commits: &[CommitInfo], + oid_to_idx: &std::collections::HashMap, +) -> std::collections::HashSet { + let mut chain = std::collections::HashSet::new(); + let mut current = Some(main_tip); + while let Some(oid) = current { + chain.insert(oid); + current = oid_to_idx.get(&oid).and_then(|&idx| { + let commit = &commits[idx]; + if commit.parent_oids.len() <= 1 { + commit.parent_oids.first().copied() + } else { + pick_main_parent(&commit.parent_oids, commits, oid_to_idx) + } + }); + } + chain +} + +/// At a merge commit, pick which parent is most likely the main-line ancestor. +/// Prefers parents that don't have a non-main branch ref (feature branches). +fn pick_main_parent( + parents: &[git2::Oid], + commits: &[CommitInfo], + oid_to_idx: &std::collections::HashMap, +) -> Option { + // Check each parent for non-main branch refs + let mut non_feature_parent = None; + for &parent_oid in parents { + let has_feature_ref = oid_to_idx.get(&parent_oid).is_some_and(|idx| { + commits[*idx].refs.iter().any(|r| { + matches!(r, + RefLabel::LocalBranch(name) if name != "main" && name != "master" + ) + }) + }); + if !has_feature_ref && non_feature_parent.is_none() { + non_feature_parent = Some(parent_oid); + } + } + non_feature_parent.or_else(|| parents.first().copied()) +} + +/// Compute the graph layout for a list of commits. +/// +/// The algorithm assigns each commit to a lane and tracks connections between rows. +/// Key behaviors: +/// - Main branch (main/master) commits always stay on lane 0 +/// - Feature branches that are ahead of main get their own lane +/// - After merges, main's history continues on lane 0 regardless of git's parent ordering +/// - Branch tips that appear later get new lanes +/// - Lanes are compacted: when multiple lanes become free, later lanes shift inward +/// - Colors are assigned per-lane and stay consistent along a branch +pub fn compute_graph(commits: &[CommitInfo]) -> Vec { + let graph_timer = std::time::Instant::now(); + log::debug!("compute_graph: {} commits", commits.len()); + if commits.is_empty() { + return Vec::new(); + } + + let oid_to_idx: std::collections::HashMap = commits + .iter() + .enumerate() + .map(|(i, c)| (c.oid, i)) + .collect(); + + // Memoizes parent-DAG reachability so each ancestry check is a hash-set + // lookup shared across every lane query in this pass. + let mut ancestry = AncestorCache::new(); + + // Detect HEAD oid — scan all commits, not just the first, because HEAD may + // not be the newest commit when remote branches are ahead. + let head_oid = commits + .iter() + .find(|c| c.refs.iter().any(|r| matches!(r, RefLabel::Head))) + .map(|c| c.oid); + + // Identify the main branch and compute its first-parent chain. + // This lets us keep main on lane 0 and push feature branches to other lanes. + let main_tip = find_main_branch_tip(commits); + let main_chain: std::collections::HashSet = match main_tip { + Some(tip) => compute_main_chain(tip, commits, &oid_to_idx), + None => { + // No main/master found — fall back to HEAD's first-parent chain + if let Some(head) = head_oid { + compute_main_chain(head, commits, &oid_to_idx) + } else { + std::collections::HashSet::new() + } + } + }; + + // Each active lane: (expected OID, color index) + let mut lanes: Vec> = Vec::new(); + let mut next_color: usize = 0; + + // Pre-reserve lane 0 for main_tip whenever main_tip isn't the first commit + // in the list. Any commit processed before main_tip that routes a parent on + // the main-chain would otherwise reuse its own freed node_lane for that + // parent, producing a straight {L, L} outgoing edge and a merge-in at + // main_tip's row that bends into lane 0. The straight edge extends 4 px + // past the row bottom with no approach line on the same lane below it to + // hide the overhang, leaving a visible stub. Routing to lane 0 up front + // emits a curve instead and keeps main on its canonical column. When + // main_tip is commits[0] nothing is processed before it, so pre-reserving + // would spuriously mark main_tip's row as having an incoming line from + // above. + let reserve_lane_0 = main_tip.is_some_and(|tip| commits.first().is_some_and(|c| c.oid != tip)); + if reserve_lane_0 { + if let Some(tip) = main_tip { + let color = next_color; + next_color += 1; + lanes.push(Some((tip, color))); // lane 0 reserved for main + } + } + + let mut rows = Vec::with_capacity(commits.len()); + + for (idx, commit) in commits.iter().enumerate() { + let oid = commit.oid; + let is_merge = commit.parent_oids.len() > 1; + let is_head = head_oid == Some(oid); + let on_main = main_chain.contains(&oid); + + // Find which lane this commit sits in. + // Main-chain commits prefer lane 0; non-main commits skip lane 0. + let (node_lane, has_incoming) = if on_main { + // Main-chain commit: look for exact match at lane 0 first, then anywhere + if matches!(lanes.first(), Some(Some((o, _))) if *o == oid) { + (0, true) + } else if matches!(lanes.first(), Some(Some((expected, _))) if ancestry.is_ancestor_of(oid, *expected, commits, &oid_to_idx)) + { + let color = lanes[0].map(|(_, c)| c).unwrap_or(next_color); + lanes[0] = Some((oid, color)); + (0, true) + } else if matches!(lanes.first(), Some(None)) || lanes.is_empty() { + // Lane 0 is free — claim it + let was_empty = lanes.is_empty(); + let color = if was_empty { + let c = next_color; + next_color += 1; + lanes.push(None); + c + } else { + // Reuse color 0 (main's color) if this is the first main commit + 0 + }; + lanes[0] = Some((oid, color)); + // has_incoming only if lane 0 was previously occupied (something + // was drawing on this lane above us). A free/empty lane means + // nothing was drawn, so no incoming line even if idx > 0. + (0, false) + } else if matches!(lanes.first(), Some(Some((expected, _))) if main_chain.contains(expected)) + { + // Lane 0 is occupied by another main-chain commit. This + // happens when topo-sort reorders main commits around a + // branch fork point. Force onto lane 0 anyway. + let color = lanes[0].map(|(_, c)| c).unwrap_or(next_color); + lanes[0] = Some((oid, color)); + (0, true) + } else { + // Lane 0 is occupied by a non-main commit — search other lanes + find_lane( + oid, + &mut lanes, + &mut next_color, + commits, + &oid_to_idx, + &mut ancestry, + None, + ) + } + } else { + // Non-main-chain commit: skip lane 0 + find_lane( + oid, + &mut lanes, + &mut next_color, + commits, + &oid_to_idx, + &mut ancestry, + Some(0), + ) + }; + + let node_color = lanes[node_lane].map(|(_, c)| c).unwrap_or(0); + + // Build pass-through edges for occupied lanes, and merge-in edges for + // stale lanes whose expected OID matches this commit (they were waiting + // for this commit but it landed in a different lane). + let mut edges = Vec::new(); + for (lane, slot) in lanes.iter_mut().enumerate() { + if lane == node_lane { + continue; + } + if let Some((expected_oid, color)) = *slot { + if expected_oid == oid { + // Stale lane: generate a merge-in edge converging into the + // node, then free this lane. + edges.push(GraphEdge { + from_lane: lane, + to_lane: node_lane, + color_index: color, + is_merge: false, + }); + *slot = None; + } else { + edges.push(GraphEdge { + from_lane: lane, + to_lane: lane, + color_index: color, + is_merge: false, + }); + } + } + } + + // Free the commit's lane before assigning parents. + lanes[node_lane] = None; + + // Clear any OTHER lanes that were also waiting for this same OID. + // This happens when a commit's OID was routed to multiple lanes + // (e.g., a feature branch tip allocated a lane for its parent, and + // then the main-chain routing also placed the same parent on lane 0). + // Without this cleanup, the stale lane persists as a zombie + // pass-through line forever. + // When clearing, convert the pass-through edge into a merge edge + // (from the zombie lane curving into node_lane) so the branch + // visually merges back. + for (lane_idx, lane) in lanes.iter_mut().enumerate() { + if matches!(lane, Some((o, _)) if *o == oid) { + let color = lane.map(|(_, c)| c).unwrap_or(0); + *lane = None; + // Replace the straight pass-through with a converging edge + if let Some(edge) = edges + .iter_mut() + .find(|e| e.from_lane == lane_idx && e.to_lane == lane_idx) + { + edge.to_lane = node_lane; + } + // If no pass-through existed (shouldn't happen, but be safe), + // add a converging edge + if !edges + .iter() + .any(|e| e.from_lane == lane_idx && e.to_lane == node_lane) + { + edges.push(GraphEdge { + from_lane: lane_idx, + to_lane: node_lane, + color_index: color, + is_merge: false, + }); + } + } + } + + // Handle parents — for main-chain merge commits, identify which parent + // is on the main chain and treat that as the "primary" parent for lane routing. + let parents = &commit.parent_oids; + if !parents.is_empty() { + // Determine effective primary parent: on main-chain merges, the main-chain + // parent gets lane priority regardless of git's parent ordering. + let (primary, secondaries) = if on_main && parents.len() > 1 { + if let Some(main_parent_pos) = parents.iter().position(|p| main_chain.contains(p)) { + let primary = parents[main_parent_pos]; + let secondaries: Vec = parents + .iter() + .enumerate() + .filter(|&(i, _)| i != main_parent_pos) + .map(|(_, &p)| p) + .collect(); + (primary, secondaries) + } else { + (parents[0], parents[1..].to_vec()) + } + } else { + (parents[0], parents[1..].to_vec()) + }; + + // Route primary parent. + // Only route to lane 0 when BOTH the commit and its parent are + // on the main chain. When a feature branch's parent is on main, + // use normal routing so it doesn't steal lane 0. + let primary_on_main = on_main && main_chain.contains(&primary); + let primary_lane = if primary_on_main { + // Main-chain parent should go to lane 0 + if matches!(lanes.first(), Some(Some((o, _))) if *o == primary) { + 0 + } else if matches!(lanes.first(), Some(None)) || (lanes.is_empty()) { + if lanes.is_empty() { + lanes.push(None); + } + lanes[0] = Some((primary, node_color)); + 0 + } else if node_lane == 0 { + lanes[0] = Some((primary, node_color)); + 0 + } else { + // Lane 0 occupied by something else, fall back + route_parent( + primary, + node_lane, + node_color, + &mut lanes, + &mut next_color, + commits, + &oid_to_idx, + &mut ancestry, + ) + } + } else { + route_parent( + primary, + node_lane, + node_color, + &mut lanes, + &mut next_color, + commits, + &oid_to_idx, + &mut ancestry, + ) + }; + + edges.push(GraphEdge { + from_lane: node_lane, + to_lane: primary_lane, + color_index: node_color, + is_merge: false, + }); + + // Secondary parents (merge edges) + for &parent in &secondaries { + let parent_lane = route_parent( + parent, + node_lane, + node_color, + &mut lanes, + &mut next_color, + commits, + &oid_to_idx, + &mut ancestry, + ); + + let parent_color = lanes[parent_lane].map(|(_, c)| c).unwrap_or(0); + + edges.push(GraphEdge { + from_lane: node_lane, + to_lane: parent_lane, + color_index: parent_color, + is_merge: true, + }); + } + } + + // Compact lanes: remove trailing empty lanes + while lanes.last() == Some(&None) { + lanes.pop(); + } + + let lane_count = lanes.len().max(node_lane + 1); + + rows.push(GraphRow { + commit_index: idx, + node_lane, + edges, + lane_count, + node_color, + has_incoming, + is_head, + is_merge, + }); + } + + // Trim the ghost main lane above main_tip. Pre-reserving lane 0 leaves a + // pass-through edge on every row above main_tip's row even when nothing + // above merges back into main — the lane only exists to hold the slot open + // for main_tip. That produces a continuous column drawn all the way to the + // top of the graph, which is misleading when the newest commits are on + // side branches. Strip pure lane-0 pass-throughs (`0 -> 0`) on rows + // up to AND INCLUDING the first row that converges into lane 0 from + // another lane. The converging edge itself handles lane 0 inside that + // row (curving down to lane 0 at row_bottom + 4); the next row's + // pass-through picks up the line with the standard 4 px overlap. Below + // that row, lane 0 must remain drawn so the curve actually reaches + // main_tip's dot — otherwise the side-branch edge dangles in mid-air on + // lane 0 with no commit at its end. + if reserve_lane_0 { + if let Some(tip) = main_tip { + if let Some(&tip_idx) = oid_to_idx.get(&tip) { + if tip_idx > 0 { + let strip_until = rows + .iter() + .take(tip_idx) + .position(|r| r.edges.iter().any(|e| e.to_lane == 0 && e.from_lane != 0)) + .map(|i| (i + 1).min(tip_idx)) + .unwrap_or(tip_idx); + + let mut last_stripped = None; + for (idx, row) in rows.iter_mut().enumerate().take(strip_until) { + let before = row.edges.len(); + row.edges.retain(|e| !(e.from_lane == 0 && e.to_lane == 0)); + if row.edges.len() != before { + last_stripped = Some(idx); + } + } + // If we stripped the pass-through that feeds into main_tip's + // row, main_tip no longer has a line coming in from above. + // Suppress has_incoming to avoid a dangling 4px stub + // painted by the rendering code above the dot. + if last_stripped == Some(tip_idx - 1) { + if let Some(tip_row) = rows.get_mut(tip_idx) { + if tip_row.node_lane == 0 { + tip_row.has_incoming = false; + } + } + } + } + } + } + } + + log::debug!( + "compute_graph complete in {:?}: {} rows", + graph_timer.elapsed(), + rows.len() + ); + rows +} + +/// Find a lane for a commit: exact OID match, ancestor match, or allocate new. +/// If `skip_lane` is Some(n), that lane is excluded from the search (used to keep +/// non-main commits off lane 0). +fn find_lane( + oid: git2::Oid, + lanes: &mut Vec>, + next_color: &mut usize, + commits: &[CommitInfo], + oid_to_idx: &std::collections::HashMap, + ancestry: &mut AncestorCache, + skip_lane: Option, +) -> (usize, bool) { + // 1. Exact OID match + if let Some(pos) = lanes + .iter() + .enumerate() + .position(|(i, s)| Some(i) != skip_lane && matches!(s, Some((o, _)) if *o == oid)) + { + return (pos, true); + } + + // 2. Ancestor match + if let Some(pos) = lanes.iter().enumerate().position(|(i, s)| { + Some(i) != skip_lane + && matches!(s, Some((expected_oid, _)) if ancestry.is_ancestor_of(oid, *expected_oid, commits, oid_to_idx)) + }) { + let color = lanes[pos].map(|(_, c)| c).unwrap_or(*next_color); + lanes[pos] = Some((oid, color)); + return (pos, true); + } + + // 3. New branch tip — allocate a lane, skipping the reserved lane + let color = *next_color; + *next_color += 1; + let pos = alloc_lane(lanes, skip_lane); + lanes[pos] = Some((oid, color)); + (pos, false) +} + +/// Route a parent to an existing lane or allocate a new one. +/// +/// Priority: exact OID match → reuse freed node_lane → ancestor match → new lane. +/// Reusing node_lane before ancestor match keeps branch lines visually continuous +/// down to the fork point, where a merge-in edge will be generated. +fn route_parent( + parent: git2::Oid, + node_lane: usize, + node_color: usize, + lanes: &mut Vec>, + next_color: &mut usize, + commits: &[CommitInfo], + oid_to_idx: &std::collections::HashMap, + ancestry: &mut AncestorCache, +) -> usize { + // 1. Exact OID match — parent already expected in a specific lane + if let Some(target) = lanes + .iter() + .position(|s| matches!(s, Some((o, _)) if *o == parent)) + { + return target; + } + + // 2. Reuse the freed node_lane if available — keeps the branch line alive + if lanes.get(node_lane) == Some(&None) { + lanes[node_lane] = Some((parent, node_color)); + return node_lane; + } + + // 3. Ancestor match — parent is ancestor of a lane's expected OID + if let Some(target) = lanes.iter().position(|s| { + matches!(s, Some((expected_oid, _)) if ancestry.is_ancestor_of(parent, *expected_oid, commits, oid_to_idx)) + }) { + return target; + } + + // 4. Allocate a new lane + let color = *next_color; + *next_color += 1; + let pos = alloc_lane(lanes, None); + lanes[pos] = Some((parent, color)); + pos +} + +/// Find the first free lane or append a new one, optionally skipping a reserved lane. +fn alloc_lane(lanes: &mut Vec>, skip_lane: Option) -> usize { + if let Some(pos) = lanes + .iter() + .enumerate() + .position(|(i, l)| l.is_none() && Some(i) != skip_lane) + { + pos + } else { + // Append a new lane. If the new position happens to be the skip_lane + // (e.g., lanes was empty and skip_lane=Some(0)), reserve that slot + // and append another one. + lanes.push(None); + let pos = lanes.len() - 1; + if Some(pos) == skip_lane { + // The appended lane collides with the reserved lane — add another. + lanes.push(None); + lanes.len() - 1 + } else { + pos + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + + fn make_oid(byte: u8) -> git2::Oid { + let mut bytes = [0u8; 20]; + bytes[0] = byte; + git2::Oid::from_bytes(&bytes).unwrap() + } + + fn make_commit(oid_byte: u8, parents: &[u8], refs: Vec) -> CommitInfo { + CommitInfo { + oid: make_oid(oid_byte), + short_id: format!("{:07x}", oid_byte), + summary: format!("Commit {}", oid_byte), + message: format!("Commit {}", oid_byte), + author: crate::Signature { + name: "Test".to_string(), + email: "test@test.com".to_string(), + }, + committer: crate::Signature { + name: "Test".to_string(), + email: "test@test.com".to_string(), + }, + co_authors: vec![], + time: Utc::now(), + parent_oids: parents.iter().map(|b| make_oid(*b)).collect(), + refs, + is_signed: false, + } + } + + #[test] + fn test_linear_history() { + let commits = vec![ + make_commit(1, &[2], vec![RefLabel::Head]), + make_commit(2, &[3], vec![]), + make_commit(3, &[], vec![]), + ]; + let rows = compute_graph(&commits); + assert_eq!(rows.len(), 3); + // All commits should be on lane 0 + assert_eq!(rows[0].node_lane, 0); + assert_eq!(rows[1].node_lane, 0); + assert_eq!(rows[2].node_lane, 0); + // HEAD detection + assert!(rows[0].is_head); + assert!(!rows[1].is_head); + // First commit has no incoming + assert!(!rows[0].has_incoming); + assert!(rows[1].has_incoming); + } + + #[test] + fn test_merge_commit_detected() { + let commits = vec![ + make_commit(1, &[2, 3], vec![RefLabel::Head]), + make_commit(2, &[4], vec![]), + make_commit(3, &[4], vec![]), + make_commit(4, &[], vec![]), + ]; + let rows = compute_graph(&commits); + assert!(rows[0].is_merge); + assert!(!rows[1].is_merge); + } + + #[test] + fn test_empty_commits() { + let rows = compute_graph(&[]); + assert!(rows.is_empty()); + } + + #[test] + fn test_branch_tip_gets_new_lane() { + let commits = vec![ + make_commit(1, &[3], vec![RefLabel::Head]), + make_commit(2, &[3], vec![RefLabel::LocalBranch("feature".into())]), + make_commit(3, &[], vec![]), + ]; + let rows = compute_graph(&commits); + // HEAD on lane 0 + assert_eq!(rows[0].node_lane, 0); + // Branch tip should get a different lane + assert_ne!(rows[1].node_lane, rows[0].node_lane); + } + + #[test] + fn test_octopus_merge_three_parents() { + // A commit with 3 parents — octopus merge + let commits = vec![ + make_commit(1, &[2, 3, 4], vec![RefLabel::Head]), + make_commit(2, &[5], vec![]), + make_commit(3, &[5], vec![]), + make_commit(4, &[5], vec![]), + make_commit(5, &[], vec![]), + ]; + let rows = compute_graph(&commits); + assert!(rows[0].is_merge); + // Octopus merge: 1 primary + 2 secondary = 2 merge edges + let merge_edges: Vec<_> = rows[0].edges.iter().filter(|e| e.is_merge).collect(); + assert_eq!(merge_edges.len(), 2); + } + + #[test] + fn test_ref_labels_tag_and_remote() { + let commits = vec![ + make_commit( + 1, + &[2], + vec![RefLabel::Head, RefLabel::Tag("v1.0.0".into())], + ), + make_commit(2, &[3], vec![RefLabel::RemoteBranch("origin/main".into())]), + make_commit(3, &[], vec![]), + ]; + let rows = compute_graph(&commits); + assert!(rows[0].is_head); + assert!(!rows[1].is_head); + assert!(!rows[2].is_head); + } + + #[test] + fn test_lane_count_grows_with_parallel_branches() { + // Create parallel branches: main and feature both active + let commits = vec![ + make_commit(1, &[2], vec![RefLabel::Head]), + make_commit(2, &[3], vec![]), + make_commit(3, &[], vec![]), + ]; + let rows = compute_graph(&commits); + // Linear history: lane_count stays 1 + assert_eq!(rows[0].lane_count, 1); + assert_eq!(rows[1].lane_count, 1); + + // Now with a branch tip appearing later + let commits_with_branch = vec![ + make_commit(1, &[3], vec![RefLabel::Head]), + make_commit(2, &[3], vec![RefLabel::LocalBranch("feature".into())]), + make_commit(3, &[], vec![]), + ]; + let rows = compute_graph(&commits_with_branch); + // When feature tip (row 1) is active, lane_count = 2 + assert!(rows[1].lane_count >= 2); + } + + #[test] + fn test_merge_edge_flags_on_secondary_parents() { + let commits = vec![ + make_commit(1, &[2, 3], vec![RefLabel::Head]), + make_commit(2, &[4], vec![]), + make_commit(3, &[4], vec![]), + make_commit(4, &[], vec![]), + ]; + let rows = compute_graph(&commits); + // Row 0 (merge commit) has 2 outgoing edges: primary (is_merge=false) and secondary (is_merge=true) + let edges = &rows[0].edges; + let merge_flagged: Vec<_> = edges.iter().filter(|e| e.is_merge).collect(); + let primary_edges: Vec<_> = edges.iter().filter(|e| !e.is_merge).collect(); + assert_eq!(merge_flagged.len(), 1); // one secondary parent → one merge edge + assert!(!primary_edges.is_empty()); // at least one primary edge + } + + #[test] + fn test_commit_index_matches_input_order() { + let commits = vec![ + make_commit(10, &[20], vec![RefLabel::Head]), + make_commit(20, &[30], vec![]), + make_commit(30, &[], vec![]), + ]; + let rows = compute_graph(&commits); + assert_eq!(rows[0].commit_index, 0); + assert_eq!(rows[1].commit_index, 1); + assert_eq!(rows[2].commit_index, 2); + } + + #[test] + fn test_primary_parent_continues_same_lane() { + // In a simple linear chain, each commit continues the same lane + let commits = vec![ + make_commit(1, &[2], vec![RefLabel::Head]), + make_commit(2, &[3], vec![]), + make_commit(3, &[], vec![]), + ]; + let rows = compute_graph(&commits); + // Each row should continue on lane 0 + for row in &rows { + assert_eq!(row.node_lane, 0); + } + // No merge edges in linear history + for row in &rows { + assert!( + !row.edges.iter().any(|e| e.is_merge), + "linear history should have no merge edges" + ); + } + } + + #[test] + fn test_edge_color_from_lane() { + // Edge color_index should match the source lane's node_color + let commits = vec![ + make_commit(1, &[3], vec![RefLabel::Head]), + make_commit(2, &[3], vec![RefLabel::LocalBranch("feature".into())]), + make_commit(3, &[], vec![]), + ]; + let rows = compute_graph(&commits); + let row0_color = rows[0].node_color; + // Edges from row 0's lane should carry row 0's color + let row0_outgoing: Vec<_> = rows[0] + .edges + .iter() + .filter(|e| e.from_lane == rows[0].node_lane) + .collect(); + for edge in row0_outgoing { + assert_eq!( + edge.color_index, row0_color, + "outgoing edge from a lane should carry that lane's color" + ); + } + } + + #[test] + fn test_feature_ahead_zero_behind_routes_parent_to_lane_0() { + // HEAD on main, feature branch ahead of main with 0 behind. + // Feature's oldest commit's parent is main tip. Topo order puts the + // newer feature commits first, so F_base is processed before M1. + // + // Without lane 0 reservation, F_base's outgoing to M1 would reuse the + // freed feature lane and produce a straight {1, 1} edge plus a + // merge-in at M1's row. That straight edge's 4 px overhang below the + // row sticks out visibly because M1 has no approach on lane 1. + // + // After the fix, lane 0 is reserved so route_parent finds an exact + // match at lane 0 and emits {1, 0} (a curve) — no overhang on lane 1. + let commits = vec![ + make_commit(10, &[20], vec![RefLabel::LocalBranch("feature".into())]), + make_commit(20, &[30], vec![]), + make_commit( + 30, + &[40], + vec![RefLabel::Head, RefLabel::LocalBranch("main".into())], + ), + make_commit(40, &[], vec![]), + ]; + let rows = compute_graph(&commits); + + let f_base = &rows[1]; + assert_eq!(f_base.node_lane, 1); + let outgoing_from_feature_lane: Vec<_> = + f_base.edges.iter().filter(|e| e.from_lane == 1).collect(); + assert!( + outgoing_from_feature_lane.iter().any(|e| e.to_lane == 0), + "F_base's outgoing edge should target lane 0 (main), got edges: {:?}", + f_base.edges + ); + assert!( + !outgoing_from_feature_lane.iter().any(|e| e.to_lane == 1), + "F_base must not emit a straight {{1, 1}} outgoing, got edges: {:?}", + f_base.edges + ); + } + + #[test] + fn test_lane_zero_continues_from_first_curve_in_to_main_tip() { + // A side branch above main_tip curves into lane 0 at row 1, while + // another unrelated side branch occupies row 2 before main_tip + // appears at row 3. + // + // Old ghost-lane trimming stripped every lane-0 pass-through above + // main_tip, which removed row 2's `0 -> 0` edge and left the curve-in + // from row 1 dangling without continuity into main_tip. + // + // We should strip only through the first curve-in row, then keep lane + // 0 drawn on the remaining rows down to main_tip. + let commits = vec![ + make_commit(10, &[20], vec![RefLabel::LocalBranch("feature-a".into())]), + make_commit(20, &[30], vec![]), + make_commit(15, &[40], vec![RefLabel::LocalBranch("feature-b".into())]), + make_commit( + 30, + &[40], + vec![RefLabel::Head, RefLabel::LocalBranch("main".into())], + ), + make_commit(40, &[], vec![]), + ]; + let rows = compute_graph(&commits); + + let feature_a_lane = rows[1].node_lane; + assert_ne!(feature_a_lane, 0, "feature base should stay off lane 0"); + assert!( + rows[1] + .edges + .iter() + .any(|e| e.from_lane == feature_a_lane && e.to_lane == 0), + "feature base should curve into lane 0" + ); + assert!( + rows[2] + .edges + .iter() + .any(|e| e.from_lane == 0 && e.to_lane == 0), + "lane 0 pass-through should continue after the first curve-in" + ); + assert_eq!(rows[3].node_lane, 0, "main tip should remain on lane 0"); + assert!( + rows[3].has_incoming, + "main tip should keep an incoming line once a curve-in appears above it" + ); + } + + #[test] + fn test_main_tip_first_commit_has_no_incoming() { + // When main_tip is the topmost commit, lane 0 must not be + // pre-reserved — otherwise main_tip's row is marked as having an + // incoming line from above, producing a 4 px approach stub above + // the top of the graph. + let commits = vec![ + make_commit( + 1, + &[2], + vec![RefLabel::Head, RefLabel::LocalBranch("main".into())], + ), + make_commit(2, &[], vec![]), + ]; + let rows = compute_graph(&commits); + assert_eq!(rows[0].node_lane, 0); + assert!( + !rows[0].has_incoming, + "topmost main commit should not report incoming" + ); + } + + #[test] + fn test_has_incoming_false_for_first_and_new_branches() { + let commits = vec![ + make_commit(1, &[3], vec![RefLabel::Head]), + make_commit(2, &[3], vec![RefLabel::LocalBranch("feature".into())]), + make_commit(3, &[], vec![]), + ]; + let rows = compute_graph(&commits); + // Row 0 (HEAD tip): first commit, no incoming + assert!(!rows[0].has_incoming); + // Row 1 (feature tip): new branch, no incoming + assert!(!rows[1].has_incoming); + // Row 2 (merge): expected by both previous commits, has incoming + assert!(rows[2].has_incoming); + } + + // ── is_ancestor_of tests ──────────────────────────────────────── + // Note: is_ancestor_of is called in compute_graph as: + // is_ancestor_of(candidate_oid, expected_parent_oid, ...) + // meaning: "is candidate_oid an ancestor of expected_parent_oid?" + // The commits list is in topological order (descendants before ancestors). + + fn make_oid_to_idx(commits: &[CommitInfo]) -> std::collections::HashMap { + commits + .iter() + .enumerate() + .map(|(i, c)| (c.oid, i)) + .collect() + } + + #[test] + fn is_ancestor_of_direct_parent() { + // Commits in topological order: HEAD=oid1, parent=oid2 + // is_ancestor_of(oid2, oid1) = true (oid2 is ancestor of oid1) + let commits = vec![ + make_commit(1, &[2], vec![RefLabel::Head]), + make_commit(2, &[], vec![]), + ]; + let oid_to_idx = make_oid_to_idx(&commits); + let mut ancestry = AncestorCache::new(); + // oid2 IS ancestor of oid1 (direct parent) + assert!(ancestry.is_ancestor_of(make_oid(2), make_oid(1), &commits, &oid_to_idx)); + // oid1 is NOT ancestor of oid2 (reverse direction) + assert!(!ancestry.is_ancestor_of(make_oid(1), make_oid(2), &commits, &oid_to_idx)); + } + + #[test] + fn is_ancestor_of_grandparent() { + // Chain: oid1 → oid2 → oid3 → none (topological: oid1, oid2, oid3) + // oid3 is ancestor of oid1 (grandparent), oid2 is direct parent + let commits = vec![ + make_commit(1, &[2], vec![RefLabel::Head]), + make_commit(2, &[3], vec![]), + make_commit(3, &[], vec![]), + ]; + let oid_to_idx = make_oid_to_idx(&commits); + let mut ancestry = AncestorCache::new(); + // oid3 is ancestor of oid1 (grandparent) + assert!(ancestry.is_ancestor_of(make_oid(3), make_oid(1), &commits, &oid_to_idx)); + // oid2 is ancestor of oid1 (direct parent) + assert!(ancestry.is_ancestor_of(make_oid(2), make_oid(1), &commits, &oid_to_idx)); + // oid1 is NOT ancestor of oid3 (reverse direction) + assert!(!ancestry.is_ancestor_of(make_oid(1), make_oid(3), &commits, &oid_to_idx)); + } + + #[test] + fn is_ancestor_of_reflexive() { + // A commit is its own ancestor (reflexive property) + let commits = vec![make_commit(1, &[], vec![RefLabel::Head])]; + let oid_to_idx = make_oid_to_idx(&commits); + let mut ancestry = AncestorCache::new(); + assert!(ancestry.is_ancestor_of(make_oid(1), make_oid(1), &commits, &oid_to_idx)); + } + + #[test] + fn is_ancestor_of_unrelated_branches() { + // Two separate chains with no common ancestor + let commits = vec![ + make_commit(1, &[2], vec![RefLabel::Head]), + make_commit(2, &[], vec![]), + make_commit(10, &[20], vec![]), + make_commit(20, &[], vec![]), + ]; + let oid_to_idx = make_oid_to_idx(&commits); + let mut ancestry = AncestorCache::new(); + // No cross-branch ancestry + assert!(!ancestry.is_ancestor_of(make_oid(1), make_oid(10), &commits, &oid_to_idx)); + assert!(!ancestry.is_ancestor_of(make_oid(10), make_oid(1), &commits, &oid_to_idx)); + // Within each chain: parent is ancestor of child + assert!(ancestry.is_ancestor_of(make_oid(2), make_oid(1), &commits, &oid_to_idx)); + assert!(ancestry.is_ancestor_of(make_oid(20), make_oid(10), &commits, &oid_to_idx)); + } + + #[test] + fn is_ancestor_of_merge_commit() { + // Merge: oid1 ← [oid2, oid20]; oid2 ← [oid3]; oid20 ← [oid3] + // Topological: [oid1, oid2, oid20, oid3] + // oid3 is common ancestor of oid1 (via oid2 AND oid20) + // oid2 is direct parent of oid1 (merge parent) + // oid20 is direct parent of oid1 (merge parent) + let commits = vec![ + make_commit(1, &[2, 20], vec![RefLabel::Head]), // merge commit + make_commit(2, &[3], vec![]), // branch 1 + make_commit(20, &[3], vec![]), // branch 2 + make_commit(3, &[], vec![]), // common ancestor + ]; + let oid_to_idx = make_oid_to_idx(&commits); + let mut ancestry = AncestorCache::new(); + // oid3 is ancestor of oid1 (via either branch) + assert!(ancestry.is_ancestor_of(make_oid(3), make_oid(1), &commits, &oid_to_idx)); + // oid2 is ancestor of oid1 (direct merge parent) + assert!(ancestry.is_ancestor_of(make_oid(2), make_oid(1), &commits, &oid_to_idx)); + // oid20 is ancestor of oid1 (direct merge parent) + assert!(ancestry.is_ancestor_of(make_oid(20), make_oid(1), &commits, &oid_to_idx)); + // Reverse: oid1 is NOT ancestor of oid3 + assert!(!ancestry.is_ancestor_of(make_oid(1), make_oid(3), &commits, &oid_to_idx)); + } + + #[test] + fn is_ancestor_of_missing_oid_returns_false() { + // OID not in the commit list → not found in oid_to_idx → returns false (no panic) + let commits = vec![make_commit(1, &[], vec![RefLabel::Head])]; + let oid_to_idx = make_oid_to_idx(&commits); + let mut ancestry = AncestorCache::new(); + // Nonexistent OID is not ancestor of anything + assert!(!ancestry.is_ancestor_of(make_oid(99), make_oid(1), &commits, &oid_to_idx)); + // Nothing is ancestor of a nonexistent OID + assert!(!ancestry.is_ancestor_of(make_oid(1), make_oid(99), &commits, &oid_to_idx)); + } + + // ── Main-branch-awareness tests ──────────────────────────────────── + + #[test] + fn test_feature_ahead_of_main_gets_own_lane() { + // Feature branch is 2 commits ahead of main, no divergence. + // HEAD is on the feature branch, main is at commit 3. + // + // Expected graph: + // Lane 0 Lane 1 + // │ ● C1 (feature, HEAD) + // │ │ + // │ ● C2 + // │ ╱ + // ● C3 (main) + // │ + // ● C4 + let commits = vec![ + make_commit( + 1, + &[2], + vec![RefLabel::Head, RefLabel::LocalBranch("feature".into())], + ), + make_commit(2, &[3], vec![]), + make_commit(3, &[4], vec![RefLabel::LocalBranch("main".into())]), + make_commit(4, &[], vec![]), + ]; + let rows = compute_graph(&commits); + + // Feature commits (1, 2) should NOT be on lane 0 + assert_ne!( + rows[0].node_lane, 0, + "feature HEAD should not be on main's lane" + ); + assert_ne!( + rows[1].node_lane, 0, + "feature commit should not be on main's lane" + ); + + // Main commits (3, 4) should be on lane 0 + assert_eq!(rows[2].node_lane, 0, "main tip should be on lane 0"); + assert_eq!(rows[3].node_lane, 0, "main ancestor should be on lane 0"); + } + + #[test] + fn test_main_stays_lane_zero_after_merge() { + // Merge commit on main where git stored feature as parents[0]. + // Main's history should still continue on lane 0. + // + // Git history: M (main, HEAD) → parents: [F1, M1] + // F1 (feature) → parent: M1 + // M1 → parent: M0 + // + // Note: parents[0] = F1 (feature), parents[1] = M1 (main ancestor) + // This simulates `git merge main` while on feature, then fast-forward. + let commits = vec![ + make_commit( + 1, + &[2, 3], + vec![RefLabel::Head, RefLabel::LocalBranch("main".into())], + ), + make_commit(2, &[3], vec![RefLabel::LocalBranch("feature".into())]), + make_commit(3, &[4], vec![]), + make_commit(4, &[], vec![]), + ]; + let rows = compute_graph(&commits); + + // Merge commit on lane 0 (it's on main) + assert_eq!(rows[0].node_lane, 0, "merge commit should be on lane 0"); + // Main's ancestor (commit 3) should be on lane 0 + assert_eq!(rows[2].node_lane, 0, "main ancestor should stay on lane 0"); + assert_eq!( + rows[3].node_lane, 0, + "deep main ancestor should stay on lane 0" + ); + // Feature commit (2) should be on a different lane + assert_ne!(rows[1].node_lane, 0, "feature should not be on lane 0"); + } + + #[test] + fn test_main_stays_lane_zero_multiple_merges() { + // Two successive merges on main — main should always stay on lane 0. + // + // M2 (main, HEAD) → parents: [M1, F2] + // F2 (feature-2) → parent: M1 + // M1 → parents: [M0, F1] + // F1 (feature-1) → parent: M0 + // M0 + let commits = vec![ + make_commit( + 1, + &[3, 2], + vec![RefLabel::Head, RefLabel::LocalBranch("main".into())], + ), + make_commit(2, &[3], vec![RefLabel::LocalBranch("feature-2".into())]), + make_commit(3, &[5, 4], vec![]), + make_commit(4, &[5], vec![RefLabel::LocalBranch("feature-1".into())]), + make_commit(5, &[], vec![]), + ]; + let rows = compute_graph(&commits); + + // All main-chain commits on lane 0 + assert_eq!(rows[0].node_lane, 0, "M2 should be on lane 0"); + assert_eq!(rows[2].node_lane, 0, "M1 should be on lane 0"); + assert_eq!(rows[4].node_lane, 0, "M0 should be on lane 0"); + // Feature branches on other lanes + assert_ne!(rows[1].node_lane, 0, "feature-2 should not be on lane 0"); + assert_ne!(rows[3].node_lane, 0, "feature-1 should not be on lane 0"); + } + + #[test] + fn test_no_main_branch_falls_back_to_head() { + // No "main" or "master" ref — HEAD's first-parent chain is treated as main. + let commits = vec![ + make_commit(1, &[2], vec![RefLabel::Head]), + make_commit(2, &[3], vec![]), + make_commit(3, &[], vec![]), + ]; + let rows = compute_graph(&commits); + // All on lane 0 (HEAD's first-parent chain = main fallback) + assert_eq!(rows[0].node_lane, 0); + assert_eq!(rows[1].node_lane, 0); + assert_eq!(rows[2].node_lane, 0); + } + + #[test] + fn test_master_branch_detected() { + // "master" branch is detected as main when "main" doesn't exist + let commits = vec![ + make_commit( + 1, + &[2], + vec![RefLabel::Head, RefLabel::LocalBranch("feature".into())], + ), + make_commit(2, &[3], vec![RefLabel::LocalBranch("master".into())]), + make_commit(3, &[], vec![]), + ]; + let rows = compute_graph(&commits); + // HEAD is on feature, not master — feature should not be on lane 0 + assert_ne!(rows[0].node_lane, 0, "feature HEAD should not be on lane 0"); + // master should be on lane 0 + assert_eq!(rows[1].node_lane, 0, "master should be on lane 0"); + } + + #[test] + fn test_remote_main_detected() { + // origin/main is detected as main when no local main exists + let commits = vec![ + make_commit( + 1, + &[2], + vec![RefLabel::Head, RefLabel::LocalBranch("feature".into())], + ), + make_commit(2, &[3], vec![RefLabel::RemoteBranch("origin/main".into())]), + make_commit(3, &[], vec![]), + ]; + let rows = compute_graph(&commits); + assert_ne!(rows[0].node_lane, 0, "feature HEAD should not be on lane 0"); + assert_eq!(rows[1].node_lane, 0, "origin/main should be on lane 0"); + } + + /// Uses LIVE data from the actual rgitui repository via gather_refresh_data. + /// This test sees exactly the same commits, refs, and ordering as the app. + #[test] + fn test_live_repo_graph() { + use std::path::Path; + + let repo_path = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap(); + + let data = match crate::gather_refresh_data(repo_path, 1000) { + Ok(d) => d, + Err(e) => { + eprintln!("Skipping live repo test: {}", e); + return; + } + }; + + let commits = &data.recent_commits; + let rows = compute_graph(commits); + + // Find key commits by OID prefix + let find = |prefix: &str| -> Option { + commits + .iter() + .position(|c| format!("{}", c.oid).starts_with(prefix)) + }; + + let some_795 = find("795f1ba"); + let some_030 = find("0308d39"); + let some_f50 = find("f50abd6"); + let some_1bb = find("1bb477f"); + let some_e5d = find("e5d6285"); + let some_3ce = find("3ce915d"); + + if [some_795, some_030, some_f50, some_1bb, some_e5d, some_3ce] + .iter() + .any(|x| x.is_none()) + { + eprintln!("Skipping: worktree demo commits not found"); + return; + } + + let i795 = some_795.unwrap(); + let i030 = some_030.unwrap(); + let if50 = some_f50.unwrap(); + let i1bb = some_1bb.unwrap(); + let ie5d = some_e5d.unwrap(); + let i3ce = some_3ce.unwrap(); + + // Dump first 15 rows + eprintln!("\n=== LIVE COMMIT ORDER (first 15) ==="); + for (i, c) in commits.iter().enumerate().take(15) { + let short = &format!("{}", c.oid)[..7]; + let refs_s: Vec<_> = c.refs.iter().map(|r| format!("{:?}", r)).collect(); + let pars: Vec<_> = c + .parent_oids + .iter() + .map(|p| format!("{}", p)[..7].to_string()) + .collect(); + eprintln!( + " idx {:2}: {} parents=[{}] refs=[{}]", + i, + short, + pars.join(","), + refs_s.join(",") + ); + } + + eprintln!("\n=== GRAPH OUTPUT ==="); + for (i, r) in rows.iter().enumerate().take(15) { + let short = &format!("{}", commits[i].oid)[..7]; + let es: Vec<_> = r + .edges + .iter() + .map(|e| { + format!( + "{}→{}(c{}{})", + e.from_lane, + e.to_lane, + e.color_index, + if e.is_merge { ",m" } else { "" } + ) + }) + .collect(); + eprintln!( + " row {:2} [{}]: lane={} color={} inc={} head={} lanes={} edges=[{}]", + i, + short, + r.node_lane, + r.node_color, + r.has_incoming, + r.is_head, + r.lane_count, + es.join(", ") + ); + } + + // ═══════════ ASSERTIONS ═══════════ + + // Main-chain commits on lane 0 + assert_eq!( + rows[if50].node_lane, 0, + "f50abd6 (HEAD/main) must be lane 0, was {}", + rows[if50].node_lane + ); + assert_eq!( + rows[i1bb].node_lane, 0, + "1bb477f must be lane 0, was {}", + rows[i1bb].node_lane + ); + assert_eq!( + rows[ie5d].node_lane, 0, + "e5d6285 must be lane 0, was {}", + rows[ie5d].node_lane + ); + assert_eq!( + rows[i3ce].node_lane, 0, + "3ce915d must be lane 0, was {}", + rows[i3ce].node_lane + ); + + // Feature branches NOT on lane 0 + assert_ne!(rows[i795].node_lane, 0, "795f1ba must NOT be lane 0"); + assert_ne!(rows[i030].node_lane, 0, "0308d39 must NOT be lane 0"); + + // HEAD + assert!(rows[if50].is_head, "f50abd6 should be HEAD"); + + // After 3ce915d, lane count should be 1 (only main) + if i3ce + 1 < rows.len() { + assert_eq!( + rows[i3ce + 1].lane_count, + 1, + "after 3ce915d, only lane 0 should remain, was {}", + rows[i3ce + 1].lane_count + ); + } + } + + /// Build a CommitInfo from a real hex OID string, parent hex strings, and refs. + fn make_real_commit(hex: &str, parent_hexes: &[&str], refs: Vec) -> CommitInfo { + CommitInfo { + oid: git2::Oid::from_str(hex).unwrap(), + short_id: hex[..7].to_string(), + summary: format!("Commit {}", &hex[..7]), + message: format!("Commit {}", &hex[..7]), + author: crate::Signature { + name: "Test".to_string(), + email: "test@test.com".to_string(), + }, + committer: crate::Signature { + name: "Test".to_string(), + email: "test@test.com".to_string(), + }, + co_authors: vec![], + time: Utc::now(), + parent_oids: parent_hexes + .iter() + .map(|h| git2::Oid::from_str(h).unwrap()) + .collect(), + refs, + is_signed: false, + } + } + + /// Loads the REAL commits from the rgitui repo using the same code path + /// as the app (`gather_refresh_data`), then runs `compute_graph` and + /// verifies the output matches expectations. + /// + /// `git log --all --topo-order --format="%H %P %D"` gives: + /// + /// ```text + /// 795f1ba e5d6285 codex/demo-wt-commit + /// 0308d39 3ce915d codex/demo-wt-pending + /// f50abd6 52c7065 HEAD -> main, origin/main + /// 52c7065 9b17780 + /// 9b17780 1bb477f + /// 1bb477f e5d6285 + /// e5d6285 9328231 + /// 9328231 ba6e821 + /// ba6e821 3ce915d + /// 3ce915d bc215a6 + /// bc215a6 (root for this test) + /// ``` + /// + /// Expected graph: + /// + /// ```text + /// Lane 0 Lane 1 + /// + /// ● 795f1ba (codex/demo-wt-commit, parent=e5d6285) + /// │ + /// ├─── ● 0308d39 (codex/demo-wt-pending, parent=3ce915d) + /// │ edge from lane 2 merges to lane 1 (ancestor match) + /// ● f50abd6 HEAD │ + /// │ main │ + /// ● 52c7065 │ + /// ● 9b17780 │ + /// ● 1bb477f │ + /// ├──────────────────┘ (1bb477f parent=e5d6285, routed to lane 0; lane 1 still holds 3ce915d) + /// ● e5d6285 (zombie cleanup: e5d6285 was on lane 0 only, lane 1 has 3ce915d) + /// │ │ (lane 1 still active with 3ce915d) + /// ● 9328231 │ + /// ● ba6e821 │ + /// ├──────────────────┘ (ba6e821 parent=3ce915d, lane 1 consumed) + /// ● 3ce915d + /// ● bc215a6 (single lane) + /// ``` + #[test] + fn test_real_repo_worktree_graph() { + // Real OIDs from the repository — topo order as git log produces them. + let commits = vec![ + // idx 0: 795f1ba — codex/demo-wt-commit branch tip + make_real_commit( + "795f1bae4648ac10a13e597a68520e07f948618c", + &["e5d62851d80d237a098ea6d8853653c5e06b25a4"], + vec![RefLabel::LocalBranch("codex/demo-wt-commit".into())], + ), + // idx 1: 0308d39 — codex/demo-wt-pending branch tip + make_real_commit( + "0308d393027b9f094a89a1f12347c4af60441a5a", + &["3ce915d70b21531099ca7535ee6c85860eeb173f"], + vec![RefLabel::LocalBranch("codex/demo-wt-pending".into())], + ), + // idx 2: f50abd6 — HEAD, main, origin/main + make_real_commit( + "f50abd6ef5b1830b8e1ac416018c2b5ffe44ec70", + &["52c706572bef2796495d9cbb21d41d3e6db72456"], + vec![ + RefLabel::Head, + RefLabel::LocalBranch("main".into()), + RefLabel::RemoteBranch("origin/main".into()), + ], + ), + // idx 3: 52c7065 + make_real_commit( + "52c706572bef2796495d9cbb21d41d3e6db72456", + &["9b17780bcb0c8fd865cfecc5d277302210a6b076"], + vec![], + ), + // idx 4: 9b17780 + make_real_commit( + "9b17780bcb0c8fd865cfecc5d277302210a6b076", + &["1bb477f600d328841ee81675dc2fb3929942cf59"], + vec![], + ), + // idx 5: 1bb477f (parent = e5d6285, which is the fork point for wt-commit) + make_real_commit( + "1bb477f600d328841ee81675dc2fb3929942cf59", + &["e5d62851d80d237a098ea6d8853653c5e06b25a4"], + vec![], + ), + // idx 6: e5d6285 (fork point 1) + make_real_commit( + "e5d62851d80d237a098ea6d8853653c5e06b25a4", + &["93282319230eb1d6c31e74e1af98092718fba3b2"], + vec![], + ), + // idx 7: 9328231 + make_real_commit( + "93282319230eb1d6c31e74e1af98092718fba3b2", + &["ba6e82166f7c291d0c0f21dcc78e97eba5062e46"], + vec![], + ), + // idx 8: ba6e821 (parent = 3ce915d, which is the fork point for wt-pending) + make_real_commit( + "ba6e82166f7c291d0c0f21dcc78e97eba5062e46", + &["3ce915d70b21531099ca7535ee6c85860eeb173f"], + vec![], + ), + // idx 9: 3ce915d (fork point 2) + make_real_commit( + "3ce915d70b21531099ca7535ee6c85860eeb173f", + &["bc215a60757d394a0fc3e5cbf51476c95081f8ee"], + vec![], + ), + // idx 10: bc215a6 (root for this test) + make_real_commit("bc215a60757d394a0fc3e5cbf51476c95081f8ee", &[], vec![]), + ]; + + let rows = compute_graph(&commits); + assert_eq!(rows.len(), 11, "should produce 11 rows"); + + // ═══════════════════════════════════════════════════════════ + // Dump all rows for debugging on failure + // ═══════════════════════════════════════════════════════════ + for (i, r) in rows.iter().enumerate() { + let edges_str: Vec<_> = r + .edges + .iter() + .map(|e| { + format!( + "{}→{}(c{}{})", + e.from_lane, + e.to_lane, + e.color_index, + if e.is_merge { ",m" } else { "" } + ) + }) + .collect(); + eprintln!( + " row {:2} [{}]: lane={} color={} incoming={} head={} merge={} lanes={} edges=[{}]", + i, commits[i].short_id, r.node_lane, r.node_color, + r.has_incoming, r.is_head, r.is_merge, r.lane_count, + edges_str.join(", "), + ); + } + + // ═══════════════════════════════════════════════════════════ + // 1. LANE ASSIGNMENTS — main on lane 0, branches elsewhere + // ═══════════════════════════════════════════════════════════ + + // idx 0: 795f1ba (wt-commit) — NOT on main, should be lane 1 + assert_ne!(rows[0].node_lane, 0, "795f1ba must not be on lane 0"); + let wt_commit_lane = rows[0].node_lane; + + // idx 1: 0308d39 (wt-pending) — NOT on main + assert_ne!(rows[1].node_lane, 0, "0308d39 must not be on lane 0"); + let wt_pending_lane = rows[1].node_lane; + + // idx 2-10: all on lane 0 (entire main chain) + for idx in 2..=10 { + assert_eq!( + rows[idx].node_lane, 0, + "idx {} ({}) must be lane 0 (main chain), was lane {}", + idx, commits[idx].short_id, rows[idx].node_lane + ); + } + + // ═══════════════════════════════════════════════════════════ + // 2. HEAD — only idx 2 (f50abd6) is HEAD + // ═══════════════════════════════════════════════════════════ + + for (i, r) in rows.iter().enumerate() { + if i == 2 { + assert!(r.is_head, "idx 2 (f50abd6) IS HEAD"); + } else { + assert!(!r.is_head, "idx {} is not HEAD", i); + } + } + + // ═══════════════════════════════════════════════════════════ + // 3. HAS_INCOMING + // ═══════════════════════════════════════════════════════════ + + // Branch tips: no line drawn from above into them + assert!(!rows[0].has_incoming, "795f1ba: new branch, no incoming"); + assert!(!rows[1].has_incoming, "0308d39: new branch, no incoming"); + + // main_tip (idx 2) is the topmost lane-0 commit — rows above it are + // feature branches with no lane-0 content once the ghost pass-through + // is trimmed, so has_incoming is false for main_tip itself. Its + // ancestors (idx 3..=10) still chain continuously on lane 0. + assert!( + !rows[2].has_incoming, + "f50abd6 (main_tip): no lane-0 continuation above" + ); + for (idx, r) in rows.iter().enumerate().skip(3).take(8) { + assert!( + r.has_incoming, + "idx {} should have incoming (lane 0 continuation)", + idx + ); + } + + // ═══════════════════════════════════════════════════════════ + // 4. COLORS — consistent within each branch + // ═══════════════════════════════════════════════════════════ + + let main_color = rows[2].node_color; + for (idx, r) in rows.iter().enumerate().skip(3).take(8) { + assert_eq!( + r.node_color, main_color, + "idx {} color should match main ({}), was {:?}", + idx, main_color, r.node_color + ); + } + + // ═══════════════════════════════════════════════════════════ + // 5. EDGE: branch tip outgoing edges — each branch keeps its own lane + // ═══════════════════════════════════════════════════════════ + + // 795f1ba (idx 0): outgoing edge stays on its own lane (node_lane reuse) + let idx0_out: Vec<_> = rows[0] + .edges + .iter() + .filter(|e| e.from_lane == wt_commit_lane) + .collect(); + assert_eq!(idx0_out.len(), 1, "795f1ba: 1 outgoing edge"); + assert_eq!( + idx0_out[0].to_lane, wt_commit_lane, + "795f1ba: parent stays on its own lane" + ); + + // 0308d39 (idx 1): outgoing edge stays on its own lane + let idx1_out: Vec<_> = rows[1] + .edges + .iter() + .filter(|e| e.from_lane == wt_pending_lane) + .collect(); + assert_eq!(idx1_out.len(), 1, "0308d39: 1 outgoing edge"); + assert_eq!( + idx1_out[0].to_lane, wt_pending_lane, + "0308d39: parent stays on its own lane" + ); + + // Each branch has its own lane (node_lane reuse keeps branch alive) + assert_ne!( + wt_commit_lane, wt_pending_lane, + "each branch should have its own lane" + ); + + // ═══════════════════════════════════════════════════════════ + // 6. PASS-THROUGH: each branch lane visible until its fork point + // ═══════════════════════════════════════════════════════════ + + let has_pt = |row: &GraphRow, lane: usize| -> bool { + row.edges + .iter() + .any(|e| e.from_lane == lane && e.to_lane == lane) + }; + + // wt_commit_lane passes through idx 1-5, then merge-in at idx 6 (e5d6285) + for (idx, row) in rows.iter().enumerate().take(6).skip(1) { + assert!( + has_pt(row, wt_commit_lane), + "idx {}: wt_commit lane {} should pass through", + idx, + wt_commit_lane + ); + } + + // wt_pending_lane passes through idx 2-8, then merge-in at idx 9 (3ce915d) + for (idx, row) in rows.iter().enumerate().take(9).skip(2) { + assert!( + has_pt(row, wt_pending_lane), + "idx {}: wt_pending lane {} should pass through", + idx, + wt_pending_lane + ); + } + + // ═══════════════════════════════════════════════════════════ + // 7. MERGE-IN EDGES at fork points + // ═══════════════════════════════════════════════════════════ + + // idx 6 (e5d6285): wt_commit_lane merge-in edge converges to lane 0 + let merge_in_6: Vec<_> = rows[6] + .edges + .iter() + .filter(|e| e.from_lane == wt_commit_lane && e.to_lane == 0) + .collect(); + assert_eq!(merge_in_6.len(), 1, "e5d6285: merge-in from wt_commit lane"); + + // idx 9 (3ce915d): wt_pending_lane merge-in edge converges to lane 0 + let merge_in_9: Vec<_> = rows[9] + .edges + .iter() + .filter(|e| e.from_lane == wt_pending_lane && e.to_lane == 0) + .collect(); + assert_eq!( + merge_in_9.len(), + 1, + "3ce915d: merge-in from wt_pending lane" + ); + + // Main edge stays on lane 0 throughout + let idx5_main = rows[5] + .edges + .iter() + .find(|e| e.from_lane == 0 && !e.is_merge); + assert!( + idx5_main.is_some(), + "1bb477f: should have main outgoing edge" + ); + assert_eq!( + idx5_main.unwrap().to_lane, + 0, + "1bb477f: main edge stays on lane 0" + ); + + let idx8_main = rows[8] + .edges + .iter() + .find(|e| e.from_lane == 0 && !e.is_merge); + assert!( + idx8_main.is_some(), + "ba6e821: should have main outgoing edge" + ); + assert_eq!( + idx8_main.unwrap().to_lane, + 0, + "ba6e821: main edge stays on lane 0" + ); + + // ═══════════════════════════════════════════════════════════ + // 8. LANE COMPACTION — lanes freed after fork points + // ═══════════════════════════════════════════════════════════ + + // After idx 9 (3ce915d consumed): both branch lanes should be cleared. + // idx 10 should have only lane 0. + assert_eq!( + rows[10].lane_count, 1, + "bc215a6: only lane 0 should remain, lane_count = {}", + rows[10].lane_count + ); + + // No pass-through edges at idx 10 + let idx10_pt = rows[10] + .edges + .iter() + .filter(|e| e.from_lane == e.to_lane) + .count(); + assert_eq!( + idx10_pt, 0, + "bc215a6: no pass-through edges, found {}", + idx10_pt + ); + } + + #[test] + fn test_remote_main_ahead_of_local_main() { + // origin/main is several commits ahead of local main. + // The entire first-parent chain should stay on lane 0. + // + // Topo order (newest first): + // C1 (origin/main) → C2 → C3 → C4 (HEAD, local main) → C5 + let commits = vec![ + make_commit(1, &[2], vec![RefLabel::RemoteBranch("origin/main".into())]), + make_commit(2, &[3], vec![]), + make_commit(3, &[4], vec![]), + make_commit( + 4, + &[5], + vec![RefLabel::Head, RefLabel::LocalBranch("main".into())], + ), + make_commit(5, &[], vec![]), + ]; + let rows = compute_graph(&commits); + + // All commits on lane 0 — origin/main is the effective main tip + for (i, row) in rows.iter().enumerate() { + assert_eq!( + row.node_lane, 0, + "commit {} should be on lane 0 (remote main ahead)", + i + ); + } + } + + #[test] + fn test_remote_main_ahead_with_merge() { + // origin/main is ahead of local main, with a merge commit in between. + // The branch should fork and converge correctly without crossings. + // + // C1 (origin/main) → C2 (merge, parents: C3, C4) + // C4 (feature branch) → C5 + // C3 → C5 + // C5 (HEAD, local main) → C6 + let commits = vec![ + make_commit(1, &[2], vec![RefLabel::RemoteBranch("origin/main".into())]), + make_commit(2, &[3, 4], vec![]), // merge commit + make_commit(4, &[5], vec![RefLabel::RemoteBranch("origin/feat".into())]), + make_commit(3, &[5], vec![]), + make_commit( + 5, + &[6], + vec![RefLabel::Head, RefLabel::LocalBranch("main".into())], + ), + make_commit(6, &[], vec![]), + ]; + let rows = compute_graph(&commits); + + // C1 on lane 0 (origin/main tip, on main chain) + assert_eq!(rows[0].node_lane, 0, "origin/main tip on lane 0"); + // Merge commit C2 on lane 0 + assert_eq!(rows[1].node_lane, 0, "merge commit on lane 0"); + // Feature C4 should NOT be on lane 0 + assert_ne!(rows[2].node_lane, 0, "feature branch not on lane 0"); + // C3 on lane 0 (main chain ancestor) + assert_eq!(rows[3].node_lane, 0, "main ancestor on lane 0"); + // C5 (HEAD, local main) on lane 0 + assert_eq!(rows[4].node_lane, 0, "HEAD/local main on lane 0"); + } + + #[test] + fn test_merge_in_edge_at_fork_point() { + // A branch forks at C4 and merges back at C1. The branch line should + // extend from C1 through C3 down to C4, producing a merge-in edge at + // C4's row that converges into lane 0. + // + // C1 (main, HEAD, merge) → parents: [C2, C3] + // C3 (feature) → C4 + // C2 → C4 + // C4 + let commits = vec![ + make_commit( + 1, + &[2, 3], + vec![RefLabel::Head, RefLabel::LocalBranch("main".into())], + ), + make_commit(3, &[4], vec![RefLabel::LocalBranch("feature".into())]), + make_commit(2, &[4], vec![]), + make_commit(4, &[], vec![]), + ]; + let rows = compute_graph(&commits); + + // C1 merge at lane 0 + assert_eq!(rows[0].node_lane, 0); + // C3 (feature) not on lane 0 + assert_ne!(rows[1].node_lane, 0); + // C2 on lane 0 + assert_eq!(rows[2].node_lane, 0); + // C4 on lane 0 (fork point) + assert_eq!(rows[3].node_lane, 0); + + // C4's row should have a merge-in edge from the feature lane to lane 0 + let merge_in_edges: Vec<_> = rows[3] + .edges + .iter() + .filter(|e| e.from_lane != e.to_lane && e.to_lane == rows[3].node_lane) + .collect(); + assert!( + !merge_in_edges.is_empty(), + "fork point should have a merge-in edge converging into lane 0" + ); + } +} diff --git a/third_party/rgitui/crates/rgitui_git/src/heap_size.rs b/third_party/rgitui/crates/rgitui_git/src/heap_size.rs new file mode 100644 index 000000000..8125ac4e1 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_git/src/heap_size.rs @@ -0,0 +1,322 @@ +//! [`HeapSize`] for the snapshot types this crate hands to the UI. +//! +//! These are the populations the perf census reports on, because they are the +//! ones that scale with the repository rather than with the window: a +//! [`CommitInfo`] per commit in the loaded window, a [`FileDiff`] per file in a +//! commit, a [`GraphRow`] per row of the graph. Every one of them is retained +//! in at least one cache, so knowing their true cost is what tells you whether +//! a cache bound expressed in *entries* is bounding anything at all. + +use rgitui_perf::{Census, HeapSize}; + +use crate::graph::{GraphEdge, GraphRow}; +use crate::project::{ + BlameEntry, BlameLine, ReflogEntryInfo, WorktreeFileSnapshot, WorktreeFileState, +}; +use crate::types::{ + BranchInfo, CommitDiff, CommitInfo, DiffHunk, DiffLine, FileChangeKind, FileDiff, FileStatus, + RefLabel, RemoteInfo, SearchResult, Signature, StashEntry, TagInfo, WorkingTreeStatus, + WorktreeInfo, +}; + +/// The change kind is a fieldless enum. +impl HeapSize for FileChangeKind { + fn heap_size(&self, _census: &mut Census) -> usize { + 0 + } +} + +impl HeapSize for Signature { + fn heap_size(&self, census: &mut Census) -> usize { + self.name.heap_size(census) + self.email.heap_size(census) + } +} + +impl HeapSize for RefLabel { + fn heap_size(&self, census: &mut Census) -> usize { + match self { + RefLabel::Head => 0, + RefLabel::LocalBranch(name) | RefLabel::RemoteBranch(name) | RefLabel::Tag(name) => { + name.heap_size(census) + } + } + } +} + +/// A commit carries its subject line twice: once as `summary` and again as the +/// first line of `message`. Both are counted, because both are really there — +/// the census exists to surface duplication like this, not to launder it. +impl HeapSize for CommitInfo { + fn heap_size(&self, census: &mut Census) -> usize { + self.short_id.heap_size(census) + + self.summary.heap_size(census) + + self.message.heap_size(census) + + self.author.heap_size(census) + + self.committer.heap_size(census) + + self.co_authors.heap_size(census) + + self.parent_oids.heap_size(census) + + self.refs.heap_size(census) + } +} + +impl HeapSize for BranchInfo { + fn heap_size(&self, census: &mut Census) -> usize { + self.name.heap_size(census) + + self.upstream.heap_size(census) + + self.author_email.heap_size(census) + } +} + +impl HeapSize for TagInfo { + fn heap_size(&self, census: &mut Census) -> usize { + self.name.heap_size(census) + self.message.heap_size(census) + } +} + +impl HeapSize for RemoteInfo { + fn heap_size(&self, census: &mut Census) -> usize { + self.name.heap_size(census) + self.url.heap_size(census) + self.push_url.heap_size(census) + } +} + +impl HeapSize for WorktreeInfo { + fn heap_size(&self, census: &mut Census) -> usize { + self.name.heap_size(census) + + self.path.heap_size(census) + + self.branch.heap_size(census) + + self.status.heap_size(census) + } +} + +impl HeapSize for StashEntry { + fn heap_size(&self, census: &mut Census) -> usize { + self.message.heap_size(census) + } +} + +impl HeapSize for FileStatus { + fn heap_size(&self, census: &mut Census) -> usize { + self.path.heap_size(census) + self.old_path.heap_size(census) + } +} + +impl HeapSize for WorkingTreeStatus { + fn heap_size(&self, census: &mut Census) -> usize { + self.staged.heap_size(census) + self.unstaged.heap_size(census) + } + + fn heap_count(&self) -> Option { + Some(self.staged.len() + self.unstaged.len()) + } +} + +/// Every variant owns one line of text, so the discriminant costs nothing extra. +impl HeapSize for DiffLine { + fn heap_size(&self, census: &mut Census) -> usize { + match self { + DiffLine::Context(text) | DiffLine::Addition(text) | DiffLine::Deletion(text) => { + text.heap_size(census) + } + } + } +} + +impl HeapSize for DiffHunk { + fn heap_size(&self, census: &mut Census) -> usize { + self.header.heap_size(census) + self.lines.heap_size(census) + } + + fn heap_count(&self) -> Option { + Some(self.lines.len()) + } +} + +impl HeapSize for FileDiff { + fn heap_size(&self, census: &mut Census) -> usize { + self.path.heap_size(census) + self.hunks.heap_size(census) + } + + /// Lines rather than hunks: a diff's cost tracks its line count, and a + /// hunk count would make a single 50,000-line hunk look cheap. + fn heap_count(&self) -> Option { + Some(self.hunks.iter().map(|hunk| hunk.lines.len()).sum()) + } +} + +impl HeapSize for CommitDiff { + fn heap_size(&self, census: &mut Census) -> usize { + self.files.heap_size(census) + } + + fn heap_count(&self) -> Option { + Some(self.files.len()) + } +} + +impl HeapSize for SearchResult { + fn heap_size(&self, census: &mut Census) -> usize { + self.path.heap_size(census) + self.content.heap_size(census) + } +} + +impl HeapSize for BlameEntry { + fn heap_size(&self, census: &mut Census) -> usize { + self.short_id.heap_size(census) + + self.author.heap_size(census) + + self.email.heap_size(census) + } +} + +/// Blame holds the whole file: one entry per line, each with its own copy of +/// the author and short id even where consecutive lines share a commit. +impl HeapSize for BlameLine { + fn heap_size(&self, census: &mut Census) -> usize { + self.content.heap_size(census) + self.entry.heap_size(census) + } +} + +/// A reflog entry holds two short ids, a message and a committer name, all as +/// owned strings. The reflog of a long-lived checkout runs to thousands of +/// entries, and the view keeps every one of them. +impl HeapSize for ReflogEntryInfo { + fn heap_size(&self, census: &mut Census) -> usize { + self.new_short_id.heap_size(census) + + self.old_short_id.heap_size(census) + + self.message.heap_size(census) + + self.committer.heap_size(census) + } +} + +/// A snapshotted working-tree file, held by the undo stack so an apply or +/// revert can be reversed byte for byte. Both halves are whole file contents, +/// which makes this the one snapshot type in the app whose size is set by the +/// files a user touched rather than by the repository. +impl HeapSize for WorktreeFileState { + fn heap_size(&self, _census: &mut Census) -> usize { + self.contents.capacity() + } + + fn heap_count(&self) -> Option { + Some(self.contents.len()) + } +} + +impl HeapSize for WorktreeFileSnapshot { + fn heap_size(&self, census: &mut Census) -> usize { + self.path.heap_size(census) + + self.before.heap_size(census) + + self.expected_after.heap_size(census) + } +} + +impl HeapSize for GraphEdge { + fn heap_size(&self, _census: &mut Census) -> usize { + 0 + } +} + +/// A graph row's own fields are scalars, but its `edges` vector is a separate +/// allocation per row — so a 100,000-commit graph is 100,000 allocations before +/// a single edge is stored. That is the cost this impl exists to make visible. +impl HeapSize for GraphRow { + fn heap_size(&self, census: &mut Census) -> usize { + self.edges.heap_size(census) + } + + fn heap_count(&self) -> Option { + Some(self.edges.len()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_commit_is_charged_for_every_string_it_owns() { + let mut census = Census::new(); + let commit = CommitInfo { + oid: git2::Oid::zero(), + short_id: "abc1234".to_string(), + summary: "Fix the thing".to_string(), + message: "Fix the thing\n\nWith detail.".to_string(), + author: Signature { + name: "A".to_string(), + email: "a@example.com".to_string(), + }, + committer: Signature { + name: "B".to_string(), + email: "b@example.com".to_string(), + }, + co_authors: Vec::new(), + time: chrono::Utc::now(), + parent_oids: vec![git2::Oid::zero()], + refs: vec![RefLabel::LocalBranch("main".to_string())], + is_signed: false, + }; + + let bytes = commit.heap_size(&mut census); + let strings = "abc1234".len() + + "Fix the thing".len() + + "Fix the thing\n\nWith detail.".len() + + "A".len() + + "a@example.com".len() + + "B".len() + + "b@example.com".len() + + "main".len(); + let vectors = size_of::() + size_of::(); + + assert_eq!(bytes, strings + vectors); + } + + #[test] + fn a_graph_row_reports_its_own_edge_allocation() { + let mut census = Census::new(); + let row = GraphRow { + commit_index: 0, + node_lane: 0, + edges: vec![ + GraphEdge { + from_lane: 0, + to_lane: 0, + color_index: 0, + is_merge: false, + }; + 3 + ], + lane_count: 1, + node_color: 0, + has_incoming: false, + is_head: false, + is_merge: false, + }; + + assert_eq!(row.heap_size(&mut census), 3 * size_of::()); + assert_eq!(row.heap_count(), Some(3)); + } + + #[test] + fn a_file_diff_counts_lines_rather_than_hunks() { + let mut census = Census::new(); + let diff = FileDiff { + path: std::path::PathBuf::from("src/main.rs"), + hunks: vec![DiffHunk { + old_start: 1, + old_lines: 2, + new_start: 1, + new_lines: 2, + header: "@@ -1,2 +1,2 @@".to_string(), + lines: vec![ + DiffLine::Context("a".to_string()), + DiffLine::Addition("b".to_string()), + ], + }], + additions: 1, + deletions: 0, + kind: FileChangeKind::Modified, + }; + + assert_eq!(diff.heap_count(), Some(2)); + assert!(diff.heap_size(&mut census) > 0); + } +} diff --git a/third_party/rgitui/crates/rgitui_git/src/lib.rs b/third_party/rgitui/crates/rgitui_git/src/lib.rs new file mode 100644 index 000000000..dbc867e47 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_git/src/lib.rs @@ -0,0 +1,9 @@ +mod graph; +#[cfg(feature = "perf")] +mod heap_size; +mod project; +mod types; + +pub use graph::*; +pub use project::*; +pub use types::*; diff --git a/third_party/rgitui/crates/rgitui_git/src/project/argsafe.rs b/third_party/rgitui/crates/rgitui_git/src/project/argsafe.rs new file mode 100644 index 000000000..8a2952302 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_git/src/project/argsafe.rs @@ -0,0 +1,152 @@ +//! Validation for values interpolated into `git` command lines. +//! +//! Remote and branch names reach the CLI as positional arguments. Git treats +//! any argument beginning with `-` as an option regardless of the position it +//! appears in, and it does not validate remote names when reading them back out +//! of `.git/config`. A repository carrying +//! `[remote "--upload-pack="]` therefore turns an ordinary fetch into +//! arbitrary command execution. `--` does not help: `git fetch -- ` is +//! rejected because git parses the trailing operand as a pathname. +//! +//! These checks run on every value that is about to become a positional +//! argument, both where the value is read out of the repository and where it +//! arrives from the UI. + +use anyhow::Result; + +/// What a rejected value was being used for, so the error names it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ArgKind { + Remote, + Branch, +} + +impl ArgKind { + fn noun(self) -> &'static str { + match self { + ArgKind::Remote => "Remote name", + ArgKind::Branch => "Branch name", + } + } +} + +/// Reject values that git would parse as an option or that would corrupt the +/// command line. +/// +/// Only a leading `-` can turn a positional argument into an option, so that is +/// the check that closes the injection. Control characters are rejected as well +/// because they cannot appear in a valid ref name and would otherwise produce +/// unreadable errors. +pub(crate) fn validate_cli_arg(value: &str, kind: ArgKind) -> Result<()> { + let noun = kind.noun(); + + if value.is_empty() { + anyhow::bail!("{noun} is empty. Check the repository's remote configuration."); + } + + if value.starts_with('-') { + anyhow::bail!( + "{noun} '{value}' starts with '-', which git would interpret as a command-line \ + option. Rename it with: git remote rename '{value}' " + ); + } + + if let Some(bad) = value.chars().find(|c| c.is_control()) { + anyhow::bail!( + "{noun} '{}' contains the control character {:?}, which is not valid in a git ref.", + value.escape_debug(), + bad + ); + } + + Ok(()) +} + +/// Convenience wrapper for remote names. +pub(crate) fn validate_remote_name(name: &str) -> Result<()> { + validate_cli_arg(name, ArgKind::Remote) +} + +/// Convenience wrapper for branch names. +pub(crate) fn validate_branch_name(name: &str) -> Result<()> { + validate_cli_arg(name, ArgKind::Branch) +} + +/// Quote a value for use inside a POSIX `sh` command line. +/// +/// Git runs `GIT_SSH_COMMAND` and rebase-todo `exec` lines through a shell — on +/// Windows that is the `sh` bundled with Git for Windows — so both need POSIX +/// quoting rules. The value is always quoted rather than only when it looks +/// dangerous, which keeps the guarantee independent of the caller's input. +pub(crate) fn sh_quote(value: &str) -> String { + // Inside single quotes every character is literal except `'` itself, which + // is closed, escaped, and reopened. + format!("'{}'", value.replace('\'', r"'\''")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_ordinary_names() { + for name in ["origin", "upstream", "my-fork", "remote.with.dots", "a"] { + assert!(validate_remote_name(name).is_ok(), "rejected {name}"); + } + } + + #[test] + fn accepts_branch_names_with_slashes_and_equals() { + // `=` is legal in a ref name and is only dangerous behind a leading `-`, + // which is rejected separately. + for name in ["main", "feat/thing", "release/1.2", "odd=name"] { + assert!(validate_branch_name(name).is_ok(), "rejected {name}"); + } + } + + #[test] + fn rejects_option_lookalikes() { + // The reproduced injection: a remote named so that `git fetch ` + // runs an arbitrary program. + assert!(validate_remote_name("--upload-pack=touch /tmp/pwned").is_err()); + assert!(validate_remote_name("--exec=whoami").is_err()); + assert!(validate_remote_name("-o").is_err()); + assert!(validate_branch_name("--force").is_err()); + } + + #[test] + fn rejects_empty_and_control_characters() { + assert!(validate_remote_name("").is_err()); + assert!(validate_remote_name("origin\nfetch").is_err()); + assert!(validate_branch_name("main\0").is_err()); + assert!(validate_branch_name("main\ttab").is_err()); + } + + #[test] + fn sh_quote_wraps_and_neutralizes_metacharacters() { + assert_eq!( + sh_quote("/home/u/.ssh/id_ed25519"), + "'/home/u/.ssh/id_ed25519'" + ); + assert_eq!(sh_quote("/tmp/with space/x"), "'/tmp/with space/x'"); + // The reproduced reword injection: backticks and $() must stay literal. + assert_eq!(sh_quote("pwn `whoami`"), "'pwn `whoami`'"); + assert_eq!(sh_quote("$(id)"), "'$(id)'"); + assert_eq!(sh_quote("a;b&c|d"), "'a;b&c|d'"); + } + + #[test] + fn sh_quote_escapes_embedded_single_quotes() { + assert_eq!(sh_quote("it's"), r"'it'\''s'"); + assert_eq!(sh_quote("'"), r"''\'''"); + } + + #[test] + fn error_message_names_the_offending_value() { + let err = validate_remote_name("--upload-pack=id") + .unwrap_err() + .to_string(); + assert!(err.contains("--upload-pack=id"), "unhelpful error: {err}"); + assert!(err.contains("Remote name"), "unhelpful error: {err}"); + } +} diff --git a/third_party/rgitui/crates/rgitui_git/src/project/auth.rs b/third_party/rgitui/crates/rgitui_git/src/project/auth.rs new file mode 100644 index 000000000..026d963aa --- /dev/null +++ b/third_party/rgitui/crates/rgitui_git/src/project/auth.rs @@ -0,0 +1,583 @@ +use anyhow::{Context as _, Result}; +use rgitui_settings::{current_git_auth_runtime, GitAuthRuntime, GitProviderRuntime}; + +use super::argsafe::sh_quote; +use std::path::Path; +use std::process::Command; + +/// Run a git network command via system git. +/// +/// If the command fails with an SSH auth error and HTTPS credentials are +/// available (provider token, system credential helper like `gh`), the +/// command is automatically retried with SSH-to-HTTPS URL rewriting. +pub(crate) fn run_git_network_command(repo_path: &Path, args: &[&str]) -> Result> { + let auth = current_git_auth_runtime(); + let remote_url = resolve_remote_url(repo_path, args); + + // --- First attempt: run as-is (with SSH key / HTTPS token if configured) --- + let result = run_git_command(repo_path, args, &auth, &remote_url, false); + + // --- If SSH auth failed, retry with HTTPS rewriting --- + if let Err(ref e) = result { + let err_msg = e.to_string(); + if is_ssh_auth_error(&err_msg) { + if let Some(ref url) = remote_url { + if is_ssh_url(url) && can_use_https(repo_path, &auth, url) { + log::info!("SSH auth failed, retrying with HTTPS rewriting for {}", url); + return run_git_command(repo_path, args, &auth, &remote_url, true); + } + } + } + } + + result +} + +/// The stock pack program to pin for a subcommand, if it accepts one. +/// +/// A repository's own config can name the program git runs on the far side of a +/// transport (`remote..uploadpack`, `remote..receivepack`, and the +/// `pack.*` equivalents). For a local-path or SSH remote that program runs on +/// this machine, so cloning an untrusted repository and pressing Fetch is enough +/// to execute whatever its `.git/config` names. The command-line flags outrank +/// repository config, so pin them to the stock programs everywhere they exist. +fn pack_program_pin(subcommand: &str) -> Option<&'static str> { + match subcommand { + "fetch" | "pull" => Some("--upload-pack=git-upload-pack"), + "push" => Some("--receive-pack=git-receive-pack"), + _ => None, + } +} + +/// Build and execute a single git command. +/// When `force_https` is true, SSH URLs are rewritten to HTTPS. +fn run_git_command( + repo_path: &Path, + args: &[&str], + auth: &GitAuthRuntime, + remote_url: &Option, + force_https: bool, +) -> Result> { + // Bound network inactivity for both HTTP and SSH transports. Git still owns + // the child process, but these limits prevent a dead peer from hanging an + // operation indefinitely when no explicit UI cancellation token exists. + let mut config_args: Vec = + vec!["http.lowSpeedLimit=1".into(), "http.lowSpeedTime=30".into()]; + let mut cmd = super::git_command(); + cmd.current_dir(repo_path).env("GIT_TERMINAL_PROMPT", "0"); + + if let Some(ref url) = remote_url { + if force_https && is_ssh_url(url) { + // Rewrite SSH to HTTPS. + if let Some((ssh_prefix, https_prefix)) = insteadof_pair(url) { + config_args.push(format!("url.{https_prefix}.insteadOf={ssh_prefix}")); + } + // Inject HTTPS credentials if we have our own. + if let Some(https_url) = ssh_to_https(url) { + inject_https_credentials(&mut cmd, auth, &https_url); + } + } else if !force_https { + // Normal mode: use SSH key or HTTPS credentials as appropriate. + if is_ssh_url(url) { + cmd.env("GIT_SSH_COMMAND", ssh_command(auth.ssh_key_path.as_deref())); + } else { + // HTTPS remote - inject credentials. + inject_https_credentials(&mut cmd, auth, url); + } + } + } + + // -c flags MUST come before the subcommand. + for cfg in &config_args { + cmd.arg("-c").arg(cfg); + } + // The pack-program pin has to land after the subcommand, so split rather + // than appending `args` wholesale. + if let Some((subcommand, rest)) = args.split_first() { + cmd.arg(subcommand); + if let Some(pin) = pack_program_pin(subcommand) { + cmd.arg(pin); + } + cmd.args(rest); + } + + let output = cmd + .output() + .with_context(|| format!("Failed to run system git command: git {}", args.join(" ")))?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let detail = if stderr.is_empty() { + stdout + } else if stdout.is_empty() { + stderr + } else { + format!("{stderr}\n{stdout}") + }; + + if output.status.success() { + let detail = detail.trim().to_string(); + Ok((!detail.is_empty()).then_some(detail)) + } else { + anyhow::bail!( + "{}", + if detail.is_empty() { + format!( + "git {} failed with exit status {}", + args.join(" "), + output.status + ) + } else { + detail + } + ); + } +} + +fn ssh_command(key_path: Option<&Path>) -> String { + let mut command = + String::from("ssh -o ConnectTimeout=15 -o ServerAliveInterval=15 -o ServerAliveCountMax=2"); + if let Some(key_path) = key_path.filter(|path| path.exists()) { + command.push_str(&format!( + " -i {} -o IdentitiesOnly=yes", + sh_quote(key_path.to_string_lossy().as_ref()) + )); + } + command +} + +/// Check if an error message indicates SSH authentication failure. +fn is_ssh_auth_error(msg: &str) -> bool { + msg.contains("Permission denied (publickey)") + || msg.contains("Host key verification failed") + || msg.contains("Could not read from remote repository") +} + +/// Check if we have HTTPS credentials available for this SSH URL. +fn can_use_https(repo_path: &Path, auth: &GitAuthRuntime, ssh_url: &str) -> bool { + let Some(https_url) = ssh_to_https(ssh_url) else { + return false; + }; + find_https_credentials(auth, &https_url).is_some() + || has_credential_helper_for_host(repo_path, &https_url) +} + +pub(crate) fn inject_https_credentials(cmd: &mut Command, auth: &GitAuthRuntime, remote_url: &str) { + let (username, token) = match find_https_credentials(auth, remote_url) { + Some(creds) => creds, + None => return, + }; + + let askpass_path = match ensure_askpass_script() { + Ok(p) => p, + Err(e) => { + log::warn!("Could not create HTTPS askpass helper: {e}"); + return; + } + }; + + cmd.env("GIT_ASKPASS", &askpass_path); + cmd.env("RGITUI_GIT_USER", username); + cmd.env("RGITUI_GIT_TOKEN", token); +} + +/// Extract the bare host from an HTTP(S) URL for credential matching. +/// +/// Strips the scheme, any `user@` userinfo prefix, and any `:port` suffix so +/// that URLs like `https://user@host:443/owner/repo.git` resolve to `host`. +/// Userinfo is removed before the port so a `:` inside `user:pass@` is not +/// mistaken for the port separator. IPv6 literals are not handled. +fn extract_host(url: &str) -> &str { + let authority = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://")) + .and_then(|rest| rest.split('/').next()) + .unwrap_or(""); + + let host = authority + .rsplit_once('@') + .map(|(_, host)| host) + .unwrap_or(authority); + + host.split(':').next().unwrap_or(host) +} + +fn find_https_credentials(auth: &GitAuthRuntime, remote_url: &str) -> Option<(String, String)> { + let host = extract_host(remote_url); + + let matching_provider: Option<&GitProviderRuntime> = auth + .providers + .iter() + .find(|p| p.use_for_https && p.token.is_some() && host_matches(&p.host, host)); + + if let Some(provider) = matching_provider { + if let Some(ref token) = provider.token { + let username = if provider.username.is_empty() { + "x-access-token".to_string() + } else { + provider.username.clone() + }; + return Some((username, token.clone())); + } + } + + if let Some(ref token) = auth.default_https_token { + return Some(("x-access-token".to_string(), token.clone())); + } + + None +} + +fn host_matches(provider_host: &str, remote_host: &str) -> bool { + let ph = provider_host.trim().to_lowercase(); + let rh = remote_host.trim().to_lowercase(); + ph == rh || rh.ends_with(&format!(".{ph}")) || ph.ends_with(&format!(".{rh}")) +} + +fn resolve_remote_url(repo_path: &Path, args: &[&str]) -> Option { + let remote_name = extract_remote_name(args)?; + let repo = git2::Repository::open(repo_path).ok()?; + let remote = repo.find_remote(&remote_name).ok()?; + remote.url().map(|s| s.to_string()) +} + +fn extract_remote_name(args: &[&str]) -> Option { + let mut iter = args.iter(); + let subcmd = iter.next()?; + match *subcmd { + "fetch" | "pull" | "push" => { + for arg in iter { + if !arg.starts_with('-') { + return Some(arg.to_string()); + } + } + None + } + _ => None, + } +} + +fn ensure_askpass_script() -> Result { + let dir = rgitui_settings::config_dir(); + std::fs::create_dir_all(&dir)?; + + #[cfg(windows)] + let script_path = dir.join("askpass.cmd"); + #[cfg(not(windows))] + let script_path = dir.join("askpass.sh"); + + let needs_write = match std::fs::metadata(&script_path) { + Ok(m) => m.len() == 0, + Err(_) => true, + }; + + if needs_write { + #[cfg(windows)] + { + // Batch helper invoked by git as `askpass.cmd ""`. Delayed + // expansion (`!VAR!`) avoids re-parsing token characters such as + // `& | < > ^`, and CRLF line endings keep cmd.exe happy. + let script = "@echo off\r\n\ + setlocal enabledelayedexpansion\r\n\ + echo %~1 | findstr /B /I \"Username\" >nul\r\n\ + if not errorlevel 1 (\r\n\ + \x20 echo !RGITUI_GIT_USER!\r\n\ + ) else (\r\n\ + \x20 echo !RGITUI_GIT_TOKEN!\r\n\ + )\r\n"; + std::fs::write(&script_path, script)?; + } + #[cfg(not(windows))] + { + let script = "#!/usr/bin/env bash\n\ + case \"$1\" in\n\ + \x20 Username*|username*) echo \"$RGITUI_GIT_USER\" ;;\n\ + \x20 *) echo \"$RGITUI_GIT_TOKEN\" ;;\n\ + esac\n"; + std::fs::write(&script_path, script)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))?; + } + } + } + + Ok(script_path) +} + +fn is_ssh_url(url: &str) -> bool { + url.starts_with("git@") || url.starts_with("ssh://") +} + +fn ssh_to_https(url: &str) -> Option { + if let Some(rest) = url.strip_prefix("git@") { + let (host, path) = rest.split_once(':')?; + Some(format!("https://{host}/{path}")) + } else if let Some(rest) = url.strip_prefix("ssh://") { + let rest = rest.strip_prefix("git@").unwrap_or(rest); + Some(format!("https://{rest}")) + } else { + None + } +} + +fn insteadof_pair(ssh_url: &str) -> Option<(String, String)> { + if let Some(rest) = ssh_url.strip_prefix("git@") { + let host = rest.split_once(':')?.0; + Some((format!("git@{host}:"), format!("https://{host}/"))) + } else if let Some(rest) = ssh_url.strip_prefix("ssh://") { + let rest = rest.strip_prefix("git@").unwrap_or(rest); + let host = rest.split_once('/')?.0; + Some((format!("ssh://git@{host}/"), format!("https://{host}/"))) + } else { + None + } +} + +fn has_credential_helper_for_host(repo_path: &Path, https_url: &str) -> bool { + let host = extract_host(https_url); + if host.is_empty() { + return false; + } + + let output = super::git_command() + .args(["config", "--get-regexp", "credential"]) + .current_dir(repo_path) + .output(); + + match output { + Ok(out) => { + let config = String::from_utf8_lossy(&out.stdout); + config.contains(&format!("credential.https://{host}")) + || config.contains("credential.helper") + } + Err(_) => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ─── is_ssh_auth_error ─────────────────────────────────────────────────── + + #[test] + fn is_ssh_auth_error_detects_publickey() { + assert!(is_ssh_auth_error("Permission denied (publickey)")); + assert!(is_ssh_auth_error( + "Permission denied (publickey) for github.com" + )); + } + + #[test] + fn is_ssh_auth_error_detects_host_key() { + assert!(is_ssh_auth_error("Host key verification failed")); + assert!(is_ssh_auth_error( + "Host key verification failed for github.com" + )); + } + + #[test] + fn is_ssh_auth_error_detects_remote_read() { + assert!(is_ssh_auth_error("Could not read from remote repository")); + } + + #[test] + fn is_ssh_auth_error_false_for_other_errors() { + assert!(!is_ssh_auth_error("object not found")); + assert!(!is_ssh_auth_error("Connection timed out")); + assert!(!is_ssh_auth_error("Permission denied (password)")); + } + + // ─── host_matches ────────────────────────────────────────────────────── + + #[test] + fn host_matches_exact() { + assert!(host_matches("github.com", "github.com")); + assert!(host_matches("gitlab.com", "gitlab.com")); + } + + #[test] + fn host_matches_case_insensitive() { + assert!(host_matches("GitHub.COM", "github.com")); + assert!(host_matches("github.com", "GITHUB.COM")); + } + + #[test] + fn host_matches_subdomain() { + // Provider is the bare domain, remote has a subdomain + assert!(host_matches("github.com", "github.com")); + assert!(host_matches("github.com", "foo.github.com")); + // Remote is the bare domain, provider has a subdomain + assert!(host_matches("foo.github.com", "github.com")); + } + + #[test] + fn host_matches_no_match() { + assert!(!host_matches("github.com", "gitlab.com")); + assert!(!host_matches("github.com", "notgithub.com")); + assert!(!host_matches("githu.com", "github.com")); + } + + #[test] + fn host_matches_trims_whitespace() { + assert!(host_matches(" github.com ", "github.com")); + assert!(host_matches("github.com", " github.com ")); + } + + // ─── extract_remote_name ───────────────────────────────────────────────── + + #[test] + fn extract_remote_name_fetch() { + assert_eq!( + extract_remote_name(&["fetch", "origin"]), + Some("origin".to_string()) + ); + } + + #[test] + fn extract_remote_name_pull() { + assert_eq!( + extract_remote_name(&["pull", "upstream", "main"]), + Some("upstream".to_string()) + ); + } + + #[test] + fn extract_remote_name_push() { + assert_eq!( + extract_remote_name(&["push", "github"]), + Some("github".to_string()) + ); + } + + #[test] + fn extract_remote_name_skips_flags() { + assert_eq!( + extract_remote_name(&["push", "--force", "origin"]), + Some("origin".to_string()) + ); + assert_eq!( + extract_remote_name(&["fetch", "-v", "--thin", "origin"]), + Some("origin".to_string()) + ); + } + + #[test] + fn extract_remote_name_empty_args() { + assert_eq!(extract_remote_name(&[]), None); + } + + #[test] + fn extract_remote_name_unknown_subcommand() { + assert_eq!(extract_remote_name(&["clone"]), None); + assert_eq!(extract_remote_name(&["remote", "add"]), None); + } + + #[test] + fn extract_remote_name_only_flags() { + assert_eq!(extract_remote_name(&["fetch", "--all"]), None); + } + + // ─── is_ssh_url ──────────────────────────────────────────────────────── + + #[test] + fn is_ssh_url_git_at() { + assert!(is_ssh_url("git@github.com:user/repo.git")); + assert!(is_ssh_url("git@gitlab.com:foo/bar.git")); + } + + #[test] + fn is_ssh_url_ssh_protocol() { + assert!(is_ssh_url("ssh://git@github.com/user/repo.git")); + assert!(is_ssh_url("ssh://user@github.com/user/repo.git")); + } + + #[test] + fn is_ssh_url_not_https() { + assert!(!is_ssh_url("https://github.com/user/repo.git")); + assert!(!is_ssh_url("http://github.com/user/repo.git")); + } + + #[test] + fn is_ssh_url_not_file() { + assert!(!is_ssh_url("file:///path/to/repo")); + } + + // ─── ssh_to_https ────────────────────────────────────────────────────── + + #[test] + fn ssh_to_https_git_at() { + assert_eq!( + ssh_to_https("git@github.com:user/repo.git"), + Some("https://github.com/user/repo.git".to_string()) + ); + assert_eq!( + ssh_to_https("git@gitlab.com:foo/bar.git"), + Some("https://gitlab.com/foo/bar.git".to_string()) + ); + } + + #[test] + fn ssh_to_https_ssh_protocol() { + assert_eq!( + ssh_to_https("ssh://git@github.com/user/repo.git"), + Some("https://github.com/user/repo.git".to_string()) + ); + // ssh://user@host/path — user@ prefix is preserved as-is in the HTTPS URL + assert_eq!( + ssh_to_https("ssh://user@github.com/user/repo.git"), + Some("https://user@github.com/user/repo.git".to_string()) + ); + } + + #[test] + fn ssh_to_https_no_op_for_https() { + assert_eq!(ssh_to_https("https://github.com/user/repo.git"), None); + assert_eq!(ssh_to_https("http://github.com/user/repo.git"), None); + } + + #[test] + fn ssh_to_https_no_op_for_scp_style_without_host() { + // "user@host:path" — the split_once on ':' won't find a valid host/path split + assert_eq!(ssh_to_https("git@github.comuser/repo.git"), None); + } + + // ─── insteadof_pair ──────────────────────────────────────────────────── + + #[test] + fn insteadof_pair_git_at() { + assert_eq!( + insteadof_pair("git@github.com:user/repo.git"), + Some(( + "git@github.com:".to_string(), + "https://github.com/".to_string() + )) + ); + } + + #[test] + fn insteadof_pair_ssh_protocol() { + assert_eq!( + insteadof_pair("ssh://git@github.com/user/repo.git"), + Some(( + "ssh://git@github.com/".to_string(), + "https://github.com/".to_string() + )) + ); + } + + #[test] + fn insteadof_pair_no_op_for_https() { + assert_eq!(insteadof_pair("https://github.com/user/repo.git"), None); + } + + #[test] + fn ssh_transport_has_bounded_connect_and_idle_timeouts() { + let command = ssh_command(None); + assert!(command.contains("ConnectTimeout=15")); + assert!(command.contains("ServerAliveInterval=15")); + assert!(command.contains("ServerAliveCountMax=2")); + } +} diff --git a/third_party/rgitui/crates/rgitui_git/src/project/bisect.rs b/third_party/rgitui/crates/rgitui_git/src/project/bisect.rs new file mode 100644 index 000000000..76b69a41b --- /dev/null +++ b/third_party/rgitui/crates/rgitui_git/src/project/bisect.rs @@ -0,0 +1,163 @@ +use super::GitProject; +use anyhow::{Context as AnyhowContext, Result}; +use gpui::{AsyncApp, Context, Task, WeakEntity}; +use std::path::Path; + +/// A single entry in the bisect log. +#[derive(Debug, Clone)] +pub struct BisectLogEntry { + /// The decision type: good, bad, skip, or start. + pub decision: BisectDecision, + /// The commit SHA (short form). + pub sha: String, + /// The commit subject message (if available). + pub subject: Option, +} + +/// The type of bisect decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BisectDecision { + Start, + Good, + Bad, + Skip, +} + +/// Parse a single line from `git bisect log`. +/// Lines look like: +/// # bisect start +/// # good: abc1234 subject line +/// # bad: def5678 another subject +/// # skip: 9123456 skipped commit +fn parse_bisect_line(line: &str) -> Option { + let line = line.trim(); + if !line.starts_with('#') { + return None; + } + let rest = line.trim_start_matches('#').trim(); + + if rest == "bisect start" { + return Some(BisectLogEntry { + decision: BisectDecision::Start, + sha: String::new(), + subject: None, + }); + } + + let (decision_str, after_sha) = rest.split_once(':')?; + let after_sha = after_sha.trim(); + + let decision = match decision_str { + "good" => BisectDecision::Good, + "bad" => BisectDecision::Bad, + "skip" => BisectDecision::Skip, + _ => return None, + }; + + // Format: " " or just "" + let parts: Vec<&str> = after_sha.splitn(2, ' ').collect(); + let sha = parts.first().unwrap_or(&"").to_string(); + let subject = parts.get(1).map(|s| s.to_string()); + + Some(BisectLogEntry { + decision, + sha, + subject, + }) +} + +/// Compute bisect log entries from the repository. +pub fn compute_bisect_log(repo_path: &Path) -> Result> { + let output = super::git_command() + .current_dir(repo_path) + .args(["bisect", "log"]) + .output() + .context("Failed to execute git bisect log")?; + + if !output.status.success() { + // If bisect isn't in progress, git bisect log may fail + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git bisect log failed: {}", stderr.trim()); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let entries: Vec = stdout.lines().filter_map(parse_bisect_line).collect(); + + Ok(entries) +} + +/// Check if bisect is currently in progress. +pub fn is_bisect_in_progress(repo_path: &Path) -> bool { + let output = super::git_command() + .current_dir(repo_path) + .args(["bisect", "status"]) + .output(); + + match output { + Ok(o) if o.status.success() => { + let stdout = String::from_utf8_lossy(&o.stdout); + // status output contains "bisecting" when active + stdout.contains("bisecting") + } + _ => false, + } +} + +impl GitProject { + /// Get bisect log entries for the repository. + pub fn bisect_log(&self) -> Result> { + compute_bisect_log(self.repo_path()) + } + + /// Get bisect log entries asynchronously. + pub fn bisect_log_async(&self, cx: &mut Context) -> Task>> { + let repo_path = self.repo_path().to_path_buf(); + cx.spawn(async move |_this: WeakEntity, cx: &mut AsyncApp| { + cx.background_executor() + .spawn(async move { compute_bisect_log(&repo_path) }) + .await + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_bisect_start() { + let entry = parse_bisect_line("# bisect start").unwrap(); + assert_eq!(entry.decision, BisectDecision::Start); + assert!(entry.sha.is_empty()); + } + + #[test] + fn test_parse_good_entry() { + let entry = parse_bisect_line("# good: abc1234 fix the bug").unwrap(); + assert_eq!(entry.decision, BisectDecision::Good); + assert_eq!(entry.sha, "abc1234"); + assert_eq!(entry.subject, Some("fix the bug".to_string())); + } + + #[test] + fn test_parse_bad_entry() { + let entry = parse_bisect_line("# bad: def5678 break everything").unwrap(); + assert_eq!(entry.decision, BisectDecision::Bad); + assert_eq!(entry.sha, "def5678"); + assert_eq!(entry.subject, Some("break everything".to_string())); + } + + #[test] + fn test_parse_skip_entry() { + let entry = parse_bisect_line("# skip: 9123456 skip this commit").unwrap(); + assert_eq!(entry.decision, BisectDecision::Skip); + assert_eq!(entry.sha, "9123456"); + } + + #[test] + fn test_ignore_non_decision_lines() { + assert!(parse_bisect_line("not a comment").is_none()); + assert!(parse_bisect_line("# some other comment").is_none()); + assert!(parse_bisect_line("").is_none()); + } +} diff --git a/third_party/rgitui/crates/rgitui_git/src/project/blame.rs b/third_party/rgitui/crates/rgitui_git/src/project/blame.rs new file mode 100644 index 000000000..ef8395e5e --- /dev/null +++ b/third_party/rgitui/crates/rgitui_git/src/project/blame.rs @@ -0,0 +1,323 @@ +use anyhow::Result; +use chrono::{DateTime, TimeZone, Utc}; +use std::path::Path; + +/// A single blame annotation for a line or range of lines. +#[derive(Debug, Clone)] +pub struct BlameEntry { + pub oid: git2::Oid, + pub short_id: String, + pub author: String, + pub email: String, + pub time: DateTime, + pub start_line: usize, + pub line_count: usize, +} + +/// A single line with its blame annotation. +#[derive(Debug, Clone)] +pub struct BlameLine { + pub line_no: usize, + pub content: String, + pub entry: BlameEntry, +} + +/// Compute blame for a file at a given commit (or HEAD if None). +/// +/// Uses `git blame --porcelain` which is significantly faster than +/// libgit2's blame implementation. +pub fn compute_blame( + repo_path: &Path, + file_path: &Path, + commit_oid: Option, +) -> Result> { + let blame_timer = std::time::Instant::now(); + log::debug!("compute_blame: path={}", file_path.display()); + let file_str = file_path.to_string_lossy(); + let mut args = vec!["blame", "--porcelain"]; + let oid_str = commit_oid.map(|o| o.to_string()); + if let Some(ref s) = oid_str { + args.push(s); + } + args.push("--"); + args.push(&file_str); + + let output = super::git_command() + .args(&args) + .current_dir(repo_path) + .output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git blame failed: {}", stderr.trim()); + } + + let result = parse_porcelain_blame(&String::from_utf8_lossy(&output.stdout)); + if let Ok(ref lines) = result { + log::debug!( + "compute_blame complete in {:?}: {} lines", + blame_timer.elapsed(), + lines.len() + ); + } + result +} + +/// Parse `git blame --porcelain` output into BlameLine entries. +fn parse_porcelain_blame(output: &str) -> Result> { + use std::collections::HashMap; + + // Cache commit metadata keyed by OID string. + struct CommitMeta { + author: String, + email: String, + time: DateTime, + } + let mut meta_cache: HashMap = HashMap::new(); + + let mut result = Vec::new(); + let mut lines = output.lines().peekable(); + + while let Some(header) = lines.next() { + // Header line: " []" + let parts: Vec<&str> = header.split_whitespace().collect(); + if parts.len() < 3 { + continue; + } + let oid_str = parts[0]; + let final_line: usize = parts[2].parse().unwrap_or(1); + + // Read metadata lines until the content line (starts with \t). + let mut author = String::new(); + let mut email = String::new(); + let mut timestamp: i64 = 0; + let mut content_line = String::new(); + + for line in lines.by_ref() { + if let Some(stripped) = line.strip_prefix('\t') { + content_line = stripped.to_string(); + break; + } + if let Some(val) = line.strip_prefix("author ") { + author = val.to_string(); + } else if let Some(val) = line.strip_prefix("author-mail <") { + email = val.trim_end_matches('>').to_string(); + } else if let Some(val) = line.strip_prefix("author-time ") { + timestamp = val.parse().unwrap_or(0); + } + } + + // Use cached metadata for repeated OIDs (porcelain only emits + // full metadata the first time an OID appears). + if !author.is_empty() { + meta_cache.insert( + oid_str.to_string(), + CommitMeta { + author: author.clone(), + email: email.clone(), + time: Utc.timestamp_opt(timestamp, 0).single().unwrap_or_default(), + }, + ); + } + + let meta = meta_cache.get(oid_str); + let (a, e, t) = match meta { + Some(m) => (m.author.clone(), m.email.clone(), m.time), + None => (String::new(), String::new(), Utc::now()), + }; + + let oid = git2::Oid::from_str(oid_str).unwrap_or(git2::Oid::zero()); + let short_id = oid_str[..7.min(oid_str.len())].to_string(); + + result.push(BlameLine { + line_no: final_line, + content: content_line, + entry: BlameEntry { + oid, + short_id, + author: a, + email: e, + time: t, + start_line: final_line, + line_count: 1, + }, + }); + } + + Ok(result) +} + +use gpui::{AsyncApp, Context, Task, WeakEntity}; + +use super::GitProject; + +impl GitProject { + /// Compute blame for a file, optionally at a specific commit. + pub fn blame_file( + &self, + file_path: &Path, + commit_oid: Option, + ) -> Result> { + compute_blame(self.repo_path(), file_path, commit_oid) + } + + /// Compute blame asynchronously on a background thread. + pub fn blame_file_async( + &self, + file_path: &Path, + commit_oid: Option, + cx: &mut Context, + ) -> Task>> { + let repo_path = self.repo_path().to_path_buf(); + let file_path = file_path.to_path_buf(); + cx.spawn(async move |_this: WeakEntity, cx: &mut AsyncApp| { + cx.background_executor() + .spawn(async move { compute_blame(&repo_path, &file_path, commit_oid) }) + .await + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rgitui_test_support::TempRepo; + use std::path::Path; + + /// A repo with two commits touching the same file, by different authors so + /// blame has something to attribute: + /// commit A (Alice): adds "line1\nline2\n" + /// commit B (Bob): appends "line3\n" + fn make_blame_repo() -> (TempRepo, git2::Oid, git2::Oid) { + let repo = TempRepo::init(); + let oid_a = repo.commit_file_as( + "Alice", + "alice@example.com", + "code.txt", + "line1\nline2\n", + "initial commit", + ); + let oid_b = repo.commit_file_as( + "Bob", + "bob@example.com", + "code.txt", + "line1\nline2\nline3\n", + "add line3", + ); + (repo, oid_a, oid_b) + } + + #[test] + fn blame_head_returns_all_lines() { + let (repo, _a, _b) = make_blame_repo(); + let lines = compute_blame(repo.path(), Path::new("code.txt"), None).unwrap(); + assert_eq!( + lines.len(), + 3, + "expected 3 blame lines, got {}", + lines.len() + ); + } + + #[test] + fn blame_line_numbers_are_one_indexed() { + let (repo, _a, _b) = make_blame_repo(); + let lines = compute_blame(repo.path(), Path::new("code.txt"), None).unwrap(); + for (i, bl) in lines.iter().enumerate() { + assert_eq!(bl.line_no, i + 1, "line_no should be {}", i + 1); + } + } + + #[test] + fn blame_line_contents_match_file() { + let (repo, _a, _b) = make_blame_repo(); + let lines = compute_blame(repo.path(), Path::new("code.txt"), None).unwrap(); + assert_eq!(lines[0].content, "line1"); + assert_eq!(lines[1].content, "line2"); + assert_eq!(lines[2].content, "line3"); + } + + #[test] + fn blame_first_two_lines_authored_by_alice() { + let (repo, _a, _b) = make_blame_repo(); + let lines = compute_blame(repo.path(), Path::new("code.txt"), None).unwrap(); + assert_eq!(lines[0].entry.author, "Alice"); + assert_eq!(lines[1].entry.author, "Alice"); + } + + #[test] + fn blame_last_line_authored_by_bob() { + let (repo, _a, _b) = make_blame_repo(); + let lines = compute_blame(repo.path(), Path::new("code.txt"), None).unwrap(); + assert_eq!(lines[2].entry.author, "Bob"); + assert_eq!(lines[2].entry.email, "bob@example.com"); + } + + #[test] + fn blame_short_ids_are_seven_chars() { + let (repo, _a, _b) = make_blame_repo(); + let lines = compute_blame(repo.path(), Path::new("code.txt"), None).unwrap(); + for bl in &lines { + assert_eq!( + bl.entry.short_id.len(), + 7, + "short_id should be 7 chars, got {:?}", + bl.entry.short_id + ); + } + } + + #[test] + fn blame_at_commit_a_shows_only_two_lines() { + let (repo, oid_a, _b) = make_blame_repo(); + let lines = compute_blame(repo.path(), Path::new("code.txt"), Some(oid_a)).unwrap(); + // At commit A the file only had 2 lines + assert_eq!(lines.len(), 2, "commit A had 2 lines, got {}", lines.len()); + } + + #[test] + fn blame_at_commit_a_all_lines_attributed_to_alice() { + let (repo, oid_a, _b) = make_blame_repo(); + let lines = compute_blame(repo.path(), Path::new("code.txt"), Some(oid_a)).unwrap(); + for bl in &lines { + assert_eq!( + bl.entry.author, "Alice", + "all lines at commit A should be Alice" + ); + } + } + + #[test] + fn blame_oid_matches_commit() { + let (repo, _a, oid_b) = make_blame_repo(); + let lines = compute_blame(repo.path(), Path::new("code.txt"), None).unwrap(); + // line3 was added in commit B + assert_eq!(lines[2].entry.oid, oid_b); + } + + #[test] + fn blame_time_is_nonzero() { + let (repo, _a, _b) = make_blame_repo(); + let lines = compute_blame(repo.path(), Path::new("code.txt"), None).unwrap(); + for bl in &lines { + assert!( + bl.entry.time.timestamp() > 0, + "time should be nonzero for line {}", + bl.line_no + ); + } + } + + #[test] + fn blame_bad_repo_path_returns_error() { + let result = compute_blame(Path::new("/nonexistent/path"), Path::new("code.txt"), None); + assert!(result.is_err(), "expected error for bad repo path"); + } + + #[test] + fn blame_missing_file_returns_error() { + let (repo, _a, _b) = make_blame_repo(); + let result = compute_blame(repo.path(), Path::new("does_not_exist.txt"), None); + assert!(result.is_err(), "expected error for nonexistent file"); + } +} diff --git a/third_party/rgitui/crates/rgitui_git/src/project/census.rs b/third_party/rgitui/crates/rgitui_git/src/project/census.rs new file mode 100644 index 000000000..d5d1d5444 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_git/src/project/census.rs @@ -0,0 +1,231 @@ +//! Walking [`GitProject`]'s retained snapshot into the heap census. +//! +//! The project is the app's largest single holder of repository-shaped memory +//! and, until this walk existed, none of it was reported: the census reached +//! the commit list only because `GraphView` happens to hold a clone of the same +//! `Arc`, and everything else the project caches — the refs, the working-tree +//! status, the per-worktree status cache — was invisible. +//! +//! Two of these nodes routinely read zero, and that is the point. `commits` and +//! `status` are `Arc`s the views also hold, so whichever path the walk reaches +//! first is charged and the other reports the population with no bytes beside +//! it. A node saying "1,000 commits, counted elsewhere" is the report proving +//! it did not double count; a node missing altogether is the report saying +//! nothing at all. + +use rgitui_perf::{Census, HeapSize}; + +use super::GitProject; + +impl GitProject { + /// Records the repository snapshot this project retains, returning the + /// bytes attributed so an enclosing scope can roll them up. + /// + /// The label set deliberately mirrors the fields rather than grouping them: + /// "the refs cost 4 MB" is not a decision anyone can act on, while "the + /// tags cost 4 MB" points straight at a load that could be paged. + pub fn census(&self, census: &mut Census) -> usize { + let mut total = 0; + + total += census.enter("commits", &self.recent_commits); + total += census.enter("status", &self.status); + total += census.enter("branches", &self.branches); + total += census.enter("tags", &self.tags); + total += census.enter("remotes", &self.remotes); + total += census.enter("stashes", &self.stashes); + total += census.enter("worktrees", &self.worktrees); + total += census.enter("status_cache", &StatusCache(self)); + total += census.enter("paths", &Paths(self)); + + total + } +} + +/// The per-worktree status cache shared with the file watcher. +/// +/// Measured through the mutex rather than around it because the payload is a +/// whole `WorkingTreeStatus` per worktree — every changed path in every +/// checkout — and a repository with several worktrees holds as many copies. +struct StatusCache<'a>(&'a GitProject); + +impl HeapSize for StatusCache<'_> { + fn heap_size(&self, census: &mut Census) -> usize { + match self.0.worktree_status_cache.lock() { + Ok(cache) => cache.heap_size(census), + // A poisoned lock means a background refresh panicked. Reporting + // zero would be a lie, but so would blocking a measurement on it; + // the count below is what says the node was skipped rather than + // empty. + Err(_) => 0, + } + } + + fn heap_count(&self) -> Option { + self.0 + .worktree_status_cache + .lock() + .ok() + .map(|cache| cache.len()) + } +} + +/// The small strings the project keeps for the life of the repository: its +/// path, the current and previous head, the default branch, the user's email +/// and any author filter. Individually trivial, grouped so the report can show +/// they are. +struct Paths<'a>(&'a GitProject); + +impl HeapSize for Paths<'_> { + fn heap_size(&self, census: &mut Census) -> usize { + self.0.repo_path.heap_size(census) + + self.0.head_branch.heap_size(census) + + self.0.previous_branch.heap_size(census) + + self.0.default_branch.heap_size(census) + + self.0.current_user_email.heap_size(census) + + self.0.commit_author_filter.heap_size(census) + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::Arc; + + use super::*; + use crate::types::{CommitInfo, Signature, TagInfo, WorkingTreeStatus}; + + fn signature() -> Signature { + Signature { + name: String::new(), + email: String::new(), + } + } + + /// Long enough that no small-string optimisation could hide it inline, + /// so a test asserting the bytes were charged is asserting something. + const LONG_NAME: &str = "release/2026-08-a-tag-name-far-too-long-to-live-inline"; + + fn project() -> GitProject { + GitProject::empty_at(PathBuf::from("/repos/rgitui")) + } + + fn node<'a>(nodes: &'a [rgitui_perf::CensusNode], path: &str) -> &'a rgitui_perf::CensusNode { + nodes + .iter() + .find(|node| node.path == path) + .unwrap_or_else(|| panic!("{path} is missing from the census")) + } + + #[test] + fn the_project_census_emits_one_node_per_measured_field() { + let mut census = Census::new(); + census.enter("project", &CensusOf(&project())); + + let mut paths: Vec = census + .into_nodes() + .into_iter() + .map(|node| node.path) + .collect(); + paths.sort(); + + assert_eq!( + paths, + [ + "project", + "project/branches", + "project/commits", + "project/paths", + "project/remotes", + "project/stashes", + "project/status", + "project/status_cache", + "project/tags", + "project/worktrees", + ] + ); + } + + #[test] + fn refs_the_project_holds_are_charged_for_the_strings_they_own() { + let mut project = project(); + project.tags = vec![TagInfo { + name: LONG_NAME.to_string(), + oid: git2::Oid::zero(), + message: None, + }]; + + let mut census = Census::new(); + census.enter("project", &CensusOf(&project)); + let nodes = census.into_nodes(); + + assert_eq!(node(&nodes, "project/tags").count, Some(1)); + assert!( + node(&nodes, "project/tags").bytes >= LONG_NAME.len(), + "the tag name is a heap allocation and has to be charged" + ); + } + + #[test] + fn the_status_cache_reports_the_worktrees_it_holds_status_for() { + let project = project(); + if let Ok(mut cache) = project.worktree_status_cache.lock() { + cache.insert( + PathBuf::from("/repos/rgitui/worktrees/feature"), + (7, WorkingTreeStatus::default()), + ); + } + + let mut census = Census::new(); + census.enter("project", &CensusOf(&project)); + let nodes = census.into_nodes(); + + assert_eq!(node(&nodes, "project/status_cache").count, Some(1)); + assert!(node(&nodes, "project/status_cache").bytes > 0); + } + + #[test] + fn a_snapshot_reached_twice_is_charged_to_the_first_path_only() { + let mut project = project(); + project.recent_commits = Arc::new(vec![CommitInfo { + oid: git2::Oid::zero(), + short_id: "abc1234".to_string(), + summary: LONG_NAME.to_string(), + message: LONG_NAME.to_string(), + author: signature(), + committer: signature(), + co_authors: Vec::new(), + time: chrono::Utc::now(), + parent_oids: Vec::new(), + refs: Vec::new(), + is_signed: false, + }]); + let shared = Arc::clone(&project.recent_commits); + + let mut census = Census::new(); + let first = census.enter("elsewhere", &shared); + census.enter("project", &CensusOf(&project)); + let nodes = census.into_nodes(); + + assert!(first > 0); + assert_eq!( + node(&nodes, "project/commits").bytes, + 0, + "the view reached the snapshot first and was charged for it" + ); + assert_eq!( + node(&nodes, "project/commits").count, + Some(1), + "the population is still named, so the report can say where it went" + ); + } + + /// Adapter that lets a bare `GitProject` be entered as a census scope; the + /// app reaches [`GitProject::census`] through the workspace walk instead. + struct CensusOf<'a>(&'a GitProject); + + impl HeapSize for CensusOf<'_> { + fn heap_size(&self, census: &mut Census) -> usize { + self.0.census(census) + } + } +} diff --git a/third_party/rgitui/crates/rgitui_git/src/project/diff.rs b/third_party/rgitui/crates/rgitui_git/src/project/diff.rs new file mode 100644 index 000000000..ae36c316e --- /dev/null +++ b/third_party/rgitui/crates/rgitui_git/src/project/diff.rs @@ -0,0 +1,2294 @@ +use anyhow::Result; +use git2::{DiffOptions, IndexEntry, MergeFileOptions, Repository}; +use std::path::{Path, PathBuf}; + +use crate::types::*; + +use super::refresh::gather_refresh_data_lightweight_cached; +use super::RefreshData; + +/// Compute line-level diff stats (additions/deletions) for a single file. +/// For staged files, diffs HEAD vs index. For unstaged files, diffs index vs workdir. +pub(crate) fn batch_diff_stats( + repo: &Repository, + staged: bool, +) -> std::collections::HashMap { + let batch_timer = std::time::Instant::now(); + log::debug!("batch_diff_stats: staged={}", staged); + let mut opts = DiffOptions::new(); + opts.include_untracked(true); + opts.show_untracked_content(true); + opts.recurse_untracked_dirs(true); + let diff_result = if staged { + let head_tree = repo.head().ok().and_then(|r| r.peel_to_tree().ok()); + repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts)) + } else { + repo.diff_index_to_workdir(None, Some(&mut opts)) + }; + let mut stats_map = std::collections::HashMap::new(); + if let Ok(diff) = diff_result { + let num_deltas = diff.deltas().len(); + for i in 0..num_deltas { + if let Ok(Some(patch)) = git2::Patch::from_diff(&diff, i) { + let (_, adds, dels) = patch.line_stats().unwrap_or((0, 0, 0)); + if let Some(path) = patch.delta().new_file().path() { + stats_map.insert(path.to_path_buf(), (adds, dels)); + } + } + } + } + log::debug!( + "batch_diff_stats complete in {:?}: {} file stats, staged={}", + batch_timer.elapsed(), + stats_map.len(), + staged + ); + stats_map +} + +pub(crate) fn generate_hunk_patch_for_repo( + repo: &Repository, + file_path: &Path, + hunk_index: usize, + staged: bool, +) -> Result { + let mut diff_opts = DiffOptions::new(); + diff_opts.pathspec(file_path); + // Without this libgit2 fnmatches the pathspec, so a file literally named + // `data[1].json` would not match itself and could match a different file. + diff_opts.disable_pathspec_match(true); + diff_opts.include_untracked(true); + diff_opts.show_untracked_content(true); + diff_opts.recurse_untracked_dirs(true); + + let diff = if staged { + let head_tree = repo.head()?.peel_to_tree().ok(); + repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut diff_opts))? + } else { + repo.diff_index_to_workdir(None, Some(&mut diff_opts))? + }; + + let mut patch_text = String::new(); + let mut current_hunk_idx: i32 = -1; + let mut file_header_written = false; + + diff.print(git2::DiffFormat::Patch, |delta, hunk, line| { + let Some(hunk) = hunk else { + if !file_header_written { + let content = String::from_utf8_lossy(line.content()); + match line.origin() { + 'F' => patch_text.push_str(&content), + _ => { + let prefix = match line.origin() { + '+' | '-' | ' ' | '>' | '<' => String::from(line.origin()), + _ => String::new(), + }; + patch_text.push_str(&prefix); + patch_text.push_str(&content); + } + } + } + return true; + }; + + // Each hunk emits exactly one hunk-header line (origin 'H') before its + // content lines, so advancing the counter on 'H' tracks hunk boundaries + // structurally — no substring scan of the accumulating patch text. + if line.origin() == 'H' { + current_hunk_idx += 1; + } + + if current_hunk_idx as usize == hunk_index { + if !file_header_written { + let old_path = delta.old_file().path().unwrap_or(Path::new("")); + let new_path = delta.new_file().path().unwrap_or(Path::new("")); + patch_text.clear(); + patch_text.push_str(&format!( + "diff --git a/{} b/{}\n", + old_path.display(), + new_path.display() + )); + patch_text.push_str(&format!("--- a/{}\n", old_path.display())); + patch_text.push_str(&format!("+++ b/{}\n", new_path.display())); + file_header_written = true; + } + + let content = String::from_utf8_lossy(line.content()); + match line.origin() { + 'H' => { + let (old_start, old_lines, new_start, new_lines) = if staged { + ( + hunk.new_start(), + hunk.new_lines(), + hunk.old_start(), + hunk.old_lines(), + ) + } else { + ( + hunk.old_start(), + hunk.old_lines(), + hunk.new_start(), + hunk.new_lines(), + ) + }; + patch_text.push_str(&format!( + "@@ -{},{} +{},{} @@\n", + old_start, old_lines, new_start, new_lines + )); + } + '+' | '-' | ' ' => { + if let Some(sign) = emitted_sign(line.origin(), staged) { + patch_text.push(sign); + } + patch_text.push_str(&content); + } + // EOFNL markers ('=' context, '>' added, '<' deleted) carry the + // "\ No newline at end of file" text in their content; emit it + // verbatim with no sign prefix, matching git's own patch output. + '=' | '>' | '<' => patch_text.push_str(&content), + _ => {} + } + } + + true + })?; + + if patch_text.is_empty() { + anyhow::bail!("Could not generate patch for hunk {}", hunk_index); + } + + if !patch_text.ends_with('\n') { + patch_text.push('\n'); + } + + Ok(patch_text) +} + +/// Map a diff line's origin to the sign it should carry in the generated patch. +/// +/// Staging (`staged == false`, an index→workdir diff applied to the index) preserves +/// signs: a `+` workdir addition must stay `+` to add the line to the index. +/// Unstaging (`staged == true`, a HEAD→index diff applied to the index) negates them: +/// a `+` index addition becomes `-` so applying the patch removes it from the index. +/// Context lines keep their space; non-content origins return `None`. +fn emitted_sign(origin: char, staged: bool) -> Option { + match origin { + ' ' => Some(' '), + '+' => Some(if staged { '-' } else { '+' }), + '-' => Some(if staged { '+' } else { '-' }), + _ => None, + } +} + +/// Generate a patch containing only the specified line ranges from a file's diff. +/// +/// `line_pairs` is `&[(Option, Option)]` — `(old_lineno, new_lineno)` +/// from the diff viewer, matching the line numbering of the underlying git2 diff. +/// The viewer emits an addition as `(None, Some(n))` and a deletion as `(Some(n), None)`. +/// +/// `staged`: +/// - `false` → diff is index→workdir; the patch stages the selected lines into the +/// index, so signs are preserved (a `+` workdir addition stays `+`). +/// - `true` → diff is HEAD→index; the patch unstages the selected lines from the +/// index, so signs are negated (a `+` index addition becomes `-` to remove it). +/// +/// Additions are matched by their `new_lineno` against the new-side targets, and +/// deletions by their `old_lineno` against the old-side targets. The two sides are +/// kept separate so an addition and a deletion that happen to share a line number +/// are not confused for one another. +pub(crate) fn generate_line_patch_for_repo( + repo: &Repository, + file_path: &Path, + line_pairs: &[(Option, Option)], + staged: bool, +) -> Result { + let mut diff_opts = DiffOptions::new(); + diff_opts.pathspec(file_path); + // Without this libgit2 fnmatches the pathspec, so a file literally named + // `data[1].json` would not match itself and could match a different file. + diff_opts.disable_pathspec_match(true); + diff_opts.include_untracked(true); + diff_opts.show_untracked_content(true); + diff_opts.recurse_untracked_dirs(true); + + let diff = if staged { + let head_tree = repo.head()?.peel_to_tree().ok(); + repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut diff_opts))? + } else { + repo.diff_index_to_workdir(None, Some(&mut diff_opts))? + }; + + // Separate target sets per side. Additions carry their position in `new_lineno`, + // deletions in `old_lineno`, in both the HEAD→index and index→workdir diffs. + let new_targets: std::collections::HashSet = + line_pairs.iter().filter_map(|(_, new)| *new).collect(); + let old_targets: std::collections::HashSet = + line_pairs.iter().filter_map(|(old, _)| *old).collect(); + + let mut patch_text = String::new(); + let num_deltas = diff.deltas().len(); + + for i in 0..num_deltas { + let patch = match git2::Patch::from_diff(&diff, i) { + Ok(Some(p)) => p, + Ok(None) => continue, + Err(_) => continue, + }; + + let old_path = patch + .delta() + .old_file() + .path() + .map(PathBuf::from) + .unwrap_or_default(); + let new_path = patch + .delta() + .new_file() + .path() + .map(PathBuf::from) + .unwrap_or_default(); + + // Skip patches for other files (pathspec should filter, but be safe). + if old_path != file_path && new_path != file_path { + continue; + } + + let num_hunks = patch.num_hunks(); + for hunk_idx in 0..num_hunks { + let (hunk, _hunk_start) = patch.hunk(hunk_idx)?; + let num_lines = patch.num_lines_in_hunk(hunk_idx)?; + + // Collect indices of lines within this hunk that match our targets. + // Additions match on new_lineno, deletions on old_lineno; context and + // EOFNL markers are always carried so the partial hunk stays coherent. + let mut matching_line_indices: Vec = Vec::new(); + let mut has_change = false; + for line_idx in 0..num_lines { + let line = patch.line_in_hunk(hunk_idx, line_idx)?; + let origin = line.origin(); + + let is_target = match origin { + '+' => line + .new_lineno() + .is_some_and(|n| new_targets.contains(&(n as usize))), + '-' => line + .old_lineno() + .is_some_and(|n| old_targets.contains(&(n as usize))), + _ => false, + }; + + // Context lines and the "no newline at end of file" markers + // ('=' context, '>' added, '<' deleted) are carried unconditionally. + let is_context = matches!(origin, ' ' | '=' | '<' | '>'); + + if is_target || is_context { + matching_line_indices.push(line_idx); + has_change |= matches!(origin, '+' | '-'); + } + } + + // A hunk made up of only context/marker lines contributes nothing — skip + // it so we never emit an empty change. + if !has_change { + continue; + } + + // Count emitted pre-/post-image lines and derive the start positions. + // + // Counts reflect the *emitted* signs: when unstaging we negate, so an + // addition becomes a deletion (pre-image side) and vice versa. EOFNL + // markers annotate the preceding line and do not count toward either side. + // + // The output patch is applied to the index in both directions, but the + // diff's own old/new line numbers describe different sides per direction: + // - staging (index→workdir): pre-image = index → old_lineno + // post-image = workdir → new_lineno + // - unstaging (HEAD→index): pre-image = index → new_lineno + // post-image = HEAD → old_lineno + // so the line number that anchors each side is selected accordingly. + let mut old_count: u32 = 0; + let mut new_count: u32 = 0; + let mut old_start: Option = None; + let mut new_start: Option = None; + + for &line_idx in &matching_line_indices { + let line = patch.line_in_hunk(hunk_idx, line_idx)?; + let (preimage, postimage) = if staged { + (line.new_lineno(), line.old_lineno()) + } else { + (line.old_lineno(), line.new_lineno()) + }; + match emitted_sign(line.origin(), staged) { + Some(' ') => { + old_count += 1; + new_count += 1; + old_start = old_start.or(preimage); + new_start = new_start.or(postimage); + } + Some('-') => { + old_count += 1; + old_start = old_start.or(preimage); + } + Some('+') => { + new_count += 1; + new_start = new_start.or(postimage); + } + _ => {} + } + } + + // Fall back to the hunk's own boundaries when one side is empty (e.g. a + // pure-addition selection has no pre-image line to anchor old_start). + let (hunk_old_start, hunk_new_start) = if staged { + ( + old_start.unwrap_or_else(|| hunk.new_start()), + new_start.unwrap_or_else(|| hunk.old_start()), + ) + } else { + ( + old_start.unwrap_or_else(|| hunk.old_start()), + new_start.unwrap_or_else(|| hunk.new_start()), + ) + }; + + // Write file header (if first hunk for this file). The `diff --git` + // line is required for libgit2's `Diff::from_buffer` to recognize the + // file header; without it the first `@@` is rejected as a hunk header + // "outside patch". + if patch_text.is_empty() { + patch_text.push_str(&format!( + "diff --git a/{} b/{}\n", + old_path.display(), + new_path.display() + )); + patch_text.push_str(&format!("--- a/{}\n", old_path.display())); + patch_text.push_str(&format!("+++ b/{}\n", new_path.display())); + } + + // Write hunk header with counts derived from the emitted lines. + patch_text.push_str(&format!( + "@@ -{},{} +{},{} @@\n", + hunk_old_start, old_count, hunk_new_start, new_count + )); + + // Write the matching lines, applying the sign transform per direction. + for &line_idx in &matching_line_indices { + let line = patch.line_in_hunk(hunk_idx, line_idx)?; + let content = String::from_utf8_lossy(line.content()); + match line.origin() { + '+' | '-' | ' ' => { + if let Some(sign) = emitted_sign(line.origin(), staged) { + patch_text.push(sign); + } + patch_text.push_str(&content); + } + // EOFNL markers carry the "\ No newline at end of file" text in + // their content; emit it verbatim with no sign prefix. + '=' | '>' | '<' => patch_text.push_str(&content), + _ => {} + } + } + } + } + + if patch_text.is_empty() { + anyhow::bail!("Could not generate patch for selected lines"); + } + + if !patch_text.ends_with('\n') { + patch_text.push('\n'); + } + + Ok(patch_text) +} + +/// Parse a git2::Diff into a CommitDiff using structured Patch iteration. +/// Cross-commit parallelism is handled by the caller; this function processes +/// a single diff sequentially to avoid serialize/re-parse overhead. +pub(crate) fn parse_multi_file_diff(diff: &git2::Diff) -> Result { + let num_patches = diff.deltas().len(); + let mut files = Vec::with_capacity(num_patches); + for i in 0..num_patches { + if let Some(mut patch) = git2::Patch::from_diff(diff, i)? { + files.push(parse_single_patch(&mut patch)?); + } + } + + let total_additions = files.iter().map(|f| f.additions).sum(); + let total_deletions = files.iter().map(|f| f.deletions).sum(); + + Ok(CommitDiff { + total_additions, + total_deletions, + files, + }) +} + +/// Parse a single git2::Patch into a FileDiff. +/// Uses the patch's print callback to extract hunk/line content. +fn parse_single_patch(patch: &mut git2::Patch) -> Result { + let path = patch + .delta() + .new_file() + .path() + .unwrap_or(Path::new("")) + .to_path_buf(); + let kind = delta_to_file_change_kind(patch.delta().status()); + let (_, additions, deletions) = patch.line_stats().unwrap_or((0, 0, 0)); + + let mut hunks: Vec = Vec::new(); + let mut current_hunk: Option = None; + + // Use the patch's embedded print callback to extract hunk/line content. + // patch.print() processes THIS PATCH ONLY (not the full diff), giving us + // per-file isolation without needing a separate diff per file. + patch.print(&mut |_delta, hunk_range, line| { + if let Some(hunk) = hunk_range { + let header = String::from_utf8_lossy(hunk.header()).to_string(); + let needs_new = current_hunk + .as_ref() + .is_none_or(|h| h.new_start != hunk.new_start() || h.header != header); + if needs_new { + if let Some(prev) = current_hunk.take() { + hunks.push(prev); + } + current_hunk = Some(DiffHunk { + old_start: hunk.old_start(), + old_lines: hunk.old_lines(), + new_start: hunk.new_start(), + new_lines: hunk.new_lines(), + header, + lines: Vec::new(), + }); + } + } + + if let Some(ref mut hunk) = current_hunk { + // delta is the same for all lines in a patch-level print + let content = String::from_utf8_lossy(line.content()).to_string(); + match line.origin() { + '+' => hunk.lines.push(DiffLine::Addition(content)), + '-' => hunk.lines.push(DiffLine::Deletion(content)), + ' ' => hunk.lines.push(DiffLine::Context(content)), + _ => {} + } + } + + true + })?; + + if let Some(hunk) = current_hunk { + hunks.push(hunk); + } + + Ok(FileDiff { + path, + hunks, + additions, + deletions, + kind, + }) +} + +pub(crate) fn delta_to_file_change_kind(delta: git2::Delta) -> FileChangeKind { + match delta { + git2::Delta::Added | git2::Delta::Untracked => FileChangeKind::Added, + git2::Delta::Deleted => FileChangeKind::Deleted, + git2::Delta::Modified | git2::Delta::Typechange => FileChangeKind::Modified, + git2::Delta::Renamed => FileChangeKind::Renamed, + git2::Delta::Copied => FileChangeKind::Modified, + _ => FileChangeKind::Modified, + } +} + +/// Parse a git2::Diff into a FileDiff using the print API to avoid borrow issues. +pub(crate) fn parse_file_diff(path: &Path, diff: &git2::Diff) -> Result { + let mut file_diff = FileDiff { + path: path.to_path_buf(), + hunks: Vec::new(), + additions: 0, + deletions: 0, + kind: FileChangeKind::Modified, + }; + + diff.print(git2::DiffFormat::Patch, |delta, hunk, line| { + file_diff.kind = delta_to_file_change_kind(delta.status()); + if let Some(hunk) = hunk { + let header = String::from_utf8_lossy(hunk.header()).to_string(); + let expected_start = hunk.new_start(); + let needs_new = file_diff + .hunks + .last() + .is_none_or(|h| h.new_start != expected_start || h.header != header); + if needs_new { + file_diff.hunks.push(DiffHunk { + old_start: hunk.old_start(), + old_lines: hunk.old_lines(), + new_start: hunk.new_start(), + new_lines: hunk.new_lines(), + header, + lines: Vec::new(), + }); + } + } + + let content = String::from_utf8_lossy(line.content()).to_string(); + match line.origin() { + '+' => { + if let Some(h) = file_diff.hunks.last_mut() { + h.lines.push(DiffLine::Addition(content)); + } + file_diff.additions += 1; + } + '-' => { + if let Some(h) = file_diff.hunks.last_mut() { + h.lines.push(DiffLine::Deletion(content)); + } + file_diff.deletions += 1; + } + ' ' => { + if let Some(h) = file_diff.hunks.last_mut() { + h.lines.push(DiffLine::Context(content)); + } + } + _ => {} + } + + true + })?; + + Ok(file_diff) +} + +pub fn compute_file_diff(repo_path: &Path, file_path: &Path, staged: bool) -> Result { + let repo = Repository::open(repo_path)?; + let mut diff_opts = DiffOptions::new(); + diff_opts.pathspec(file_path); + // Without this libgit2 fnmatches the pathspec, so a file literally named + // `data[1].json` would not match itself and could match a different file. + diff_opts.disable_pathspec_match(true); + diff_opts.include_untracked(true); + diff_opts.show_untracked_content(true); + diff_opts.recurse_untracked_dirs(true); + let diff = if staged { + let head_tree = repo.head()?.peel_to_tree().ok(); + repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut diff_opts))? + } else { + repo.diff_index_to_workdir(None, Some(&mut diff_opts))? + }; + parse_file_diff(file_path, &diff) +} + +pub fn compute_commit_diff(repo_path: &Path, oid: git2::Oid) -> Result { + let diff_timer = std::time::Instant::now(); + log::debug!( + "compute_commit_diff: oid={} repo={}", + oid, + repo_path.display() + ); + let repo = Repository::open(repo_path)?; + let commit = repo.find_commit(oid)?; + let tree = commit.tree()?; + let parent_tree = if commit.parent_count() > 0 { + Some(commit.parent(0)?.tree()?) + } else { + None + }; + let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None)?; + let result = parse_multi_file_diff(&diff); + if let Ok(ref d) = result { + log::debug!( + "compute_commit_diff complete in {:?}: {} files", + diff_timer.elapsed(), + d.files.len() + ); + } + result +} + +pub fn compute_stash_diff(repo_path: &Path, index: usize) -> Result { + let mut repo = Repository::open(repo_path)?; + let mut stash_oid: Option = None; + repo.stash_foreach(|idx, _msg, oid| { + if idx == index { + stash_oid = Some(*oid); + false // stop early — found the target stash + } else { + true + } + })?; + let stash_oid = + stash_oid.ok_or_else(|| anyhow::anyhow!("Stash index {} out of range", index))?; + compute_commit_diff(repo_path, stash_oid) +} + +pub fn compute_staged_diff_text(repo_path: &Path) -> Result { + let repo = Repository::open(repo_path)?; + let head_tree = repo.head()?.peel_to_tree().ok(); + let diff = repo.diff_tree_to_index(head_tree.as_ref(), None, None)?; + let mut text = String::new(); + diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { + // Lossy rather than skip-on-error: a Latin-1 or Shift-JIS source file + // used to have its lines vanish from the diff with no marker, leaving + // the AI to reason about an incomplete change set and write a + // confidently wrong message. A mangled line is far better than a + // missing one. (Binary content is unaffected — libgit2 defaults to + // `show_binary = false` and renders "Binary files ... differ".) + text.push(line.origin()); + text.push_str(&String::from_utf8_lossy(line.content())); + true + })?; + Ok(text) +} + +use gpui::{AsyncApp, Context, Task, WeakEntity}; + +use super::GitProject; +use super::GitProjectEvent; + +impl GitProject { + /// Get diff for a specific file (staged or unstaged). + pub fn diff_file(&self, path: &Path, staged: bool) -> Result { + let repo = self.open_repo()?; + let mut diff_opts = DiffOptions::new(); + diff_opts.pathspec(path); + // Without this libgit2 fnmatches the pathspec, so a file literally named + // `data[1].json` would not match itself and could match a different file. + diff_opts.disable_pathspec_match(true); + diff_opts.include_untracked(true); + diff_opts.show_untracked_content(true); + diff_opts.recurse_untracked_dirs(true); + + let diff = if staged { + let head_tree = repo.head()?.peel_to_tree().ok(); + repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut diff_opts))? + } else { + repo.diff_index_to_workdir(None, Some(&mut diff_opts))? + }; + + parse_file_diff(path, &diff) + } + + /// Get diff for a specific commit. + pub fn diff_commit(&self, oid: git2::Oid) -> Result { + let repo = self.open_repo()?; + let commit = repo.find_commit(oid)?; + let tree = commit.tree()?; + + let parent_tree = if commit.parent_count() > 0 { + Some(commit.parent(0)?.tree()?) + } else { + None + }; + + let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None)?; + parse_multi_file_diff(&diff) + } + + /// Get the diff for a stash entry at the given index. + pub fn diff_stash(&self, index: usize) -> Result { + let mut repo = self.open_repo()?; + + let mut stash_oids: Vec = Vec::new(); + repo.stash_foreach(|_idx, _msg, oid| { + stash_oids.push(*oid); + true + })?; + + let oid = *stash_oids + .get(index) + .ok_or_else(|| anyhow::anyhow!("Stash index {} out of range", index))?; + + let commit = repo.find_commit(oid)?; + let tree = commit.tree()?; + + let parent_tree = if commit.parent_count() > 0 { + Some(commit.parent(0)?.tree()?) + } else { + None + }; + + let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None)?; + parse_multi_file_diff(&diff) + } + + /// Stage a specific hunk in the given worktree. + pub fn stage_hunk_at( + &mut self, + file_path: &Path, + hunk_index: usize, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + let file_path = file_path.to_path_buf(); + let task_file_path = file_path.clone(); + let worktree_path = worktree_path.to_path_buf(); + let refresh_repo_path = self.repo_path.clone(); + let worktree_cache = self.worktree_status_cache.clone(); + let author_filter = self.commit_author_filter.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let operation_id = self.begin_operation( + GitOperationKind::Stage, + format!( + "Staging hunk {} in {}...", + hunk_index + 1, + file_path.display() + ), + None, + branch_name.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&worktree_path)?; + let patch_text = + generate_hunk_patch_for_repo(&repo, &task_file_path, hunk_index, false)?; + let diff = git2::Diff::from_buffer(patch_text.as_bytes())?; + repo.apply(&diff, git2::ApplyLocation::Index, None)?; + gather_refresh_data_lightweight_cached( + &refresh_repo_path, + commit_limit, + &worktree_cache, + author_filter.as_deref(), + ) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.refresh_ahead_behind(cx); + this.complete_op( + operation_id, + GitOperationKind::Stage, + format!( + "Staged hunk {} in {}", + hunk_index + 1, + file_path.display() + ), + (None, None, branch_name.clone()), + cx, + ); + cx.emit(GitProjectEvent::StatusChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Stage, + "Stage hunk failed", + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Unstage a specific hunk from a staged file diff in the given worktree. + pub fn unstage_hunk_at( + &mut self, + file_path: &Path, + hunk_index: usize, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + let file_path = file_path.to_path_buf(); + let task_file_path = file_path.clone(); + let worktree_path = worktree_path.to_path_buf(); + let refresh_repo_path = self.repo_path.clone(); + let worktree_cache = self.worktree_status_cache.clone(); + let author_filter = self.commit_author_filter.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let operation_id = self.begin_operation( + GitOperationKind::Unstage, + format!( + "Unstaging hunk {} in {}...", + hunk_index + 1, + file_path.display() + ), + None, + branch_name.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&worktree_path)?; + let patch_text = + generate_hunk_patch_for_repo(&repo, &task_file_path, hunk_index, true)?; + let diff = git2::Diff::from_buffer(patch_text.as_bytes())?; + let mut opts = git2::ApplyOptions::new(); + repo.apply(&diff, git2::ApplyLocation::Index, Some(&mut opts))?; + gather_refresh_data_lightweight_cached( + &refresh_repo_path, + commit_limit, + &worktree_cache, + author_filter.as_deref(), + ) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.refresh_ahead_behind(cx); + this.complete_op( + operation_id, + GitOperationKind::Unstage, + format!( + "Unstaged hunk {} in {}", + hunk_index + 1, + file_path.display() + ), + (None, None, branch_name.clone()), + cx, + ); + cx.emit(GitProjectEvent::StatusChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Unstage, + "Unstage hunk failed", + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Stage specific lines within a file's diff in the given worktree. + pub fn stage_lines_at( + &mut self, + file_path: &Path, + line_pairs: &[(Option, Option)], + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + let file_path = file_path.to_path_buf(); + let task_file_path = file_path.clone(); + let worktree_path = worktree_path.to_path_buf(); + let refresh_repo_path = self.repo_path.clone(); + let worktree_cache = self.worktree_status_cache.clone(); + let author_filter = self.commit_author_filter.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let line_count = line_pairs.len(); + let line_pairs_owned = line_pairs.to_vec(); + let operation_id = self.begin_operation( + GitOperationKind::Stage, + format!( + "Staging {} line{} in {}...", + line_count, + if line_count == 1 { "" } else { "s" }, + file_path.display() + ), + None, + branch_name.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&worktree_path)?; + let patch_text = generate_line_patch_for_repo( + &repo, + &task_file_path, + &line_pairs_owned, + false, // staging from workdir to index + )?; + let diff = git2::Diff::from_buffer(patch_text.as_bytes())?; + repo.apply(&diff, git2::ApplyLocation::Index, None)?; + gather_refresh_data_lightweight_cached( + &refresh_repo_path, + commit_limit, + &worktree_cache, + author_filter.as_deref(), + ) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.refresh_ahead_behind(cx); + this.complete_op( + operation_id, + GitOperationKind::Stage, + format!( + "Staged {} line{} in {}", + line_count, + if line_count == 1 { "" } else { "s" }, + file_path.display() + ), + (None, None, branch_name.clone()), + cx, + ); + cx.emit(GitProjectEvent::StatusChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Stage, + "Stage lines failed", + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Unstage specific lines from a staged file diff in the given worktree. + pub fn unstage_lines_at( + &mut self, + file_path: &Path, + line_pairs: &[(Option, Option)], + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + let file_path = file_path.to_path_buf(); + let task_file_path = file_path.clone(); + let worktree_path = worktree_path.to_path_buf(); + let refresh_repo_path = self.repo_path.clone(); + let worktree_cache = self.worktree_status_cache.clone(); + let author_filter = self.commit_author_filter.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let line_count = line_pairs.len(); + let line_pairs_owned = line_pairs.to_vec(); + let operation_id = self.begin_operation( + GitOperationKind::Unstage, + format!( + "Unstaging {} line{} in {}...", + line_count, + if line_count == 1 { "" } else { "s" }, + file_path.display() + ), + None, + branch_name.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&worktree_path)?; + // staged=true: diff is HEAD→index; we negate signs to remove from index. + let patch_text = generate_line_patch_for_repo( + &repo, + &task_file_path, + &line_pairs_owned, + true, // unstaging from staged (HEAD→index) + )?; + let diff = git2::Diff::from_buffer(patch_text.as_bytes())?; + let mut opts = git2::ApplyOptions::new(); + repo.apply(&diff, git2::ApplyLocation::Index, Some(&mut opts))?; + gather_refresh_data_lightweight_cached( + &refresh_repo_path, + commit_limit, + &worktree_cache, + author_filter.as_deref(), + ) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.refresh_ahead_behind(cx); + this.complete_op( + operation_id, + GitOperationKind::Unstage, + format!( + "Unstaged {} line{} in {}", + line_count, + if line_count == 1 { "" } else { "s" }, + file_path.display() + ), + (None, None, branch_name.clone()), + cx, + ); + cx.emit(GitProjectEvent::StatusChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Unstage, + "Unstage lines failed", + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Generate a patch for a single hunk from a file's diff. + /// Get the staged diff as a string (for AI commit message generation). + pub fn staged_diff_text(&self) -> Result { + let repo = self.open_repo()?; + let head_tree = repo.head()?.peel_to_tree().ok(); + let diff = repo.diff_tree_to_index(head_tree.as_ref(), None, None)?; + + let mut output = String::new(); + diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { + let prefix = match line.origin() { + '+' => "+", + '-' => "-", + _ => " ", + }; + let content = String::from_utf8_lossy(line.content()); + output.push_str(prefix); + output.push_str(&content); + true + })?; + + Ok(output) + } + + /// Summary of staged changes for AI context. + pub fn staged_summary(&self) -> String { + Self::format_staged_summary(&self.status.staged) + } + + /// Staged-file summary for a specific worktree, so an AI commit message + /// describes the changes the commit will actually contain rather than + /// whatever the main checkout happens to have staged. + pub fn staged_summary_at(&self, worktree_path: &Path) -> String { + match self + .worktree_at(worktree_path) + .and_then(|worktree| worktree.status.as_ref()) + { + Some(status) => Self::format_staged_summary(&status.staged), + None => self.staged_summary(), + } + } + + fn format_staged_summary(staged: &[FileStatus]) -> String { + staged + .iter() + .map(|file| format!("{} {}", file.kind.short_code(), file.path.display())) + .collect::>() + .join("\n") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── delta_to_file_change_kind ────────────────────────────────── + + #[test] + fn delta_added() { + assert!(matches!( + delta_to_file_change_kind(git2::Delta::Added), + FileChangeKind::Added + )); + } + + #[test] + fn delta_untracked_maps_to_added() { + assert!(matches!( + delta_to_file_change_kind(git2::Delta::Untracked), + FileChangeKind::Added + )); + } + + #[test] + fn delta_deleted() { + assert!(matches!( + delta_to_file_change_kind(git2::Delta::Deleted), + FileChangeKind::Deleted + )); + } + + #[test] + fn delta_modified() { + assert!(matches!( + delta_to_file_change_kind(git2::Delta::Modified), + FileChangeKind::Modified + )); + } + + #[test] + fn delta_typechange_maps_to_modified() { + assert!(matches!( + delta_to_file_change_kind(git2::Delta::Typechange), + FileChangeKind::Modified + )); + } + + #[test] + fn delta_renamed() { + assert!(matches!( + delta_to_file_change_kind(git2::Delta::Renamed), + FileChangeKind::Renamed + )); + } + + #[test] + fn delta_copied_maps_to_modified() { + assert!(matches!( + delta_to_file_change_kind(git2::Delta::Copied), + FileChangeKind::Modified + )); + } + + #[test] + fn delta_conflicted_maps_to_modified() { + assert!(matches!( + delta_to_file_change_kind(git2::Delta::Conflicted), + FileChangeKind::Modified + )); + } + + #[test] + fn delta_ignored_maps_to_modified() { + assert!(matches!( + delta_to_file_change_kind(git2::Delta::Ignored), + FileChangeKind::Modified + )); + } +} + +// ── Three-way conflict diff ─────────────────────────────────── + +/// Compute Git's lossless three-way merge model for a conflicted file. +pub fn compute_three_way_conflict_diff( + repo_path: &Path, + file_path: &Path, +) -> Result { + let repo = Repository::open(repo_path)?; + let index = repo.index()?; + let mut conflicts = index.conflicts()?; + + let path_bytes = file_path.as_os_str().as_encoded_bytes(); + + let conflict_entry = loop { + if let Some(Ok(conflict)) = conflicts.next() { + let conflict_path_bytes: Option<&[u8]> = conflict + .our + .as_ref() + .map(|e| e.path.as_slice()) + .or_else(|| conflict.their.as_ref().map(|e| e.path.as_slice())) + .or_else(|| conflict.ancestor.as_ref().map(|e| e.path.as_slice())); + + if conflict_path_bytes.is_some_and(|pb| pb == path_bytes) { + break conflict; + } + } else { + anyhow::bail!("conflict not found for path '{}'", file_path.display()); + } + }; + + let ancestor_exists = conflict_entry.ancestor.is_some(); + let ours_exists = conflict_entry.our.is_some(); + let theirs_exists = conflict_entry.their.is_some(); + let ancestor_bytes = entry_bytes(&repo, conflict_entry.ancestor.as_ref())?; + let ours_bytes = entry_bytes(&repo, conflict_entry.our.as_ref())?; + let theirs_bytes = entry_bytes(&repo, conflict_entry.their.as_ref())?; + + let entries = [ + conflict_entry.ancestor.as_ref(), + conflict_entry.our.as_ref(), + conflict_entry.their.as_ref(), + ]; + let is_special_file = entries + .iter() + .flatten() + .any(|entry| entry.mode & 0o170000 != 0o100000); + let is_binary = [&ancestor_bytes, &ours_bytes, &theirs_bytes] + .into_iter() + .any(|bytes| !is_text(bytes)); + + // Delete/modify, binary and special-file conflicts need a whole-side + // decision. Feeding a synthetic empty blob through the text merger would + // make deletion indistinguishable from keeping an empty file. + let (sections, result_mode) = if !is_binary && !is_special_file && ours_exists && theirs_exists + { + let empty_blob = (!ancestor_exists).then(|| repo.blob(&[])).transpose()?; + let synthetic_ancestor = empty_blob.map(|oid| { + synthetic_empty_entry( + conflict_entry + .our + .as_ref() + .or(conflict_entry.their.as_ref()) + .expect("both text sides exist"), + oid, + ) + }); + let ancestor_entry = conflict_entry + .ancestor + .as_ref() + .or(synthetic_ancestor.as_ref()) + .expect("a real or synthetic ancestor exists"); + let markers = MergeMarkers::collision_free([ + ancestor_bytes.as_slice(), + ours_bytes.as_slice(), + theirs_bytes.as_slice(), + ])?; + let mut options = MergeFileOptions::new(); + options + .ancestor_label(&markers.ancestor_label) + .our_label(&markers.our_label) + .their_label(&markers.their_label) + .style_diff3(true) + .patience(true) + .marker_size(markers.size); + let merged = repo.merge_file_from_index( + ancestor_entry, + conflict_entry.our.as_ref().expect("ours exists"), + conflict_entry.their.as_ref().expect("theirs exists"), + Some(&mut options), + )?; + let mut sections = parse_merge_sections(merged.content(), &markers)?; + restore_missing_final_newline(&mut sections, &ancestor_bytes, ConflictInputSide::Ancestor); + restore_missing_final_newline(&mut sections, &ours_bytes, ConflictInputSide::Ours); + restore_missing_final_newline(&mut sections, &theirs_bytes, ConflictInputSide::Theirs); + (sections, merged.mode()) + } else { + let mode = conflict_entry + .our + .as_ref() + .or(conflict_entry.their.as_ref()) + .or(conflict_entry.ancestor.as_ref()) + .map(|entry| entry.mode) + .unwrap_or(0o100644); + ( + vec![MergeSection::Conflict { + ancestor: ancestor_bytes.clone(), + ours: ours_bytes.clone(), + theirs: theirs_bytes.clone(), + }], + mode, + ) + }; + + let worktree = repo + .workdir() + .map(|workdir| workdir.join(file_path)) + .map_or(ConflictWorktreeSnapshot::Missing, |path| { + read_worktree_snapshot(&path) + }); + + Ok(ThreeWayFileDiff { + path: file_path.to_path_buf(), + sections, + snapshot: ConflictSnapshot { + ancestor_oid: conflict_entry.ancestor.as_ref().map(|entry| entry.id), + ancestor_mode: conflict_entry.ancestor.as_ref().map(|entry| entry.mode), + ours_oid: conflict_entry.our.as_ref().map(|entry| entry.id), + ours_mode: conflict_entry.our.as_ref().map(|entry| entry.mode), + theirs_oid: conflict_entry.their.as_ref().map(|entry| entry.id), + theirs_mode: conflict_entry.their.as_ref().map(|entry| entry.mode), + worktree, + }, + ancestor_exists, + ours_exists, + theirs_exists, + is_binary, + is_special_file, + result_mode, + }) +} + +const MIN_MERGE_MARKER_SIZE: u16 = 23; + +struct MergeMarkers { + size: u16, + ancestor_label: String, + our_label: String, + their_label: String, + open: Vec, + bare_base: Vec, + base: Vec, + separator: Vec, + close: Vec, +} + +impl MergeMarkers { + fn collision_free(inputs: [&[u8]; 3]) -> Result { + let size = (MIN_MERGE_MARKER_SIZE..=u16::MAX) + .find(|size| { + let bare_base = vec![b'|'; usize::from(*size)]; + let separator = vec![b'='; usize::from(*size)]; + !input_contains_line(&inputs, &bare_base) + && !input_contains_line(&inputs, &separator) + }) + .ok_or_else(|| anyhow::anyhow!("Could not allocate collision-free merge markers"))?; + + for nonce in 0_u64.. { + let suffix = if nonce == 0 { + String::new() + } else { + format!("-{nonce}") + }; + let ancestor_label = format!("rgitui-base{suffix}"); + let our_label = format!("rgitui-current{suffix}"); + let their_label = format!("rgitui-incoming{suffix}"); + let bare_base = vec![b'|'; usize::from(size)]; + let separator = vec![b'='; usize::from(size)]; + let open = labeled_merge_marker(b'<', size, &our_label); + let base = labeled_merge_marker(b'|', size, &ancestor_label); + let close = labeled_merge_marker(b'>', size, &their_label); + if [&open, &base, &close] + .into_iter() + .all(|marker| !input_contains_line(&inputs, marker)) + { + return Ok(Self { + size, + ancestor_label, + our_label, + their_label, + open, + bare_base, + base, + separator, + close, + }); + } + } + unreachable!("the merge-marker nonce space is exhaustive") + } +} + +fn labeled_merge_marker(byte: u8, size: u16, label: &str) -> Vec { + let mut marker = vec![byte; usize::from(size)]; + marker.push(b' '); + marker.extend_from_slice(label.as_bytes()); + marker +} + +fn input_contains_line(inputs: &[&[u8]], candidate: &[u8]) -> bool { + inputs.iter().any(|input| { + input + .split_inclusive(|byte| *byte == b'\n') + .any(|line| line_without_eol(line) == candidate) + }) +} + +fn entry_bytes(repo: &Repository, entry: Option<&IndexEntry>) -> Result> { + let Some(entry) = entry else { + return Ok(Vec::new()); + }; + if entry.mode & 0o170000 == 0o160000 { + // Gitlink OIDs identify commits, so there is no blob to load. Keeping + // the commit ID as display data lets the whole-side resolver show the + // exact submodule revision represented by each index stage. + return Ok(entry.id.to_string().into_bytes()); + } + Ok(repo.find_blob(entry.id)?.content().to_vec()) +} + +fn is_text(bytes: &[u8]) -> bool { + !bytes.contains(&0) && std::str::from_utf8(bytes).is_ok() +} + +fn synthetic_empty_entry(template: &IndexEntry, oid: git2::Oid) -> IndexEntry { + IndexEntry { + ctime: template.ctime, + mtime: template.mtime, + dev: template.dev, + ino: template.ino, + mode: template.mode, + uid: template.uid, + gid: template.gid, + file_size: 0, + id: oid, + flags: template.flags, + flags_extended: template.flags_extended, + path: template.path.clone(), + } +} + +#[derive(Clone, Copy)] +enum ConflictInputSide { + Ancestor, + Ours, + Theirs, +} + +#[cfg(test)] +fn side_projection(sections: &[MergeSection], side: ConflictInputSide) -> Vec { + let mut bytes = Vec::new(); + for section in sections { + match section { + MergeSection::Resolved(resolved) => bytes.extend_from_slice(resolved), + MergeSection::Conflict { + ancestor, + ours, + theirs, + } => bytes.extend_from_slice(match side { + ConflictInputSide::Ancestor => ancestor, + ConflictInputSide::Ours => ours, + ConflictInputSide::Theirs => theirs, + }), + } + } + bytes +} + +/// libgit2 has to put marker lines on their own lines. When a conflicting side +/// reaches EOF without a newline it therefore inserts one byte before the next +/// marker. Remove only that provably synthetic byte so choosing a side remains +/// byte-exact. +fn restore_missing_final_newline( + sections: &mut [MergeSection], + original: &[u8], + side: ConflictInputSide, +) { + if original.ends_with(b"\n") { + return; + } + + for section in sections.iter_mut().rev() { + let MergeSection::Conflict { + ancestor, + ours, + theirs, + } = section + else { + // A non-empty resolved suffix proves this conflict did not reach + // EOF in the merge output, so its newline is real. + if matches!(section, MergeSection::Resolved(bytes) if !bytes.is_empty()) { + return; + } + continue; + }; + let bytes = match side { + ConflictInputSide::Ancestor => ancestor, + ConflictInputSide::Ours => ours, + ConflictInputSide::Theirs => theirs, + }; + if bytes.is_empty() { + continue; + } + + // Depending on the input's line-ending style, libgit2 can add LF or + // CRLF before its next marker. The conflict must be the final non-empty + // merge section and the remaining bytes must match the original side's + // actual EOF before either suffix is considered synthetic. + let synthetic_len = + if bytes.ends_with(b"\r\n") && original.ends_with(&bytes[..bytes.len() - 2]) { + 2 + } else if bytes.ends_with(b"\n") && original.ends_with(&bytes[..bytes.len() - 1]) { + 1 + } else { + return; + }; + bytes.truncate(bytes.len() - synthetic_len); + return; + } +} + +fn read_worktree_snapshot(path: &Path) -> ConflictWorktreeSnapshot { + let metadata = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return ConflictWorktreeSnapshot::Missing; + } + Err(_) => return ConflictWorktreeSnapshot::Other, + }; + if metadata.file_type().is_symlink() { + return std::fs::read_link(path).map_or(ConflictWorktreeSnapshot::Other, |target| { + ConflictWorktreeSnapshot::Symlink { + target: target.as_os_str().as_encoded_bytes().to_vec(), + } + }); + } + if metadata.is_file() { + std::fs::read(path).map_or(ConflictWorktreeSnapshot::Other, |bytes| { + ConflictWorktreeSnapshot::Regular { + bytes, + executable: worktree_executable(&metadata), + readonly: metadata.permissions().readonly(), + } + }) + } else { + ConflictWorktreeSnapshot::Other + } +} + +#[cfg(unix)] +fn worktree_executable(metadata: &std::fs::Metadata) -> Option { + use std::os::unix::fs::PermissionsExt; + Some(metadata.permissions().mode() & 0o111 != 0) +} + +#[cfg(not(unix))] +fn worktree_executable(_metadata: &std::fs::Metadata) -> Option { + None +} + +fn parse_merge_sections(content: &[u8], markers: &MergeMarkers) -> Result> { + #[derive(Clone, Copy)] + enum ParseState { + Resolved, + Ours, + Ancestor, + Theirs, + } + + let mut state = ParseState::Resolved; + let mut resolved = Vec::new(); + let mut ours = Vec::new(); + let mut ancestor = Vec::new(); + let mut theirs = Vec::new(); + let mut sections = Vec::new(); + + for line in content.split_inclusive(|byte| *byte == b'\n') { + let marker_line = line_without_eol(line); + match state { + ParseState::Resolved if marker_line == markers.open => { + if !resolved.is_empty() { + sections.push(MergeSection::Resolved(std::mem::take(&mut resolved))); + } + state = ParseState::Ours; + } + ParseState::Ours if marker_line == markers.base || marker_line == markers.bare_base => { + state = ParseState::Ancestor + } + // libgit2 may omit the ancestor block for add/add conflicts even + // when diff3 style was requested. The synthetic ancestor is empty, + // so the ordinary separator still carries the exact information. + ParseState::Ours if marker_line == markers.separator => state = ParseState::Theirs, + ParseState::Ancestor if marker_line == markers.separator => state = ParseState::Theirs, + ParseState::Theirs if marker_line == markers.close => { + sections.push(MergeSection::Conflict { + ancestor: std::mem::take(&mut ancestor), + ours: std::mem::take(&mut ours), + theirs: std::mem::take(&mut theirs), + }); + state = ParseState::Resolved; + } + ParseState::Resolved => resolved.extend_from_slice(line), + ParseState::Ours => ours.extend_from_slice(line), + ParseState::Ancestor => ancestor.extend_from_slice(line), + ParseState::Theirs => theirs.extend_from_slice(line), + } + } + + if !matches!(state, ParseState::Resolved) { + anyhow::bail!("Git returned an incomplete conflict section for this file"); + } + if !resolved.is_empty() || sections.is_empty() { + sections.push(MergeSection::Resolved(resolved)); + } + Ok(sections) +} + +fn line_without_eol(line: &[u8]) -> &[u8] { + let line = line.strip_suffix(b"\n").unwrap_or(line); + line.strip_suffix(b"\r").unwrap_or(line) +} + +#[cfg(test)] +mod conflict_diff_tests { + use super::*; + use rgitui_test_support::TempRepo; + use std::io::Write; + use std::process::Stdio; + + fn run_git(repo: &TempRepo, args: &[&str]) -> std::process::Output { + super::super::git_command() + .current_dir(repo.path()) + .args(args) + .output() + .expect("failed to run git") + } + + fn install_gitlink_conflict( + repo: &TempRepo, + path: &str, + ancestor: git2::Oid, + ours: git2::Oid, + theirs: git2::Oid, + ) { + let entries = format!( + "160000 {ancestor} 1\t{path}\n160000 {ours} 2\t{path}\n160000 {theirs} 3\t{path}\n" + ); + let mut child = super::super::git_command() + .current_dir(repo.path()) + .args(["update-index", "--index-info"]) + .stdin(Stdio::piped()) + .spawn() + .expect("start git update-index"); + child + .stdin + .take() + .expect("update-index stdin") + .write_all(entries.as_bytes()) + .expect("write conflict entries"); + assert!(child.wait().expect("wait for update-index").success()); + } + + fn make_text_conflict() -> TempRepo { + let repo = TempRepo::init(); + let base = (0..24) + .map(|line| format!("base {line}\n")) + .collect::(); + repo.commit_file("conflict.txt", &base, "base"); + + assert!(run_git(&repo, &["checkout", "-b", "incoming"]) + .status + .success()); + let incoming = base + .replace("base 3\n", "incoming 3\n") + .replace("base 18\n", "incoming 18\n"); + repo.commit_file("conflict.txt", &incoming, "incoming changes"); + + assert!(run_git(&repo, &["checkout", "main"]).status.success()); + let current = base + .replace("base 3\n", "current 3\n") + .replace("base 18\n", "current 18\n"); + repo.commit_file("conflict.txt", ¤t, "current changes"); + + let merge = run_git(&repo, &["merge", "incoming"]); + assert!(!merge.status.success(), "merge should stop on a conflict"); + repo + } + + #[test] + fn parse_merge_sections_preserves_crlf_and_missing_final_newline() { + let markers = MergeMarkers::collision_free([b"", b"", b""]).unwrap(); + let marker = |byte: char| byte.to_string().repeat(usize::from(markers.size)); + let content = format!( + "prefix\r\n{} rgitui-current\r\nours\r\n{} rgitui-base\r\nbase\r\n{}\r\ntheirs\r\n{} rgitui-incoming\r\nsuffix", + marker('<'), + marker('|'), + marker('='), + marker('>') + ); + + assert_eq!( + parse_merge_sections(content.as_bytes(), &markers).unwrap(), + vec![ + MergeSection::Resolved(b"prefix\r\n".to_vec()), + MergeSection::Conflict { + ancestor: b"base\r\n".to_vec(), + ours: b"ours\r\n".to_vec(), + theirs: b"theirs\r\n".to_vec(), + }, + MergeSection::Resolved(b"suffix".to_vec()), + ] + ); + } + + #[test] + fn conflict_side_lines_that_look_like_generated_markers_remain_byte_exact() { + let repo = TempRepo::init(); + repo.commit_file("conflict.txt", "base\n", "base"); + assert!(run_git(&repo, &["checkout", "-b", "incoming"]) + .status + .success()); + repo.commit_file("conflict.txt", "incoming\n", "incoming changes"); + assert!(run_git(&repo, &["checkout", "main"]).status.success()); + let collision = format!( + "{} rgitui-base\ncurrent\n", + "|".repeat(usize::from(MIN_MERGE_MARKER_SIZE)) + ); + repo.commit_file("conflict.txt", &collision, "current changes"); + assert!(!run_git(&repo, &["merge", "incoming"]).status.success()); + + let diff = compute_three_way_conflict_diff(repo.path(), Path::new("conflict.txt")).unwrap(); + + assert_eq!( + side_projection(&diff.sections, ConflictInputSide::Ours), + collision.as_bytes() + ); + assert_eq!( + side_projection(&diff.sections, ConflictInputSide::Ancestor), + b"base\n" + ); + assert_eq!( + side_projection(&diff.sections, ConflictInputSide::Theirs), + b"incoming\n" + ); + } + + #[test] + fn computes_independent_regions_from_gits_merge_engine() { + let repo = make_text_conflict(); + let diff = compute_three_way_conflict_diff(repo.path(), Path::new("conflict.txt")).unwrap(); + + assert_eq!(diff.conflict_count(), 2); + assert!(diff.supports_text_resolution()); + assert!(!diff.is_binary); + assert_eq!( + diff.snapshot.worktree.bytes(), + std::fs::read(repo.path().join("conflict.txt")) + .ok() + .as_deref() + ); + assert!(diff.sections.iter().any(|section| matches!( + section, + MergeSection::Resolved(bytes) if bytes.windows(b"base 10\n".len()).any(|window| window == b"base 10\n") + ))); + } + + #[test] + fn conflict_sides_without_final_newlines_remain_byte_exact() { + let repo = TempRepo::init(); + repo.commit_file("conflict.txt", "base", "base"); + assert!(run_git(&repo, &["checkout", "-b", "incoming"]) + .status + .success()); + repo.commit_file("conflict.txt", "incoming", "incoming changes"); + assert!(run_git(&repo, &["checkout", "main"]).status.success()); + repo.commit_file("conflict.txt", "current", "current changes"); + assert!(!run_git(&repo, &["merge", "incoming"]).status.success()); + + let diff = compute_three_way_conflict_diff(repo.path(), Path::new("conflict.txt")).unwrap(); + assert_eq!( + side_projection(&diff.sections, ConflictInputSide::Ancestor), + b"base" + ); + assert_eq!( + side_projection(&diff.sections, ConflictInputSide::Ours), + b"current" + ); + assert_eq!( + side_projection(&diff.sections, ConflictInputSide::Theirs), + b"incoming" + ); + } + + #[test] + fn eof_conflict_remains_exact_when_resolved_sections_include_other_side_changes() { + let repo = TempRepo::init(); + let base = (0..20) + .map(|line| { + if line == 19 { + format!("base {line}") + } else { + format!("base {line}\n") + } + }) + .collect::(); + repo.commit_file("conflict.txt", &base, "base"); + + assert!(run_git(&repo, &["checkout", "-b", "incoming"]) + .status + .success()); + let incoming = base + .replace("base 2\n", "incoming 2\n") + .replace("base 19", "incoming 19"); + repo.commit_file("conflict.txt", &incoming, "incoming changes"); + + assert!(run_git(&repo, &["checkout", "main"]).status.success()); + let current = base + .replace("base 8\n", "current 8\n") + .replace("base 19", "current 19"); + repo.commit_file("conflict.txt", ¤t, "current changes"); + assert!(!run_git(&repo, &["merge", "incoming"]).status.success()); + + let diff = compute_three_way_conflict_diff(repo.path(), Path::new("conflict.txt")).unwrap(); + assert_eq!(diff.conflict_count(), 1); + assert!(matches!( + diff.sections + .iter() + .find(|section| matches!(section, MergeSection::Conflict { .. })), + Some(MergeSection::Conflict { + ancestor, + ours, + theirs, + }) if ancestor == b"base 19" + && ours == b"current 19" + && theirs == b"incoming 19" + )); + } + + #[test] + fn non_eof_conflict_keeps_real_newline_when_unterminated_suffix_repeats_its_text() { + let repo = TempRepo::init(); + let suffix = (1..20) + .map(|line| { + if line == 19 { + "X".to_string() + } else { + format!("common {line}\n") + } + }) + .collect::(); + repo.commit_file("conflict.txt", &format!("base\n{suffix}"), "base"); + + assert!(run_git(&repo, &["checkout", "-b", "incoming"]) + .status + .success()); + repo.commit_file( + "conflict.txt", + &format!("incoming\n{suffix}"), + "incoming changes", + ); + assert!(run_git(&repo, &["checkout", "main"]).status.success()); + repo.commit_file("conflict.txt", &format!("X\n{suffix}"), "current changes"); + assert!(!run_git(&repo, &["merge", "incoming"]).status.success()); + + let diff = compute_three_way_conflict_diff(repo.path(), Path::new("conflict.txt")).unwrap(); + assert!(matches!( + diff.sections + .iter() + .find(|section| matches!(section, MergeSection::Conflict { .. })), + Some(MergeSection::Conflict { ours, .. }) if ours == b"X\n" + )); + assert!(matches!( + diff.sections.last(), + Some(MergeSection::Resolved(bytes)) if bytes.ends_with(b"X") + )); + } + + #[test] + fn add_add_conflict_uses_a_synthetic_empty_ancestor() { + let repo = TempRepo::init(); + repo.commit("base"); + assert!(run_git(&repo, &["checkout", "-b", "incoming"]) + .status + .success()); + repo.commit_file("added.txt", "incoming\n", "incoming add"); + assert!(run_git(&repo, &["checkout", "main"]).status.success()); + repo.commit_file("added.txt", "current\n", "current add"); + assert!(!run_git(&repo, &["merge", "incoming"]).status.success()); + + let diff = compute_three_way_conflict_diff(repo.path(), Path::new("added.txt")).unwrap(); + assert!(!diff.ancestor_exists); + assert!(diff.ours_exists && diff.theirs_exists); + assert!(diff.supports_text_resolution()); + assert_eq!(diff.conflict_count(), 1); + assert!( + matches!( + &diff.sections[0], + MergeSection::Conflict { ancestor, ours, theirs } + if ancestor.is_empty() && ours == b"current\n" && theirs == b"incoming\n" + ), + "unexpected sections: {:?}", + diff.sections + ); + } + + #[test] + fn binary_conflict_is_kept_as_a_whole_side_decision() { + let repo = TempRepo::init(); + repo.write_file_bytes("image.bin", b"base\0bytes"); + repo.stage("image.bin"); + repo.commit("base"); + assert!(run_git(&repo, &["checkout", "-b", "incoming"]) + .status + .success()); + repo.write_file_bytes("image.bin", b"incoming\0bytes"); + repo.stage("image.bin"); + repo.commit("incoming binary"); + assert!(run_git(&repo, &["checkout", "main"]).status.success()); + repo.write_file_bytes("image.bin", b"current\0bytes"); + repo.stage("image.bin"); + repo.commit("current binary"); + assert!(!run_git(&repo, &["merge", "incoming"]).status.success()); + + let diff = compute_three_way_conflict_diff(repo.path(), Path::new("image.bin")).unwrap(); + assert!(diff.is_binary); + assert!(!diff.supports_text_resolution()); + assert_eq!(diff.conflict_count(), 1); + } + + #[test] + fn gitlink_conflict_uses_commit_ids_without_blob_lookup() { + let repo = TempRepo::init(); + let ancestor = repo.commit_file("seed.txt", "ancestor\n", "ancestor"); + let ours = repo.commit_file("seed.txt", "ours\n", "ours"); + let theirs = repo.commit_file("seed.txt", "theirs\n", "theirs"); + install_gitlink_conflict(&repo, "module", ancestor, ours, theirs); + + let diff = compute_three_way_conflict_diff(repo.path(), Path::new("module")).unwrap(); + + assert!(diff.is_special_file); + assert!(!diff.supports_text_resolution()); + assert_eq!(diff.result_mode, 0o160000); + assert!(matches!( + &diff.sections[0], + MergeSection::Conflict { + ancestor: actual_ancestor, + ours: actual_ours, + theirs: actual_theirs, + } if actual_ancestor == ancestor.to_string().as_bytes() + && actual_ours == ours.to_string().as_bytes() + && actual_theirs == theirs.to_string().as_bytes() + )); + } +} + +// ── Integration-level diff tests ──────────────────────────────── + +#[cfg(test)] +mod diff_integration_tests { + use super::*; + use rgitui_test_support::TempRepo; + use std::path::Path; + + /// A repo with two commits touching `hello.txt`; `HEAD` is the second. + /// File content: + /// initial → "line1\nline2\nline3\n" + /// amended → "line1\nLINE2_CHANGED\nline3\n" + fn make_two_commit_repo() -> TempRepo { + let fixture = TempRepo::init(); + fixture.commit_file("hello.txt", "line1\nline2\nline3\n", "initial"); + fixture.commit_file("hello.txt", "line1\nLINE2_CHANGED\nline3\n", "change line2"); + fixture + } + + /// Build a repo where one file is staged (index differs from HEAD). + fn make_staged_change_repo() -> TempRepo { + let fixture = TempRepo::init(); + fixture.commit_file("data.txt", "alpha\nbeta\ngamma\n", "initial"); + + // Modify the file and stage it — don't commit. + fixture.write_file("data.txt", "alpha\nbeta_modified\ngamma\n"); + fixture.stage("data.txt"); + fixture + } + + // ── compute_commit_diff ─────────────────────────────────────── + + #[test] + fn commit_diff_returns_changed_file() { + let fixture = make_two_commit_repo(); + let diff = compute_commit_diff(fixture.path(), fixture.head_oid()).unwrap(); + assert_eq!(diff.files.len(), 1, "should have exactly one changed file"); + assert_eq!(diff.files[0].path, Path::new("hello.txt")); + } + + #[test] + fn commit_diff_counts_additions_and_deletions() { + let fixture = make_two_commit_repo(); + let diff = compute_commit_diff(fixture.path(), fixture.head_oid()).unwrap(); + // "line2" → "LINE2_CHANGED": one deletion + one addition + assert_eq!(diff.total_additions, 1); + assert_eq!(diff.total_deletions, 1); + } + + #[test] + fn commit_diff_hunk_has_lines() { + let fixture = make_two_commit_repo(); + let diff = compute_commit_diff(fixture.path(), fixture.head_oid()).unwrap(); + let file = &diff.files[0]; + assert!(!file.hunks.is_empty(), "should have at least one hunk"); + let hunk = &file.hunks[0]; + // Should contain an addition and a deletion + let has_addition = hunk + .lines + .iter() + .any(|l| matches!(l, DiffLine::Addition(_))); + let has_deletion = hunk + .lines + .iter() + .any(|l| matches!(l, DiffLine::Deletion(_))); + assert!(has_addition, "hunk should have an addition line"); + assert!(has_deletion, "hunk should have a deletion line"); + } + + #[test] + fn commit_diff_invalid_oid_returns_err() { + let fixture = make_two_commit_repo(); + let fake_oid = git2::Oid::from_str("0000000000000000000000000000000000000000").unwrap(); + assert!(compute_commit_diff(fixture.path(), fake_oid).is_err()); + } + + #[test] + fn commit_diff_first_commit_no_parent() { + // The first commit has no parent; compute_commit_diff should handle it + let fixture = TempRepo::init(); + let oid = fixture.commit_file("f.txt", "hello\n", "root"); + let diff = compute_commit_diff(fixture.path(), oid).unwrap(); + // Root commit: diff against empty tree → f.txt is added + assert_eq!(diff.files.len(), 1); + assert!(matches!(diff.files[0].kind, FileChangeKind::Added)); + } + + // ── compute_file_diff (unstaged) ───────────────────────────── + + #[test] + fn file_diff_unstaged_no_changes() { + let fixture = TempRepo::init(); + fixture.commit_file("clean.txt", "no changes\n", "init"); + // No modifications — should return empty hunk list + let diff = compute_file_diff(fixture.path(), Path::new("clean.txt"), false).unwrap(); + assert!(diff.hunks.is_empty(), "no unstaged changes expected"); + } + + // ── compute_staged_diff_text ────────────────────────────────── + + #[test] + fn staged_diff_text_contains_change() { + let fixture = make_staged_change_repo(); + let text = compute_staged_diff_text(fixture.path()).unwrap(); + assert!( + text.contains("beta_modified") || text.contains("+beta_modified"), + "staged diff should include modified content" + ); + assert!( + text.contains("-beta") || text.contains("beta"), + "staged diff should include original content" + ); + } + + #[test] + fn staged_diff_text_has_diff_markers() { + let fixture = make_staged_change_repo(); + let text = compute_staged_diff_text(fixture.path()).unwrap(); + // compute_staged_diff_text prefixes each line with origin char + assert!( + text.contains('+') || text.contains('-'), + "should have diff markers" + ); + } + + // ── batch_diff_stats ───────────────────────────────────────── + + #[test] + fn batch_diff_stats_staged_detects_changed_file() { + let fixture = make_staged_change_repo(); + let stats = batch_diff_stats(fixture.repo(), true); + assert!( + stats.contains_key(Path::new("data.txt")), + "staged stats should include data.txt" + ); + let (adds, dels) = stats[Path::new("data.txt")]; + assert_eq!(adds, 1, "one line added"); + assert_eq!(dels, 1, "one line deleted"); + } + + #[test] + fn batch_diff_stats_unstaged_empty_when_clean() { + let fixture = TempRepo::init(); + fixture.commit_file("x.txt", "clean\n", "init"); + let stats = batch_diff_stats(fixture.repo(), false); + // Working tree matches index → no unstaged changes + assert!(stats.is_empty(), "no unstaged changes on clean repo"); + } + // ── parse_multi_file_diff ───────────────────────────────────── + + #[test] + fn parse_multi_file_diff_aggregates_files() { + let fixture = make_two_commit_repo(); + let repo = fixture.repo(); + let commit = repo.find_commit(fixture.head_oid()).unwrap(); + let tree = commit.tree().unwrap(); + let parent = commit.parent(0).unwrap(); + let parent_tree = parent.tree().unwrap(); + let diff = repo + .diff_tree_to_tree(Some(&parent_tree), Some(&tree), None) + .unwrap(); + let result = parse_multi_file_diff(&diff).unwrap(); + assert_eq!(result.files.len(), 1); + assert_eq!(result.total_additions, 1); + assert_eq!(result.total_deletions, 1); + } + + // ── emitted_sign ────────────────────────────────────────────── + + #[test] + fn emitted_sign_staging_preserves_signs() { + // Staging (staged=false) applies an index→workdir patch to the index, so + // signs are preserved: a workdir addition stays '+', a deletion stays '-'. + assert_eq!(emitted_sign('+', false), Some('+')); + assert_eq!(emitted_sign('-', false), Some('-')); + assert_eq!(emitted_sign(' ', false), Some(' ')); + assert_eq!(emitted_sign('=', false), None); + } + + #[test] + fn emitted_sign_unstaging_negates_signs() { + // Unstaging (staged=true) applies a HEAD→index patch to the index, so signs + // are negated: an index addition becomes '-' (removed), a deletion '+'. + assert_eq!(emitted_sign('+', true), Some('-')); + assert_eq!(emitted_sign('-', true), Some('+')); + assert_eq!(emitted_sign(' ', true), Some(' ')); + assert_eq!(emitted_sign('>', true), None); + } + + // ── generate_line_patch_for_repo tests ─────────────────────────────────── + + /// End-to-end check of the viewer→git contract for unstaging. + /// + /// The diff viewer emits an addition as `(None, Some(new_lineno))`. For the + /// staged diff that turns "beta" into "beta_modified", the addition + /// "+beta_modified" has new_lineno=2, so the viewer sends `(None, Some(2))`. + /// Unstaging must negate that addition into a "-beta_modified" deletion so the + /// applied patch removes the line from the index. + /// + /// This is the contract BUG-14 corrected: the previous code matched additions + /// against the `old` slot, which is always `None` for viewer-emitted additions, + /// silently producing a context-only no-op patch. + #[test] + fn hunk_patch_stages_and_parses_as_a_complete_patch() { + let fixture = make_staged_change_repo(); + let repo = fixture.repo(); + + let head_tree = repo.head().unwrap().peel_to_tree().unwrap(); + let mut index = repo.index().unwrap(); + index.read_tree(&head_tree).unwrap(); + index.write().unwrap(); + + let patch_text = + generate_hunk_patch_for_repo(repo, Path::new("data.txt"), 0, false).unwrap(); + assert!(patch_text.starts_with("diff --git ")); + let diff = git2::Diff::from_buffer(patch_text.as_bytes()).unwrap(); + repo.apply(&diff, git2::ApplyLocation::Index, None).unwrap(); + } + + #[test] + fn hunk_patch_unstaging_reverses_the_change() { + let fixture = make_staged_change_repo(); + let repo = fixture.repo(); + + let patch_text = + generate_hunk_patch_for_repo(repo, Path::new("data.txt"), 0, true).unwrap(); + assert!(patch_text.contains("-beta_modified\n")); + assert!(patch_text.contains("+beta\n")); + let diff = git2::Diff::from_buffer(patch_text.as_bytes()).unwrap(); + repo.apply(&diff, git2::ApplyLocation::Index, None).unwrap(); + + let head_tree = repo.head().unwrap().peel_to_tree().unwrap(); + let staged = repo + .diff_tree_to_index(Some(&head_tree), None, None) + .unwrap(); + assert_eq!(staged.deltas().len(), 0); + } + + #[test] + fn line_patch_unstage_addition_negates_to_deletion() { + let fixture = make_staged_change_repo(); + let repo = fixture.repo(); + + // Viewer-produced pair for the "+beta_modified" addition (new_lineno=2). + let line_pairs = vec![(None, Some(2usize))]; + let patch_text = + generate_line_patch_for_repo(repo, Path::new("data.txt"), &line_pairs, true).unwrap(); + + assert!( + patch_text.contains("-beta_modified\n"), + "unstaging should negate the addition into '-beta_modified', got:\n{patch_text}" + ); + assert!( + !patch_text.contains("+beta_modified\n"), + "the addition must not be emitted with its original '+' sign, got:\n{patch_text}" + ); + + // The patch is applied to the index, whose content is the pre-image. Only a + // correct old_start/new_start swap (BUG-13) yields a patch git will accept; + // a text-only check cannot catch an off-by-one in those positions. + let diff = git2::Diff::from_buffer(patch_text.as_bytes()).unwrap(); + repo.apply(&diff, git2::ApplyLocation::Index, None).unwrap(); + } + + /// A deletion selected for unstaging (viewer pair `(Some(old_lineno), None)`) + /// must be negated into a restoring "+" line. + #[test] + fn line_patch_unstage_deletion_negates_to_addition() { + let fixture = make_staged_change_repo(); + let repo = fixture.repo(); + + // "-beta" deletion in the staged diff has old_lineno=2. + let line_pairs = vec![(Some(2usize), None)]; + let patch_text = + generate_line_patch_for_repo(repo, Path::new("data.txt"), &line_pairs, true).unwrap(); + + assert!( + patch_text.contains("+beta\n"), + "unstaging should negate the deletion into '+beta', got:\n{patch_text}" + ); + } + + /// Staging a workdir addition preserves its '+' sign so applying the patch adds + /// the line to the index. Build an unstaged-only addition and stage it. + #[test] + fn line_patch_stage_addition_preserves_sign() { + let fixture = TempRepo::init(); + fixture.commit_file("data.txt", "alpha\ngamma\n", "init"); + + // Insert "beta" in the working tree only (index→workdir addition at new line 2). + fixture.write_file("data.txt", "alpha\nbeta\ngamma\n"); + + let repo = fixture.repo(); + let line_pairs = vec![(None, Some(2usize))]; + let patch_text = + generate_line_patch_for_repo(repo, Path::new("data.txt"), &line_pairs, false).unwrap(); + + assert!( + patch_text.contains("+beta\n"), + "staging should preserve the '+beta' addition, got:\n{patch_text}" + ); + + // The generated patch must apply cleanly to the index. + let diff = git2::Diff::from_buffer(patch_text.as_bytes()).unwrap(); + repo.apply(&diff, git2::ApplyLocation::Index, None).unwrap(); + } + + /// Appending a line at the end of a long file makes the last matched line an + /// addition (old_lineno = None) inside a hunk that starts well past line 1. + /// The old `last_lineno + 1 - hunk_old_start` math underflowed u32 here + /// (BUG-13); counting emitted lines instead must produce an applicable patch. + #[test] + fn line_patch_stage_trailing_addition_no_underflow() { + let fixture = TempRepo::init(); + let base: String = (1..=20).map(|i| format!("line{i}\n")).collect(); + fixture.commit_file("data.txt", &base, "init"); + + // Append a line at the end; its hunk starts past line 1 and ends on the + // addition itself (old_lineno = None for that line). + fixture.write_file("data.txt", &format!("{base}appended\n")); + + // The appended line sits at new line 21. + let repo = fixture.repo(); + let line_pairs = vec![(None, Some(21usize))]; + let patch_text = + generate_line_patch_for_repo(repo, Path::new("data.txt"), &line_pairs, false).unwrap(); + + assert!( + patch_text.contains("+appended\n"), + "patch should contain the appended line, got:\n{patch_text}" + ); + // Underflow would yield "@@ -N,4294967292 ... @@"; assert no such count. + assert!( + !patch_text.contains("4294967292"), + "hunk header must not carry an underflowed count, got:\n{patch_text}" + ); + let diff = git2::Diff::from_buffer(patch_text.as_bytes()).unwrap(); + repo.apply(&diff, git2::ApplyLocation::Index, None).unwrap(); + } +} diff --git a/third_party/rgitui/crates/rgitui_git/src/project/file_history.rs b/third_party/rgitui/crates/rgitui_git/src/project/file_history.rs new file mode 100644 index 000000000..83ddd57af --- /dev/null +++ b/third_party/rgitui/crates/rgitui_git/src/project/file_history.rs @@ -0,0 +1,236 @@ +use anyhow::Result; +use chrono::{TimeZone, Utc}; +use git2::Repository; +use std::path::Path; + +use crate::types::{CommitInfo, Signature}; + +use super::refresh::clean_co_author_lines; + +/// Maximum number of commits to return for file history. +const MAX_FILE_HISTORY: usize = 100; + +/// Get commit history for a specific file. +/// +/// Uses `git log -- ` which leverages git's built-in pathspec +/// optimization (tree-diff pruning) and is orders of magnitude faster +/// than walking all commits and diffing each tree manually. +pub fn compute_file_history( + repo_path: &Path, + file_path: &Path, + limit: usize, +) -> Result> { + compute_file_history_at(repo_path, file_path, limit, None) +} + +/// Get commit history for a file, optionally stopping at a selected commit. +/// +/// Supplying a start commit keeps history consistent with a commit diff instead +/// of accidentally showing changes that happened later on the current branch. +pub fn compute_file_history_at( + repo_path: &Path, + file_path: &Path, + limit: usize, + start_oid: Option, +) -> Result> { + let limit = limit.min(MAX_FILE_HISTORY); + let file_str = file_path.to_string_lossy(); + + let mut args = vec![ + "log".to_string(), + "--format=%H".to_string(), + format!("-{}", limit), + ]; + if let Some(oid) = start_oid { + args.push(oid.to_string()); + } + args.push("--".to_string()); + args.push(file_str.into_owned()); + + let output = super::git_command() + .args(&args) + .current_dir(repo_path) + .output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git log failed: {}", stderr.trim()); + } + + let repo = Repository::open(repo_path)?; + let stdout = String::from_utf8_lossy(&output.stdout); + let mut commits = Vec::new(); + + for line in stdout.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let oid = git2::Oid::from_str(line)?; + let commit = repo.find_commit(oid)?; + + let author = commit.author(); + let committer = commit.committer(); + let time = Utc.timestamp_opt(commit.time().seconds(), 0).single(); + + let raw_message = commit.message().unwrap_or("").to_string(); + let message = clean_co_author_lines(&raw_message); + + commits.push(CommitInfo { + oid, + short_id: format!("{:.7}", oid), + summary: commit.summary().unwrap_or("").to_string(), + message, + author: Signature { + name: author.name().unwrap_or("").to_string(), + email: author.email().unwrap_or("").to_string(), + }, + committer: Signature { + name: committer.name().unwrap_or("").to_string(), + email: committer.email().unwrap_or("").to_string(), + }, + co_authors: Vec::new(), + time: time.unwrap_or_else(Utc::now), + parent_oids: commit.parent_ids().collect(), + refs: Vec::new(), + is_signed: false, + }); + } + + Ok(commits) +} + +use gpui::{AsyncApp, Context, Task, WeakEntity}; + +use super::GitProject; + +impl GitProject { + /// Get commit history for a specific file. + pub fn file_history(&self, file_path: &Path, limit: usize) -> Result> { + compute_file_history(self.repo_path(), file_path, limit) + } + + /// Get commit history for a file asynchronously. + pub fn file_history_async( + &self, + file_path: &Path, + limit: usize, + cx: &mut Context, + ) -> Task>> { + let repo_path = self.repo_path().to_path_buf(); + let file_path = file_path.to_path_buf(); + cx.spawn(async move |_this: WeakEntity, cx: &mut AsyncApp| { + cx.background_executor() + .spawn(async move { compute_file_history(&repo_path, &file_path, limit) }) + .await + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rgitui_test_support::TempRepo; + use std::path::Path; + + // Build a repo where: + // - "tracked.txt" is created in commit 1, modified in commit 3 + // - "other.txt" is created in commit 2 + fn make_multi_file_repo() -> TempRepo { + let repo = TempRepo::init(); + repo.commit_file("tracked.txt", "hello\n", "add tracked.txt"); + repo.commit_file("other.txt", "world\n", "add other.txt"); + repo.commit_file("tracked.txt", "hello updated\n", "modify tracked.txt"); + repo + } + + #[test] + fn file_history_returns_only_touching_commits() { + let repo = make_multi_file_repo(); + let entries = compute_file_history(repo.path(), Path::new("tracked.txt"), 100).unwrap(); + // commit 1 (add) + commit 3 (modify) touch tracked.txt; commit 2 does not + assert_eq!(entries.len(), 2, "expected 2 commits for tracked.txt"); + } + + #[test] + fn file_history_unrelated_file_excluded() { + let repo = make_multi_file_repo(); + let entries = compute_file_history(repo.path(), Path::new("other.txt"), 100).unwrap(); + // Only commit 2 touches other.txt + assert_eq!(entries.len(), 1, "expected 1 commit for other.txt"); + } + + #[test] + fn file_history_latest_commit_first() { + let repo = make_multi_file_repo(); + let entries = compute_file_history(repo.path(), Path::new("tracked.txt"), 100).unwrap(); + // The most recent touching commit is commit 3 ("modify tracked.txt") + assert!( + entries[0].summary.contains("modify"), + "first entry should be the latest modification, got: {:?}", + entries[0].summary + ); + } + + #[test] + fn file_history_at_commit_excludes_later_changes() { + let repo = make_multi_file_repo(); + let before_latest = repo.repo().revparse_single("HEAD~1").unwrap().id(); + + let entries = compute_file_history_at( + repo.path(), + Path::new("tracked.txt"), + 100, + Some(before_latest), + ) + .unwrap(); + + assert_eq!(entries.len(), 1); + assert!(entries[0].summary.contains("add tracked")); + } + + #[test] + fn file_history_limit_respected() { + let repo = make_multi_file_repo(); + // tracked.txt has 2 touching commits; requesting limit=1 should return 1 + let entries = compute_file_history(repo.path(), Path::new("tracked.txt"), 1).unwrap(); + assert_eq!(entries.len(), 1); + } + + #[test] + fn file_history_author_populated() { + let repo = make_multi_file_repo(); + let entries = compute_file_history(repo.path(), Path::new("tracked.txt"), 100).unwrap(); + assert!(!entries.is_empty()); + assert_eq!(entries[0].author.name, TempRepo::AUTHOR_NAME); + assert_eq!(entries[0].author.email, TempRepo::AUTHOR_EMAIL); + } + + #[test] + fn file_history_short_id_is_seven_chars() { + let repo = make_multi_file_repo(); + let entries = compute_file_history(repo.path(), Path::new("tracked.txt"), 100).unwrap(); + for entry in &entries { + assert_eq!( + entry.short_id.len(), + 7, + "short_id should be 7 chars, got {:?}", + entry.short_id + ); + } + } + + #[test] + fn file_history_nonexistent_file_returns_empty() { + let repo = make_multi_file_repo(); + let entries = + compute_file_history(repo.path(), Path::new("does_not_exist.txt"), 100).unwrap(); + assert!(entries.is_empty(), "unknown file should yield 0 entries"); + } + + #[test] + fn file_history_err_on_bad_repo() { + let result = compute_file_history(Path::new("/no/such/repo"), Path::new("file.txt"), 10); + assert!(result.is_err()); + } +} diff --git a/third_party/rgitui/crates/rgitui_git/src/project/history_cache.rs b/third_party/rgitui/crates/rgitui_git/src/project/history_cache.rs new file mode 100644 index 000000000..14db14c30 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_git/src/project/history_cache.rs @@ -0,0 +1,419 @@ +use anyhow::{Context as _, Result}; +use chrono::{TimeZone, Utc}; +use git2::{Oid, Repository}; +use serde::{Deserialize, Serialize}; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::{CommitInfo, RefLabel, Signature}; + +const SCHEMA_VERSION: u32 = 1; +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +pub(super) struct HydratedHistory { + pub commits: Vec, + pub has_more_commits: bool, + pub default_branch: Option, +} + +#[derive(Serialize, Deserialize)] +struct CacheFile { + schema_version: u32, + repo_identity: String, + ref_fingerprint: u64, + has_more_commits: bool, + default_branch: Option, + commits: Vec, +} + +#[derive(Serialize, Deserialize)] +struct CachedCommit { + oid: String, + short_id: String, + summary: String, + message: String, + author: CachedSignature, + committer: CachedSignature, + co_authors: Vec, + timestamp: i64, + parent_oids: Vec, + refs: Vec, + is_signed: bool, +} + +#[derive(Serialize, Deserialize)] +struct CachedSignature { + name: String, + email: String, +} + +#[derive(Serialize, Deserialize)] +enum CachedRef { + Head, + LocalBranch(String), + RemoteBranch(String), + Tag(String), +} + +fn repo_identity_and_fingerprint(repo_path: &Path) -> Result<(String, u64)> { + let repo = Repository::open(repo_path)?; + let identity_path = repo + .commondir() + .canonicalize() + .unwrap_or_else(|_| repo.commondir().to_path_buf()); + let identity = identity_path.to_string_lossy().into_owned(); + let mut refs = Vec::new(); + if let Ok(iter) = repo.references() { + for reference in iter.flatten() { + let name = reference.name().unwrap_or("").to_owned(); + if name.starts_with("refs/heads/") + || name.starts_with("refs/remotes/") + || name.starts_with("refs/tags/") + { + refs.push((name, reference.target().map(|oid| oid.to_string()))); + } + } + } + refs.sort(); + let mut hasher = DefaultHasher::new(); + let head = repo.head().ok(); + head.as_ref() + .and_then(|head| head.target()) + .hash(&mut hasher); + head.as_ref().and_then(|head| head.name()).hash(&mut hasher); + repo.head_detached().unwrap_or(false).hash(&mut hasher); + refs.hash(&mut hasher); + Ok((identity, hasher.finish())) +} + +pub(super) fn ref_fingerprint(repo_path: &Path) -> Result { + repo_identity_and_fingerprint(repo_path).map(|(_, fingerprint)| fingerprint) +} + +fn cache_path(repo_path: &Path) -> Result<(PathBuf, String, u64)> { + let (identity, fingerprint) = repo_identity_and_fingerprint(repo_path)?; + let mut identity_hasher = DefaultHasher::new(); + identity.hash(&mut identity_hasher); + let root = rgitui_settings::cache_dir().join("history"); + let path = root.join(format!( + "v{}-{:016x}-{:016x}.json", + SCHEMA_VERSION, + identity_hasher.finish(), + fingerprint + )); + Ok((path, identity, fingerprint)) +} + +pub(super) fn load(repo_path: &Path, limit: usize) -> Option { + let (path, identity, fingerprint) = cache_path(repo_path).ok()?; + let bytes = std::fs::read(&path).ok()?; + let parsed: CacheFile = match serde_json::from_slice(&bytes) { + Ok(parsed) => parsed, + Err(_) => { + let _ = std::fs::remove_file(path); + return None; + } + }; + hydrate(parsed, &identity, fingerprint, limit) +} + +fn hydrate( + parsed: CacheFile, + identity: &str, + fingerprint: u64, + limit: usize, +) -> Option { + if parsed.schema_version != SCHEMA_VERSION + || parsed.repo_identity != identity + || parsed.ref_fingerprint != fingerprint + { + return None; + } + let mut commits: Vec = parsed + .commits + .into_iter() + .map(CachedCommit::into_commit) + .collect::>>()?; + let truncated = commits.len() > limit; + commits.truncate(limit); + Some(HydratedHistory { + commits, + has_more_commits: parsed.has_more_commits || truncated, + default_branch: parsed.default_branch, + }) +} + +pub(super) fn store( + repo_path: &Path, + expected_fingerprint: u64, + commits: &[CommitInfo], + has_more_commits: bool, + default_branch: Option<&str>, +) -> Result<()> { + let (path, identity, fingerprint) = cache_path(repo_path)?; + if fingerprint != expected_fingerprint { + anyhow::bail!("Repository refs changed while history snapshot was gathered"); + } + if path.exists() { + return Ok(()); + } + let cache = CacheFile { + schema_version: SCHEMA_VERSION, + repo_identity: identity, + ref_fingerprint: fingerprint, + has_more_commits, + default_branch: default_branch.map(str::to_owned), + commits: commits.iter().map(CachedCommit::from_commit).collect(), + }; + let bytes = serde_json::to_vec(&cache)?; + let parent = path.parent().context("Cache path has no parent")?; + std::fs::create_dir_all(parent)?; + let temp = path.with_extension(format!( + "tmp-{}-{}", + std::process::id(), + TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + std::fs::write(&temp, bytes)?; + match std::fs::rename(&temp, &path) { + Ok(()) => { + prune_old_generations(&path); + Ok(()) + } + Err(_error) if path.exists() => { + let _ = std::fs::remove_file(temp); + Ok(()) + } + Err(error) => { + let _ = std::fs::remove_file(temp); + Err(error).context("Failed to atomically publish history cache") + } + } +} + +fn prune_old_generations(current: &Path) { + let Some(parent) = current.parent() else { + return; + }; + let Some(name) = current.file_name().and_then(|name| name.to_str()) else { + return; + }; + let mut parts = name.split('-'); + let Some(version) = parts.next() else { return }; + let Some(identity) = parts.next() else { return }; + let prefix = format!("{version}-{identity}-"); + if let Ok(entries) = std::fs::read_dir(parent) { + for entry in entries.flatten() { + let path = entry.path(); + if path != current + && path + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.starts_with(&prefix) && value.ends_with(".json")) + { + let _ = std::fs::remove_file(path); + } + } + } +} + +impl CachedCommit { + fn from_commit(commit: &CommitInfo) -> Self { + Self { + oid: commit.oid.to_string(), + short_id: commit.short_id.clone(), + summary: commit.summary.clone(), + message: commit.message.clone(), + author: CachedSignature::from_signature(&commit.author), + committer: CachedSignature::from_signature(&commit.committer), + co_authors: commit + .co_authors + .iter() + .map(CachedSignature::from_signature) + .collect(), + timestamp: commit.time.timestamp(), + parent_oids: commit.parent_oids.iter().map(ToString::to_string).collect(), + refs: commit.refs.iter().map(CachedRef::from_ref).collect(), + is_signed: commit.is_signed, + } + } + + fn into_commit(self) -> Option { + Some(CommitInfo { + oid: Oid::from_str(&self.oid).ok()?, + short_id: self.short_id, + summary: self.summary, + message: self.message, + author: self.author.into_signature(), + committer: self.committer.into_signature(), + co_authors: self + .co_authors + .into_iter() + .map(CachedSignature::into_signature) + .collect(), + time: Utc.timestamp_opt(self.timestamp, 0).single()?, + parent_oids: self + .parent_oids + .iter() + .map(|oid| Oid::from_str(oid).ok()) + .collect::>>()?, + refs: self.refs.into_iter().map(CachedRef::into_ref).collect(), + is_signed: self.is_signed, + }) + } +} + +impl CachedSignature { + fn from_signature(signature: &Signature) -> Self { + Self { + name: signature.name.clone(), + email: signature.email.clone(), + } + } + fn into_signature(self) -> Signature { + Signature { + name: self.name, + email: self.email, + } + } +} + +impl CachedRef { + fn from_ref(label: &RefLabel) -> Self { + match label { + RefLabel::Head => Self::Head, + RefLabel::LocalBranch(name) => Self::LocalBranch(name.clone()), + RefLabel::RemoteBranch(name) => Self::RemoteBranch(name.clone()), + RefLabel::Tag(name) => Self::Tag(name.clone()), + } + } + fn into_ref(self) -> RefLabel { + match self { + Self::Head => RefLabel::Head, + Self::LocalBranch(name) => RefLabel::LocalBranch(name), + Self::RemoteBranch(name) => RefLabel::RemoteBranch(name), + Self::Tag(name) => RefLabel::Tag(name), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rgitui_test_support::TempRepo; + use std::time::{Duration, Instant}; + + fn commit(index: usize) -> CommitInfo { + let oid = Oid::from_bytes(&[index as u8; 20]).unwrap(); + CommitInfo { + oid, + short_id: oid.to_string()[..7].to_owned(), + summary: format!("commit {index}"), + message: format!("commit {index}\n\nbody"), + author: Signature { + name: "Test".into(), + email: "test@example.com".into(), + }, + committer: Signature { + name: "Test".into(), + email: "test@example.com".into(), + }, + co_authors: Vec::new(), + time: Utc.timestamp_opt(index as i64, 0).single().unwrap(), + parent_oids: Vec::new(), + refs: if index == 0 { + vec![RefLabel::Head] + } else { + Vec::new() + }, + is_signed: false, + } + } + + fn encoded(count: usize, fingerprint: u64) -> Vec { + let file = CacheFile { + schema_version: SCHEMA_VERSION, + repo_identity: "repo".into(), + ref_fingerprint: fingerprint, + has_more_commits: true, + default_branch: Some("main".into()), + commits: (0..count) + .map(|index| CachedCommit::from_commit(&commit(index))) + .collect(), + }; + serde_json::to_vec(&file).unwrap() + } + + #[test] + fn cache_round_trip_hydrates_commit_metadata() { + let parsed: CacheFile = serde_json::from_slice(&encoded(3, 7)).unwrap(); + let hydrated = hydrate(parsed, "repo", 7, 10).unwrap(); + assert_eq!(hydrated.commits.len(), 3); + assert_eq!(hydrated.commits[0].summary, "commit 0"); + assert!(matches!( + hydrated.commits[0].refs.as_slice(), + [RefLabel::Head] + )); + assert_eq!(hydrated.default_branch.as_deref(), Some("main")); + } + + #[test] + fn corrupt_cache_falls_back_silently() { + assert!(serde_json::from_slice::(b"not json").is_err()); + } + + #[test] + fn ref_fingerprint_and_schema_invalidate_cache() { + let parsed: CacheFile = serde_json::from_slice(&encoded(1, 7)).unwrap(); + assert!(hydrate(parsed, "repo", 8, 10).is_none()); + let mut parsed: CacheFile = serde_json::from_slice(&encoded(1, 7)).unwrap(); + parsed.schema_version += 1; + assert!(hydrate(parsed, "repo", 7, 10).is_none()); + } + + #[test] + fn fingerprint_changes_when_head_branch_name_changes_at_same_oid() { + let fixture = TempRepo::init(); + let oid = fixture.commit("initial"); + let repo = fixture.repo(); + repo.reference("refs/heads/other", oid, true, "test") + .unwrap(); + let main = ref_fingerprint(fixture.path()).unwrap(); + repo.set_head("refs/heads/other").unwrap(); + let other = ref_fingerprint(fixture.path()).unwrap(); + assert_ne!(main, other); + } + + #[test] + fn invalid_parent_oid_rejects_entire_cache() { + let mut parsed: CacheFile = serde_json::from_slice(&encoded(2, 7)).unwrap(); + parsed.commits[1].parent_oids.push("not-an-oid".into()); + assert!(hydrate(parsed, "repo", 7, 10).is_none()); + } + + #[test] + fn pruning_keeps_only_current_generation_for_repo() { + let temp = tempfile::TempDir::new().unwrap(); + let old = temp.path().join("v1-abc-111.json"); + let current = temp.path().join("v1-abc-222.json"); + let unrelated = temp.path().join("v1-def-111.json"); + std::fs::write(&old, b"old").unwrap(); + std::fs::write(¤t, b"current").unwrap(); + std::fs::write(&unrelated, b"other").unwrap(); + prune_old_generations(¤t); + assert!(!old.exists()); + assert!(current.exists()); + assert!(unrelated.exists()); + } + + #[test] + fn hydration_of_one_thousand_commits_is_fast() { + let bytes = encoded(1_000, 7); + let started = Instant::now(); + let parsed: CacheFile = serde_json::from_slice(&bytes).unwrap(); + let hydrated = hydrate(parsed, "repo", 7, 1_000).unwrap(); + assert_eq!(hydrated.commits.len(), 1_000); + assert!(started.elapsed() < Duration::from_secs(2)); + } +} diff --git a/third_party/rgitui/crates/rgitui_git/src/project/local_ops.rs b/third_party/rgitui/crates/rgitui_git/src/project/local_ops.rs new file mode 100644 index 000000000..57cc804e6 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_git/src/project/local_ops.rs @@ -0,0 +1,8152 @@ +use anyhow::{Context as _, Result}; +use git2::Repository; +use gpui::{AsyncApp, Context, Task, WeakEntity}; +use std::io::{Read as _, Seek as _, Write as _}; +use std::path::{Path, PathBuf}; + +use rgitui_settings::current_git_auth_runtime; + +use crate::types::*; + +use super::auth::inject_https_credentials; +use super::refresh::{gather_refresh_data, gather_refresh_data_lightweight_cached}; +use super::worktree_patch::{clean_worktree_bytes, smudge_canonical_bytes}; +use super::{ensure_clean_worktree, head_branch_name, GitProject, GitProjectEvent, RefreshData}; + +// TODO(audit): deferred audit items for this module (tracked, not yet applied): +// - QUAL-02: the ~39 copy-pasted begin/spawn/apply/complete blocks should collapse +// into one generic job-dispatch helper (cf. Zed `git_store::send_job`). +// - QUAL-05: cache an `Arc>` instead of `Repository::open` +// per call — conflicts with the deliberate per-thread opens in refresh's +// thread::scope, so needs a design pass. +// - BUG-41: `discard_changes_at` directory handling — verifier judged the current +// code correct (untracked dirs take the remove_dir_all branch); left unchanged. + +/// Commit the resolved merge in `repo` with its stored `MERGE_MSG`, exactly as +/// `git merge --continue` does: the index is committed as the user staged it, +/// with `MERGE_HEAD`'s commits as the extra parents. Untracked and unstaged +/// working-tree changes are deliberately left out of the merge commit. +fn finish_merge_commit(repo: &Repository) -> Result { + let merge_head_path = repo.path().join("MERGE_HEAD"); + if !merge_head_path.exists() { + anyhow::bail!("Repository is not in a merge state (no MERGE_HEAD to continue)."); + } + + let mut index = repo.index()?; + let tree_oid = index.write_tree()?; + let tree = repo.find_tree(tree_oid)?; + + let sig = repo.signature()?; + let head_commit = repo.head()?.peel_to_commit()?; + + let merge_msg_path = repo.path().join("MERGE_MSG"); + let message = if merge_msg_path.exists() { + std::fs::read_to_string(&merge_msg_path).unwrap_or_else(|_| "Merge commit".to_string()) + } else { + "Merge commit".to_string() + }; + + let mut parents = vec![head_commit]; + let contents = std::fs::read_to_string(&merge_head_path)?; + for line in contents.lines() { + let line = line.trim(); + if !line.is_empty() { + let oid = git2::Oid::from_str(line)?; + parents.push(repo.find_commit(oid)?); + } + } + + let parent_refs: Vec<&git2::Commit> = parents.iter().collect(); + repo.commit(Some("HEAD"), &sig, &sig, &message, &tree, &parent_refs)?; + repo.cleanup_state()?; + + Ok(message.lines().next().unwrap_or("Merge commit").to_string()) +} + +/// Run `git --continue` in `worktree_path`. +/// +/// libgit2 has no sequencer, so cherry-pick, revert, mailbox application and +/// rebase can only be carried forward by the git CLI. Each of those accepts +/// `--continue` on its own and rejects any other flag alongside it, so the +/// editor is suppressed through the environment rather than with `--no-edit`; +/// the message git already stored for the stopped step is reused as-is. +fn run_continue_subcommand(worktree_path: &Path, subcommand: &str) -> Result { + let output = super::git_command() + .current_dir(worktree_path) + .args([subcommand, "--continue"]) + .env("GIT_EDITOR", "true") + .env("GIT_SEQUENCE_EDITOR", "true") + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .with_context(|| format!("Failed to execute git {} --continue", subcommand))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + if !output.status.success() { + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + anyhow::bail!("git {} --continue failed: {}", subcommand, detail); + } + + let summary = stdout + .lines() + .chain(stderr.lines()) + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or("Operation continued") + .to_string(); + Ok(summary) +} + +/// Renders up to three paths inline, summarising the rest, for error messages. +fn format_path_list(paths: &[String]) -> String { + const SHOWN: usize = 3; + let shown = paths.len().min(SHOWN); + let mut list = paths[..shown].join(", "); + if paths.len() > shown { + list.push_str(&format!(" and {} more", paths.len() - shown)); + } + list +} + +/// Checks out `target` without overwriting local work, naming the files that +/// stand in the way when it cannot. +/// +/// libgit2 reports a blocked safe checkout as the bare "1 conflict prevents +/// checkout", which tells the user nothing they can act on. The notify callback +/// collects the offending paths so the error can name them, matching what the +/// `git` CLI does on the same refusal. +fn checkout_tree_safe(repo: &Repository, target: &git2::Object<'_>, operation: &str) -> Result<()> { + let conflicts = std::cell::RefCell::new(Vec::new()); + let result = { + let mut opts = git2::build::CheckoutBuilder::new(); + opts.safe() + .notify_on(git2::CheckoutNotificationType::CONFLICT) + .notify(|_kind, path, _baseline, _target, _workdir| { + if let Some(path) = path { + conflicts.borrow_mut().push(path.display().to_string()); + } + true + }); + repo.checkout_tree(target, Some(&mut opts)) + }; + + match result { + Ok(()) => Ok(()), + Err(err) if err.code() == git2::ErrorCode::Conflict => { + let paths = conflicts.into_inner(); + if paths.is_empty() { + anyhow::bail!( + "{} would overwrite local changes. Commit, stash, or discard them first.", + operation + ); + } + anyhow::bail!( + "{} would overwrite {}. Commit, stash, move, or delete {} first.", + operation, + format_path_list(&paths), + if paths.len() == 1 { "it" } else { "them" } + ); + } + Err(err) => Err(err.into()), + } +} + +impl GitProject { + /// Stage specific files in the given worktree. + pub fn stage_files_at( + &mut self, + paths: &[PathBuf], + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!("stage_files: {} paths", paths.len()); + let paths = paths.to_vec(); + let task_paths = paths.clone(); + let worktree_path = worktree_path.to_path_buf(); + let refresh_repo_path = self.repo_path.clone(); + let worktree_cache = self.worktree_status_cache.clone(); + let author_filter = self.commit_author_filter.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let operation_id = self.begin_operation( + GitOperationKind::Stage, + if paths.len() == 1 { + format!("Staging {}...", paths[0].display()) + } else { + format!("Staging {} files...", paths.len()) + }, + None, + branch_name.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&worktree_path)?; + let mut index = repo.index()?; + for path in &task_paths { + if index.conflict_get(path).is_ok() { + anyhow::bail!( + "'{}' has unresolved conflicts. Open the conflict resolver before staging it.", + path.display() + ); + } + if worktree_path.join(path).exists() { + index.add_path(path)?; + } else { + index.remove_path(path)?; + } + } + index.write()?; + gather_refresh_data_lightweight_cached( + &refresh_repo_path, + commit_limit, + &worktree_cache, + author_filter.as_deref(), + ) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.refresh_ahead_behind(cx); + this.complete_op( + operation_id, + GitOperationKind::Stage, + if paths.len() == 1 { + format!("Staged {}", paths[0].display()) + } else { + format!("Staged {} files", paths.len()) + }, + (None, None, branch_name.clone()), + cx, + ); + cx.emit(GitProjectEvent::StatusChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Stage, + "Stage failed", + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Unstage specific files in the given worktree. + pub fn unstage_files_at( + &mut self, + paths: &[PathBuf], + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!("unstage_files: {} paths", paths.len()); + let paths = paths.to_vec(); + let task_paths = paths.clone(); + let worktree_path = worktree_path.to_path_buf(); + let refresh_repo_path = self.repo_path.clone(); + let worktree_cache = self.worktree_status_cache.clone(); + let author_filter = self.commit_author_filter.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let operation_id = self.begin_operation( + GitOperationKind::Unstage, + if paths.len() == 1 { + format!("Unstaging {}...", paths[0].display()) + } else { + format!("Unstaging {} files...", paths.len()) + }, + None, + branch_name.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&worktree_path)?; + if let Ok(head_tree) = repo.head().and_then(|h| h.peel_to_tree()) { + repo.reset_default(Some(&head_tree.into_object()), &task_paths)?; + } else { + let mut index = repo.index()?; + for path in &task_paths { + if let Err(e) = index.remove_path(path) { + log::warn!( + "Failed to remove path from index during unstage: {}: {}", + path.display(), + e + ); + } + } + index.write()?; + } + gather_refresh_data_lightweight_cached( + &refresh_repo_path, + commit_limit, + &worktree_cache, + author_filter.as_deref(), + ) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.refresh_ahead_behind(cx); + this.complete_op( + operation_id, + GitOperationKind::Unstage, + if paths.len() == 1 { + format!("Unstaged {}", paths[0].display()) + } else { + format!("Unstaged {} files", paths.len()) + }, + (None, None, branch_name.clone()), + cx, + ); + cx.emit(GitProjectEvent::StatusChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Unstage, + "Unstage failed", + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Stage all changes in the given worktree. + pub fn stage_all_at( + &mut self, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!("stage_all"); + let worktree_path = worktree_path.to_path_buf(); + let refresh_repo_path = self.repo_path.clone(); + let worktree_cache = self.worktree_status_cache.clone(); + let author_filter = self.commit_author_filter.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let operation_id = self.begin_operation( + GitOperationKind::Stage, + "Staging all changes...", + None, + branch_name.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&worktree_path)?; + let mut index = repo.index()?; + let conflicted = index + .conflicts()? + .filter_map(|entry| entry.ok()) + .filter_map(|entry| { + entry + .our + .as_ref() + .or(entry.their.as_ref()) + .or(entry.ancestor.as_ref()) + .map(|entry| entry.path.clone()) + }) + .collect::>(); + let mut skip_conflicts = |path: &Path, _matched: &[u8]| { + i32::from(conflicted.contains(path.as_os_str().as_encoded_bytes())) + }; + index.add_all( + ["*"].iter(), + git2::IndexAddOption::DEFAULT, + Some(&mut skip_conflicts), + )?; + index.write()?; + gather_refresh_data_lightweight_cached( + &refresh_repo_path, + commit_limit, + &worktree_cache, + author_filter.as_deref(), + ) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.refresh_ahead_behind(cx); + this.complete_op( + operation_id, + GitOperationKind::Stage, + "Staged all changes", + (None, None, branch_name.clone()), + cx, + ); + cx.emit(GitProjectEvent::StatusChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Stage, + "Stage all failed", + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Unstage all changes in the given worktree. + pub fn unstage_all_at( + &mut self, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!("unstage_all"); + let worktree_path = worktree_path.to_path_buf(); + let refresh_repo_path = self.repo_path.clone(); + let worktree_cache = self.worktree_status_cache.clone(); + let author_filter = self.commit_author_filter.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let operation_id = self.begin_operation( + GitOperationKind::Unstage, + "Unstaging all changes...", + None, + branch_name.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&worktree_path)?; + if let Ok(head) = repo.head() { + let obj = head.peel(git2::ObjectType::Any)?; + repo.reset(&obj, git2::ResetType::Mixed, None)?; + } + gather_refresh_data_lightweight_cached( + &refresh_repo_path, + commit_limit, + &worktree_cache, + author_filter.as_deref(), + ) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.refresh_ahead_behind(cx); + this.complete_op( + operation_id, + GitOperationKind::Unstage, + "Unstaged all changes", + (None, None, branch_name.clone()), + cx, + ); + cx.emit(GitProjectEvent::StatusChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Unstage, + "Unstage all failed", + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Create a commit with the current staged changes. + /// Create a commit in the given worktree with the current staged changes. + pub fn commit_at( + &mut self, + message: &str, + amend: bool, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!("commit: amend={}", amend); + let message = message.to_string(); + let task_message = message.clone(); + let commit_summary = message.lines().next().unwrap_or("").to_string(); + let worktree_path = worktree_path.to_path_buf(); + let refresh_repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let operation_id = self.begin_operation( + GitOperationKind::Commit, + if amend { + "Amending commit..." + } else { + "Creating commit..." + }, + None, + branch_name.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result<(git2::Oid, RefreshData)> = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&worktree_path)?; + let sig = repo.signature()?; + let mut index = repo.index()?; + if index.is_empty() { + anyhow::bail!("There are no staged changes to commit.") + } + let tree_oid = index.write_tree()?; + let tree = repo.find_tree(tree_oid)?; + + let auth = current_git_auth_runtime(); + + // Amending must never silently destroy an in-progress rebase + // (the working tree would be reset to ORIG_HEAD and all rebase + // progress/conflict resolution lost). Refuse instead. + if amend + && matches!( + repo.state(), + git2::RepositoryState::Rebase + | git2::RepositoryState::RebaseInteractive + | git2::RepositoryState::RebaseMerge + ) + { + anyhow::bail!( + "Cannot amend during a rebase. Continue or abort the rebase first." + ); + } + + // A plain commit made while a merge is in progress must finalize + // it as a real merge commit (HEAD + MERGE_HEAD parents) and clear + // the merge state; otherwise the second parent is dropped and the + // repository stays stuck in the 'Merging' state. + let merge_parent_oids: Vec = if !amend + && (repo.state() == git2::RepositoryState::Merge + || repo.path().join("MERGE_HEAD").exists()) + { + let merge_head_path = repo.path().join("MERGE_HEAD"); + let mut oids = Vec::new(); + if let Ok(contents) = std::fs::read_to_string(&merge_head_path) { + for line in contents.lines() { + let line = line.trim(); + if !line.is_empty() { + oids.push(git2::Oid::from_str(line)?); + } + } + } + oids + } else { + Vec::new() + }; + let finalizing_merge = !merge_parent_oids.is_empty(); + + let oid = if amend { + if auth.sign_commits { + let gpg_key = auth.gpg_key_id.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "GPG signing enabled but no key ID configured in settings" + ) + })?; + + let head = repo.head()?.peel_to_commit()?; + let parents: Vec = head.parents().collect(); + let parent_refs: Vec<&git2::Commit> = parents.iter().collect(); + let buf = repo.commit_create_buffer( + &sig, + &sig, + &task_message, + &tree, + &parent_refs, + )?; + let buf_str = std::str::from_utf8(&buf) + .context("commit buffer contains invalid UTF-8")?; + let signature = sign_with_gpg(buf_str, gpg_key)?; + let commit_oid = + repo.commit_signed(buf_str, &signature, Some("gpgsig"))?; + if let Ok(mut head_ref) = repo.head() { + head_ref.set_target(commit_oid, "commit (gpg signed amend)")?; + } else { + repo.reference( + "HEAD", + commit_oid, + true, + "commit (gpg signed amend)", + )?; + } + commit_oid + } else { + let head = repo.head()?.peel_to_commit()?; + head.amend( + Some("HEAD"), + Some(&sig), + Some(&sig), + None, + Some(&task_message), + Some(&tree), + )? + } + } else { + let mut parents: Vec = if let Ok(head) = repo.head() { + vec![head.peel_to_commit()?] + } else { + vec![] + }; + for oid in &merge_parent_oids { + parents.push(repo.find_commit(*oid)?); + } + let parent_refs: Vec<&git2::Commit> = parents.iter().collect(); + if auth.sign_commits { + let gpg_key = auth.gpg_key_id.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "GPG signing enabled but no key ID configured in settings" + ) + })?; + let buf = repo.commit_create_buffer( + &sig, + &sig, + &task_message, + &tree, + &parent_refs, + )?; + let buf_str = std::str::from_utf8(&buf) + .context("commit buffer contains invalid UTF-8")?; + let signature = sign_with_gpg(buf_str, gpg_key)?; + let commit_oid = + repo.commit_signed(buf_str, &signature, Some("gpgsig"))?; + if let Ok(mut head_ref) = repo.head() { + head_ref.set_target(commit_oid, "commit (gpg signed)")?; + } else { + repo.reference("HEAD", commit_oid, true, "commit (gpg signed)")?; + } + commit_oid + } else { + repo.commit( + Some("HEAD"), + &sig, + &sig, + &task_message, + &tree, + &parent_refs, + )? + } + }; + + // Clear MERGE_HEAD/MERGE_MSG so the repository leaves the + // 'Merging' state once the merge commit has been created. + if finalizing_merge { + repo.cleanup_state()?; + } + + let data = gather_refresh_data(&refresh_repo_path, commit_limit)?; + Ok((oid, data)) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| match result { + Ok((oid, data)) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Commit, + if amend { + format!("Amended commit {}", &oid.to_string()[..7]) + } else { + format!("Created commit {}", &oid.to_string()[..7]) + }, + (Some(commit_summary.clone()), None, branch_name.clone()), + cx, + ); + cx.emit(GitProjectEvent::RepositoryChanged); + cx.notify(); + Ok(oid) + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Commit, + if amend { + "Amend failed" + } else { + "Commit failed" + }, + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + Err(e) + } + }) + })? + }) + } + + /// Checkout a branch by name. + /// Handles both local branches and remote tracking branches (e.g. `origin/main`). + /// For remote branches, creates a local tracking branch first. + pub fn checkout_branch(&mut self, name: &str, cx: &mut Context) -> Task> { + log::info!("checkout_branch: name={}", name); + let name = name.to_string(); + let task_name = name.clone(); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let operation_id = self.begin_operation( + GitOperationKind::Checkout, + format!("Switching to '{}'...", name), + None, + Some(name.clone()), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result<(String, RefreshData)> = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&repo_path)?; + ensure_clean_worktree(&repo, "Checkout")?; + let current_branch = head_branch_name(&repo).ok(); + + // Determine whether this is a local or remote branch, and the + // object + local branch name to use. + let (obj, local_branch_name, is_tracking) = + match repo.revparse_single(&format!("refs/heads/{}", task_name)) { + Ok(o) => (o, task_name.clone(), false), + Err(_) => { + // Not a local branch — check if it's a remote tracking branch + // (e.g. "origin/main" → refs/remotes/origin/main). + let remote_ref = format!("refs/remotes/{}", task_name); + if let Ok(remote_obj) = repo.revparse_single(&remote_ref) { + let Some((_remote, short)) = task_name.split_once('/') else { + anyhow::bail!( + "Invalid remote branch name '{}'. \ + Expected 'remote/branch' format.", + task_name + ); + }; + let local_branch_name = short; + + // Refuse to overwrite an existing local branch. + if repo + .find_branch(local_branch_name, git2::BranchType::Local) + .is_ok() + { + anyhow::bail!( + "A local branch named '{}' already exists. \ + Please delete or rename it first.", + local_branch_name + ); + } + + // Create the local tracking branch at the remote's commit. + let commit = remote_obj.peel_to_commit()?; + repo.branch(local_branch_name, &commit, false)?; + + // Set upstream to track the remote branch. + if let Ok(mut branch) = + repo.find_branch(local_branch_name, git2::BranchType::Local) + { + let _ = branch.set_upstream(Some(&task_name)); + } + + (remote_obj, local_branch_name.to_string(), true) + } else { + anyhow::bail!( + "Branch '{}' not found as a local or remote branch. \ + Try fetching to update remote refs.", + task_name + ); + } + } + }; + + // Bail if already on the target branch (use local name for tracking). + if current_branch.as_deref() == Some(local_branch_name.as_str()) { + anyhow::bail!("Already on branch '{}'.", local_branch_name); + } + + let head_ref = if is_tracking { + format!("refs/heads/{}", local_branch_name) + } else { + format!("refs/heads/{}", task_name) + }; + + checkout_tree_safe(&repo, &obj, "Checkout")?; + repo.set_head(&head_ref)?; + let data = gather_refresh_data(&repo_path, commit_limit)?; + let msg = if is_tracking { + format!( + "Switched to new branch '{}' tracking '{}'", + local_branch_name, task_name + ) + } else { + format!("Switched to '{}'", task_name) + }; + Ok((msg, data)) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok((msg, data)) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Checkout, + msg, + ( + Some("Working tree updated for the selected branch.".into()), + None, + Some(name.clone()), + ), + cx, + ); + cx.emit(GitProjectEvent::RepositoryChanged); + cx.notify(); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Checkout, + format!("Checkout of '{}' failed", name), + e.to_string(), + (None, Some(name.clone()), true), + cx, + ); + } + } + Ok(()) + }) + })? + }) + } + + /// Checkout a specific commit (detached HEAD). + pub fn checkout_commit(&mut self, oid: git2::Oid, cx: &mut Context) -> Task> { + log::info!("checkout_commit: oid={}", oid); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let short_id = oid.to_string()[..7].to_string(); + let operation_id = self.begin_operation( + GitOperationKind::Checkout, + format!("Checking out {}...", short_id), + None, + Some(short_id.clone()), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&repo_path)?; + ensure_clean_worktree(&repo, "Checkout")?; + let commit = repo.find_commit(oid)?; + let obj = commit.into_object(); + checkout_tree_safe(&repo, &obj, "Checkout")?; + repo.set_head_detached(oid)?; + gather_refresh_data(&repo_path, commit_limit) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Checkout, + format!("Checked out {}", short_id), + ( + Some("HEAD is now detached at the selected commit.".into()), + None, + Some(short_id.clone()), + ), + cx, + ); + cx.emit(GitProjectEvent::RepositoryChanged); + cx.notify(); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Checkout, + format!("Checkout of {} failed", short_id), + e.to_string(), + (None, Some(short_id.clone()), true), + cx, + ); + } + } + Ok(()) + }) + })? + }) + } + + /// Checkout a tag, putting HEAD in detached state. + pub fn checkout_tag(&mut self, name: &str, cx: &mut Context) -> Task> { + log::info!("checkout_tag: name={}", name); + let name = name.to_string(); + let task_name = name.clone(); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let operation_id = self.begin_operation( + GitOperationKind::Checkout, + format!("Checking out tag '{}'...", name), + None, + Some(name.clone()), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&repo_path)?; + ensure_clean_worktree(&repo, "Checkout")?; + let obj = repo.revparse_single(&format!("refs/tags/{}", task_name))?; + let commit = obj.peel_to_commit()?; + let oid = commit.id(); + let obj = commit.into_object(); + checkout_tree_safe(&repo, &obj, "Checkout")?; + repo.set_head_detached(oid)?; + gather_refresh_data(&repo_path, commit_limit) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Checkout, + format!("Checked out tag '{}'", name), + ( + Some("HEAD is now detached at the selected tag.".into()), + None, + Some(name.clone()), + ), + cx, + ); + cx.emit(GitProjectEvent::RepositoryChanged); + cx.notify(); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Checkout, + format!("Checkout of tag '{}' failed", name), + e.to_string(), + (None, Some(name.clone()), true), + cx, + ); + } + } + Ok(()) + }) + })? + }) + } + + /// Create a new branch from HEAD. + /// Create a new branch, optionally at a specific commit (SHA or ref). + /// If `base_ref` is None or empty, creates at HEAD. + pub fn create_branch_at( + &mut self, + name: &str, + base_ref: Option<&str>, + cx: &mut Context, + ) -> Task> { + log::info!("create_branch_at: name={}", name); + let name = name.to_string(); + let base_ref = base_ref.map(|s| s.to_string()); + let task_name = name.clone(); + let task_base_ref = base_ref.clone(); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let operation_id = self.begin_operation( + GitOperationKind::Branch, + format!("Creating branch '{}'...", name), + None, + Some(name.clone()), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&repo_path)?; + let target = if let Some(ref r) = task_base_ref { + if r.is_empty() { + repo.head()?.peel_to_commit()? + } else { + let obj = repo.revparse_single(r)?; + obj.peel_to_commit().map_err(|_| { + anyhow::anyhow!("'{}' does not resolve to a commit", r) + })? + } + } else { + repo.head()?.peel_to_commit()? + }; + repo.branch(&task_name, &target, false)?; + gather_refresh_data(&repo_path, commit_limit) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Branch, + format!("Created branch '{}'", name), + ( + base_ref.as_ref().map(|value| format!("Base: {}", value)), + None, + Some(name.clone()), + ), + cx, + ); + cx.emit(GitProjectEvent::RefsChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Branch, + format!("Branch '{}' could not be created", name), + e.to_string(), + (None, Some(name.clone()), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Delete a local branch. + pub fn delete_branch(&mut self, name: &str, cx: &mut Context) -> Task> { + log::info!("delete_branch: name={}", name); + let name = name.to_string(); + let task_name = name.clone(); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let operation_id = self.begin_operation( + GitOperationKind::Branch, + format!("Deleting branch '{}'...", name), + None, + Some(name.clone()), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&repo_path)?; + let mut branch = repo.find_branch(&task_name, git2::BranchType::Local)?; + branch.delete()?; + gather_refresh_data(&repo_path, commit_limit) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Branch, + format!("Deleted branch '{}'", name), + (None, None, Some(name.clone())), + cx, + ); + cx.emit(GitProjectEvent::RefsChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Branch, + format!("Delete branch '{}' failed", name), + e.to_string(), + (None, Some(name.clone()), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Rename a local branch. + pub fn rename_branch( + &mut self, + old_name: &str, + new_name: &str, + cx: &mut Context, + ) -> Task> { + log::info!("rename_branch: old={} new={}", old_name, new_name); + let old_name = old_name.to_string(); + let new_name = new_name.to_string(); + let task_old_name = old_name.clone(); + let task_new_name = new_name.clone(); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let operation_id = self.begin_operation( + GitOperationKind::Branch, + format!("Renaming branch '{}'...", old_name), + None, + Some(old_name.clone()), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&repo_path)?; + let mut branch = repo.find_branch(&task_old_name, git2::BranchType::Local)?; + branch.rename(&task_new_name, false)?; + gather_refresh_data(&repo_path, commit_limit) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Branch, + format!("Renamed '{}' to '{}'", old_name, new_name), + (None, None, Some(new_name.clone())), + cx, + ); + cx.emit(GitProjectEvent::RepositoryChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Branch, + format!("Rename branch '{}' failed", old_name), + e.to_string(), + (None, Some(old_name.clone()), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Create a lightweight tag at the given commit. + pub fn create_tag( + &mut self, + name: &str, + target_oid: git2::Oid, + cx: &mut Context, + ) -> Task> { + log::info!("create_tag: name={} target={}", name, target_oid); + let name = name.to_string(); + let task_name = name.clone(); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let operation_id = self.begin_operation( + GitOperationKind::Tag, + format!("Creating tag '{}'...", name), + None, + self.head_branch.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&repo_path)?; + let obj = repo.find_object(target_oid, None)?; + repo.tag_lightweight(&task_name, &obj, false)?; + gather_refresh_data(&repo_path, commit_limit) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Tag, + format!("Created tag '{}'", name), + (None, None, this.head_branch.clone()), + cx, + ); + cx.emit(GitProjectEvent::RefsChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Tag, + format!("Tag '{}' could not be created", name), + e.to_string(), + (None, this.head_branch.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Delete a tag by name. + pub fn delete_tag(&mut self, name: &str, cx: &mut Context) -> Task> { + log::info!("delete_tag: name={}", name); + let name = name.to_string(); + let task_name = name.clone(); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let operation_id = self.begin_operation( + GitOperationKind::Tag, + format!("Deleting tag '{}'...", name), + None, + self.head_branch.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&repo_path)?; + repo.tag_delete(&task_name)?; + gather_refresh_data(&repo_path, commit_limit) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Tag, + format!("Deleted tag '{}'", name), + (None, None, this.head_branch.clone()), + cx, + ); + cx.emit(GitProjectEvent::RefsChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Tag, + format!("Delete tag '{}' failed", name), + e.to_string(), + (None, this.head_branch.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Save the given worktree to a stash. Stashes are stored in the shared + /// object store, but the changes taken are those of `worktree_path`. + pub fn stash_save_at( + &mut self, + message: Option<&str>, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!("stash_save: worktree={}", worktree_path.display()); + let message = message.map(String::from); + let worktree_path = worktree_path.to_path_buf(); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let operation_id = self.begin_operation( + GitOperationKind::Stash, + "Saving stash...", + None, + branch_name.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + let mut repo = Repository::open(&worktree_path)?; + let sig = repo.signature()?; + repo.stash_save(&sig, message.as_deref().unwrap_or("WIP"), None)?; + gather_refresh_data(&repo_path, commit_limit) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Stash, + "Saved stash", + (None, None, branch_name.clone()), + cx, + ); + cx.emit(GitProjectEvent::StatusChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Stash, + "Save stash failed", + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Pop a stash entry into the given worktree. + pub fn stash_pop_at( + &mut self, + index: usize, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!( + "stash_pop: index={} worktree={}", + index, + worktree_path.display() + ); + let worktree_path = worktree_path.to_path_buf(); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let operation_id = self.begin_operation( + GitOperationKind::Stash, + format!("Popping stash #{}...", index), + None, + branch_name.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result<(RefreshData, String)> = cx + .background_executor() + .spawn(async move { + let mut repo = Repository::open(&worktree_path)?; + // A conflicting pop leaves conflict markers and unmerged index + // entries rather than truly failing; surface that (and still + // refresh) instead of a hard failure that hides the new state. + let message = match repo.stash_pop(index, None) { + Ok(()) => format!("Popped stash #{}", index), + Err(e) => { + if repo.index().map(|i| i.has_conflicts()).unwrap_or(false) { + format!( + "CONFLICT: stash #{} applied with conflicts; resolve them, then drop the stash.", + index + ) + } else { + return Err(e.into()); + } + } + }; + let data = gather_refresh_data(&repo_path, commit_limit)?; + Ok((data, message)) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok((data, message)) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Stash, + message, + (None, None, branch_name.clone()), + cx, + ); + cx.emit(GitProjectEvent::StatusChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Stash, + format!("Pop stash #{} failed", index), + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Apply a stash entry into the given worktree without removing it. + pub fn stash_apply_at( + &mut self, + index: usize, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!( + "stash_apply: index={} worktree={}", + index, + worktree_path.display() + ); + let worktree_path = worktree_path.to_path_buf(); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let operation_id = self.begin_operation( + GitOperationKind::Stash, + format!("Applying stash #{}...", index), + None, + branch_name.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result<(RefreshData, String)> = cx + .background_executor() + .spawn(async move { + let mut repo = Repository::open(&worktree_path)?; + // A conflicting apply leaves conflict markers and unmerged index + // entries rather than truly failing; surface that (and still + // refresh) instead of a hard failure that hides the new state. + let message = match repo.stash_apply(index, None) { + Ok(()) => format!("Applied stash #{}", index), + Err(e) => { + if repo.index().map(|i| i.has_conflicts()).unwrap_or(false) { + format!( + "CONFLICT: stash #{} applied with conflicts; resolve them before continuing.", + index + ) + } else { + return Err(e.into()); + } + } + }; + let data = gather_refresh_data(&repo_path, commit_limit)?; + Ok((data, message)) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok((data, message)) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Stash, + message, + ( + Some("The stash entry was kept.".into()), + None, + branch_name.clone(), + ), + cx, + ); + cx.emit(GitProjectEvent::StatusChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Stash, + format!("Apply stash #{} failed", index), + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Drop a stash entry without applying it. + pub fn stash_drop(&mut self, index: usize, cx: &mut Context) -> Task> { + log::info!("stash_drop: index={}", index); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let operation_id = self.begin_operation( + GitOperationKind::Stash, + format!("Dropping stash #{}...", index), + None, + branch_name.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + let mut repo = Repository::open(&repo_path)?; + repo.stash_drop(index)?; + gather_refresh_data(&repo_path, commit_limit) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Stash, + format!("Dropped stash #{}", index), + (None, None, branch_name.clone()), + cx, + ); + cx.emit(GitProjectEvent::StatusChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Stash, + format!("Drop stash #{} failed", index), + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Create a branch from a stash entry and apply the stash to it. + /// Equivalent to `git stash branch `. + pub fn stash_branch_at( + &mut self, + branch_name: &str, + stash_index: usize, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!( + "stash_branch: branch={} index={} worktree={}", + branch_name, + stash_index, + worktree_path.display() + ); + // This checks a new branch out and applies the stash into a working + // tree — both of which belong to the checkout on screen. Doing it in + // the main one would switch a checkout the user is not looking at and + // drop the shared stash from under the one they are. + let worktree_path = worktree_path.to_path_buf(); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let branch_name_owned = branch_name.to_string(); + let current_branch = self.head_branch_at(&worktree_path); + let operation_id = self.begin_operation( + GitOperationKind::Stash, + format!( + "Creating branch '{}' from stash #{}...", + branch_name_owned, stash_index + ), + None, + current_branch.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + // Clone for cx.update closures (see below) + let branch_name_for_update = branch_name_owned.clone(); + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + let mut repo = Repository::open(&worktree_path)?; + + // Collect stash OIDs to find the one at stash_index. + let mut stash_oids: Vec = Vec::new(); + repo.stash_foreach(|_idx, _msg, oid| { + stash_oids.push(*oid); + true + })?; + + let stash_oid = *stash_oids.get(stash_index).ok_or_else(|| { + anyhow::anyhow!("Stash index {} out of range", stash_index) + })?; + + // `git stash branch`: create the branch at the commit HEAD was + // on when the stash was made (the stash WIP commit's first + // parent), check it out, apply the stash onto it, then drop the + // stash. The immutable borrows from find_commit/parent end with + // the block, so the mutable set_head/checkout/apply/drop follow. + let refname = { + let stash_wip = repo.find_commit(stash_oid)?; + let base = stash_wip + .parent(0) + .context("stash entry has no base commit")?; + repo.branch(&branch_name_owned, &base, false)?; + format!("refs/heads/{}", branch_name_owned) + }; + repo.set_head(&refname)?; + repo.checkout_head(Some(git2::build::CheckoutBuilder::new().safe()))?; + + // Apply onto the new branch, then drop the stash on success. A + // conflicting apply errors here and intentionally keeps the + // stash entry so the user can retry. + repo.stash_apply(stash_index, None)?; + repo.stash_drop(stash_index)?; + + gather_refresh_data(&repo_path, commit_limit) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Stash, + format!( + "Created branch '{}' from stash #{}", + branch_name_for_update, stash_index + ), + (None, None, Some(branch_name_for_update.clone())), + cx, + ); + cx.emit(GitProjectEvent::StatusChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Stash, + format!("Create branch from stash #{} failed", stash_index), + e.to_string(), + (None, current_branch, false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Discard changes in specific files for the given worktree (restore to HEAD). + pub fn discard_changes_at( + &mut self, + paths: &[PathBuf], + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!("discard_changes: {} paths", paths.len()); + let paths = paths.to_vec(); + let operation_id = self.begin_operation( + GitOperationKind::Discard, + if paths.len() == 1 { + format!("Discarding changes in {}...", paths[0].display()) + } else { + format!("Discarding changes in {} files...", paths.len()) + }, + None, + self.head_branch.clone(), + cx, + ); + let worktree_path = worktree_path.to_path_buf(); + let refresh_repo_path = self.repo_path.clone(); + let worktree_cache = self.worktree_status_cache.clone(); + let author_filter = self.commit_author_filter.clone(); + let commit_limit = self.commit_limit; + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&worktree_path)?; + let workdir = repo + .workdir() + .ok_or_else(|| anyhow::anyhow!("Bare repository has no working directory"))? + .to_path_buf(); + let mut checkout_opts = git2::build::CheckoutBuilder::new(); + checkout_opts.force(); + let mut has_tracked = false; + for path in &paths { + let is_untracked = repo + .status_file(path) + .map(|s| s.contains(git2::Status::WT_NEW)) + .unwrap_or(false); + if is_untracked { + let full = workdir.join(path); + if full.is_file() { + std::fs::remove_file(&full).with_context(|| { + format!("Failed to delete {}", full.display()) + })?; + } else if full.is_dir() { + std::fs::remove_dir_all(&full).with_context(|| { + format!("Failed to delete directory {}", full.display()) + })?; + } + } else { + checkout_opts.path(path); + has_tracked = true; + } + } + if has_tracked { + repo.checkout_head(Some(&mut checkout_opts))?; + } + let data = gather_refresh_data_lightweight_cached( + &refresh_repo_path, + commit_limit, + &worktree_cache, + author_filter.as_deref(), + )?; + Ok::<_, anyhow::Error>(data) + }) + .await; + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.refresh_ahead_behind(cx); + this.complete_op( + operation_id, + GitOperationKind::Discard, + "Discarded changes", + (None, None, this.head_branch.clone()), + cx, + ); + cx.emit(GitProjectEvent::StatusChanged); + cx.notify(); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Discard, + "Discard changes failed", + e.to_string(), + (None, this.head_branch.clone(), false), + cx, + ); + } + } + Ok(()) + }) + })? + }) + } + + /// Remove all untracked files and directories from the given worktree. + pub fn clean_untracked_at( + &mut self, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!("clean_untracked: worktree={}", worktree_path.display()); + let worktree_path = worktree_path.to_path_buf(); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let operation_id = self.begin_operation( + GitOperationKind::Clean, + "Cleaning untracked files...", + None, + branch_name.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result<(usize, RefreshData)> = cx + .background_executor() + .spawn(async move { + // Dry run first to count files + let dry_output = super::git_command() + .current_dir(&worktree_path) + .args(["clean", "-n", "-fd"]) + .output() + .context("Failed to execute git clean -n")?; + + if !dry_output.status.success() { + anyhow::bail!( + "git clean -n failed: {}", + String::from_utf8_lossy(&dry_output.stderr).trim() + ); + } + + // `git clean -n` reports what it would remove on stdout. + let file_count = + count_would_remove(&String::from_utf8_lossy(&dry_output.stdout)); + + if file_count == 0 { + // Nothing to clean + return Ok((0, gather_refresh_data(&repo_path, commit_limit)?)); + } + + // Actually remove untracked files and directories + let output = super::git_command() + .current_dir(&worktree_path) + .args(["clean", "-f", "-fd"]) + .output() + .context("Failed to execute git clean -f")?; + + let stderr = String::from_utf8_lossy(&output.stderr); + if !output.status.success() { + anyhow::bail!("git clean -f failed: {}", stderr.trim()); + } + + Ok((file_count, gather_refresh_data(&repo_path, commit_limit)?)) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok((removed, data)) => { + this.apply_refresh_data(data); + let (title, detail) = if removed == 0 { + ( + "Nothing to clean".to_string(), + "There were no untracked files to remove.".to_string(), + ) + } else { + ( + "Cleaned untracked files".to_string(), + format!( + "Removed {} untracked {}.", + removed, + if removed == 1 { "entry" } else { "entries" } + ), + ) + }; + this.complete_op( + operation_id, + GitOperationKind::Clean, + title, + (Some(detail), None, branch_name.clone()), + cx, + ); + cx.emit(GitProjectEvent::StatusChanged); + cx.notify(); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Clean, + "Clean failed", + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + Ok(()) + }) + })? + }) + } + + /// Hard reset to HEAD, discarding all working tree and index changes. + pub fn reset_hard_at( + &mut self, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!("reset_hard: worktree={}", worktree_path.display()); + let worktree_path = worktree_path.to_path_buf(); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let operation_id = self.begin_operation( + GitOperationKind::Reset, + "Resetting working tree to HEAD...", + None, + branch_name.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&worktree_path)?; + let head_commit = repo.head()?.peel_to_commit()?; + repo.reset(head_commit.as_object(), git2::ResetType::Hard, None)?; + gather_refresh_data(&repo_path, commit_limit) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Reset, + "Reset working tree to HEAD", + ( + Some("All staged and unstaged changes were discarded.".into()), + None, + branch_name.clone(), + ), + cx, + ); + cx.emit(GitProjectEvent::RepositoryChanged); + cx.notify(); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Reset, + "Reset to HEAD failed", + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + Ok(()) + }) + })? + }) + } + + /// Hard-reset the given worktree's branch to a specific commit. + pub fn reset_to_commit_at( + &mut self, + oid: git2::Oid, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!( + "reset_to_commit: oid={} worktree={}", + oid, + worktree_path.display() + ); + let worktree_path = worktree_path.to_path_buf(); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let short_id = oid.to_string()[..7].to_string(); + let operation_id = self.begin_operation( + GitOperationKind::Reset, + format!("Resetting to {}...", short_id), + None, + branch_name.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&worktree_path)?; + let commit = repo.find_commit(oid)?; + repo.reset(commit.as_object(), git2::ResetType::Hard, None)?; + gather_refresh_data(&repo_path, commit_limit) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Reset, + format!("Reset to {}", short_id), + ( + Some("Working tree reset to the selected commit.".into()), + None, + branch_name.clone(), + ), + cx, + ); + cx.emit(GitProjectEvent::RepositoryChanged); + cx.notify(); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Reset, + format!("Reset to {} failed", short_id), + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + Ok(()) + }) + })? + }) + } + + /// Soft-reset the given worktree's branch to `oid`, preserving changes in the + /// index. Used to undo a commit made while inspecting a linked worktree so the + /// reset targets that worktree's branch rather than the main repo's HEAD. + pub fn reset_soft_at( + &mut self, + oid: git2::Oid, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!("reset_soft: oid={}", oid); + let worktree_path = worktree_path.to_path_buf(); + let refresh_repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let short_id = oid.to_string()[..7].to_string(); + let operation_id = self.begin_operation( + GitOperationKind::Reset, + format!("Soft-resetting to {}...", short_id), + None, + branch_name.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&worktree_path)?; + let commit = repo.find_commit(oid)?; + repo.reset(commit.as_object(), git2::ResetType::Soft, None)?; + gather_refresh_data(&refresh_repo_path, commit_limit) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Reset, + format!("Soft reset to {}", short_id), + ( + Some("Changes preserved in index.".into()), + None, + branch_name.clone(), + ), + cx, + ); + cx.emit(GitProjectEvent::RepositoryChanged); + cx.notify(); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Reset, + format!("Soft reset to {} failed", short_id), + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + Ok(()) + }) + })? + }) + } + + /// Mixed-reset the given worktree's branch to a specific commit, unstaging all changes. + pub fn reset_mixed_at( + &mut self, + oid: git2::Oid, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!( + "reset_mixed: oid={} worktree={}", + oid, + worktree_path.display() + ); + let worktree_path = worktree_path.to_path_buf(); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let short_id = oid.to_string()[..7].to_string(); + let operation_id = self.begin_operation( + GitOperationKind::Reset, + format!("Mixed-resetting to {}...", short_id), + None, + branch_name.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&worktree_path)?; + let commit = repo.find_commit(oid)?; + repo.reset(commit.as_object(), git2::ResetType::Mixed, None)?; + gather_refresh_data(&repo_path, commit_limit) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Reset, + format!("Mixed reset to {}", short_id), + ( + Some("Changes unstaged; index and working tree reset.".into()), + None, + branch_name.clone(), + ), + cx, + ); + cx.emit(GitProjectEvent::RepositoryChanged); + cx.notify(); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Reset, + format!("Mixed reset to {} failed", short_id), + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + Ok(()) + }) + })? + }) + } + + /// Revert a commit (creates a new commit that undoes the given commit). + pub fn revert_commit_at( + &mut self, + oid: git2::Oid, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!( + "revert_commit: oid={} worktree={}", + oid, + worktree_path.display() + ); + let worktree_path = worktree_path.to_path_buf(); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch_at(&worktree_path); + let short_id = oid.to_string()[..7].to_string(); + let operation_id = self.begin_operation( + GitOperationKind::Revert, + format!("Reverting {}...", short_id), + None, + branch_name.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result<(String, RefreshData, bool)> = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&worktree_path)?; + ensure_clean_worktree(&repo, "Revert")?; + let commit = repo.find_commit(oid)?; + let summary = commit.summary().unwrap_or("").to_string(); + let mut opts = git2::RevertOptions::new(); + repo.revert(&commit, Some(&mut opts))?; + let has_conflicts = repo.index()?.has_conflicts(); + if !has_conflicts { + repo.cleanup_state()?; + } + let data = gather_refresh_data(&repo_path, commit_limit)?; + Ok((summary, data, has_conflicts)) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok((summary, data, has_conflicts)) => { + this.apply_refresh_data(data); + cx.emit(GitProjectEvent::StatusChanged); + if has_conflicts { + this.fail_op( + operation_id, + GitOperationKind::Revert, + format!("Revert of {} needs conflict resolution", short_id), + "Resolve the conflicts in the working tree, then commit the revert manually.".to_string(), + (None, branch_name.clone(), false), + cx, + ); + } else { + this.complete_op( + operation_id, + GitOperationKind::Revert, + format!("Reverted {}", short_id), + (Some(format!( + "Revert for '{}' has been applied. Review the changes and commit them manually.", + summary + )), None, branch_name.clone()), + cx, + ); + } + cx.notify(); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Revert, + format!("Revert of {} failed", short_id), + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + Ok(()) + }) + })? + }) + } + + /// Cherry-pick a commit onto the current HEAD. + pub fn cherry_pick_at( + &mut self, + oid: git2::Oid, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!( + "cherry_pick: oid={} worktree={}", + oid, + worktree_path.display() + ); + let worktree_path = worktree_path.to_path_buf(); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch_at(&worktree_path); + let short_id = oid.to_string()[..7].to_string(); + let operation_id = self.begin_operation( + GitOperationKind::CherryPick, + format!("Cherry-picking {}...", short_id), + None, + branch_name.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result<(String, RefreshData, bool)> = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&worktree_path)?; + ensure_clean_worktree(&repo, "Cherry-pick")?; + let commit = repo.find_commit(oid)?; + let summary = commit.summary().unwrap_or("").to_string(); + let mut opts = git2::CherrypickOptions::new(); + repo.cherrypick(&commit, Some(&mut opts))?; + let has_conflicts = repo.index()?.has_conflicts(); + if !has_conflicts { + repo.cleanup_state()?; + } + let data = gather_refresh_data(&repo_path, commit_limit)?; + Ok((summary, data, has_conflicts)) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok((summary, data, has_conflicts)) => { + this.apply_refresh_data(data); + cx.emit(GitProjectEvent::StatusChanged); + if has_conflicts { + this.fail_op( + operation_id, + GitOperationKind::CherryPick, + format!("Cherry-pick of {} needs conflict resolution", short_id), + "Resolve the conflicts in the working tree, then commit the cherry-pick manually.".to_string(), + (None, branch_name.clone(), false), + cx, + ); + } else { + this.complete_op( + operation_id, + GitOperationKind::CherryPick, + format!("Cherry-picked {}", short_id), + (Some(format!( + "Cherry-pick for '{}' has been applied. Review the changes and commit them manually.", + summary + )), None, branch_name.clone()), + cx, + ); + } + cx.notify(); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::CherryPick, + format!("Cherry-pick of {} failed", short_id), + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + Ok(()) + }) + })? + }) + } + + /// Abort the operation in progress in `worktree_path` (merge, rebase, + /// cherry-pick, revert). Resets that working tree and index to its HEAD and + /// cleans up its state. + pub fn abort_operation_at( + &mut self, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!("abort_operation: worktree={}", worktree_path.display()); + let worktree_path = worktree_path.to_path_buf(); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch_at(&worktree_path); + let state_label = self.repo_state_at(&worktree_path).label().to_string(); + let operation_id = self.begin_operation( + GitOperationKind::Merge, + format!("Aborting {}...", state_label.to_lowercase()), + None, + branch_name.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&worktree_path)?; + + if repo.state() == git2::RepositoryState::Rebase + || repo.state() == git2::RepositoryState::RebaseInteractive + || repo.state() == git2::RepositoryState::RebaseMerge + { + if let Ok(mut rebase) = repo.open_rebase(None) { + let _ = rebase.abort(); + } + } + let head = repo.head()?.peel_to_commit()?; + repo.reset( + head.as_object(), + git2::ResetType::Hard, + Some(git2::build::CheckoutBuilder::new().force()), + )?; + repo.cleanup_state()?; + gather_refresh_data(&repo_path, commit_limit) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Merge, + format!("{} aborted", state_label), + ( + Some("Working tree has been reset to HEAD.".into()), + None, + branch_name.clone(), + ), + cx, + ); + cx.emit(GitProjectEvent::RepositoryChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Merge, + format!("Failed to abort {}", state_label.to_lowercase()), + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Continue whichever operation is paused in `worktree_path`. + /// + /// A merge is finished here with libgit2. The sequencer states — cherry-pick, + /// revert and mailbox application — and rebase have no libgit2 equivalent, so + /// they run `git --continue` in that checkout. Dispatching on the + /// state is what makes the conflict banner's Continue button finish the + /// operation the banner is describing rather than only a merge. + pub fn continue_operation_at( + &mut self, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + let state = self.repo_state_at(worktree_path); + log::info!( + "continue_operation: state={} worktree={}", + state.label(), + worktree_path.display() + ); + let worktree_path = worktree_path.to_path_buf(); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch_at(&worktree_path); + let kind = state.operation_kind(); + let state_label = state.label().to_string(); + let operation_id = self.begin_operation( + kind, + format!("Continuing {}...", state_label.to_lowercase()), + None, + branch_name.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result<(String, RefreshData)> = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&worktree_path)?; + let state = RepoState::from_git2(repo.state()); + + let index = repo.index()?; + if index.has_conflicts() { + anyhow::bail!( + "There are still unresolved conflicts. Resolve all conflicts before continuing." + ); + } + drop(index); + + let summary = match state.continue_subcommand() { + Some("merge") => finish_merge_commit(&repo)?, + Some(subcommand) => { + drop(repo); + run_continue_subcommand(&worktree_path, subcommand)? + } + None => anyhow::bail!( + "There is no {} to continue.", + state.label().to_lowercase() + ), + }; + + let data = gather_refresh_data(&repo_path, commit_limit)?; + Ok((summary, data)) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok((summary, data)) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + kind, + format!("{} completed", state_label), + (Some(summary), None, branch_name.clone()), + cx, + ); + cx.emit(GitProjectEvent::RepositoryChanged); + } + Err(e) => { + this.fail_op( + operation_id, + kind, + format!("Could not continue {}", state_label.to_lowercase()), + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Merge a branch into the current HEAD. + pub fn merge_branch(&mut self, branch_name: &str, cx: &mut Context) -> Task> { + log::info!("merge_branch: name={}", branch_name); + let branch_name = branch_name.to_string(); + let task_branch_name = branch_name.clone(); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let current_branch = self.head_branch.clone(); + let operation_id = self.begin_operation( + GitOperationKind::Merge, + format!("Merging '{}'...", branch_name), + None, + current_branch.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result<(String, RefreshData)> = cx + .background_executor() + .spawn(async move { + let msg = { + let repo = Repository::open(&repo_path)?; + ensure_clean_worktree(&repo, "Merge")?; + + let reference = repo + .find_branch(&task_branch_name, git2::BranchType::Local) + .or_else(|_| { + repo.find_branch(&task_branch_name, git2::BranchType::Remote) + })?; + let annotated_commit = + repo.reference_to_annotated_commit(reference.get())?; + + let (analysis, _pref) = repo.merge_analysis(&[&annotated_commit])?; + + if analysis.is_up_to_date() { + "Already up to date".to_string() + } else if analysis.is_fast_forward() { + let head = repo.head()?; + let head_branch_name = + head.shorthand().unwrap_or("HEAD").to_string(); + let refname = format!("refs/heads/{}", head_branch_name); + let target = repo.find_object(annotated_commit.id(), None)?; + // Check out before moving the ref. A safe checkout + // refuses rather than clobbering an untracked file, + // and leaving the ref alone until it succeeds keeps + // HEAD and the working tree in step when it refuses. + checkout_tree_safe(&repo, &target, "Merge")?; + let mut reference = repo.find_reference(&refname)?; + reference.set_target( + annotated_commit.id(), + &format!("Fast-forward merge of '{}'", task_branch_name), + )?; + repo.set_head(&refname)?; + format!("Merged '{}' (fast-forward)", task_branch_name) + } else if analysis.is_normal() { + repo.merge(&[&annotated_commit], None, None)?; + + let has_conflicts = repo.index()?.has_conflicts(); + if has_conflicts { + let conflict_count = repo + .index()? + .conflicts()? + .count(); + format!( + "CONFLICT:{} conflict(s) detected merging '{}'. Resolve and continue.", + conflict_count, task_branch_name + ) + } else { + let sig = repo.signature()?; + let mut index = repo.index()?; + let tree_oid = index.write_tree()?; + let tree = repo.find_tree(tree_oid)?; + let head_commit = repo.head()?.peel_to_commit()?; + let merge_commit = + repo.find_commit(annotated_commit.id())?; + repo.commit( + Some("HEAD"), + &sig, + &sig, + &format!( + "Merge branch '{}' into {}", + task_branch_name, + repo.head()? + .shorthand() + .unwrap_or("HEAD") + ), + &tree, + &[&head_commit, &merge_commit], + )?; + repo.cleanup_state()?; + format!("Merged '{}' successfully", task_branch_name) + } + } else { + "Merge complete".to_string() + } + }; + + let data = gather_refresh_data(&repo_path, commit_limit)?; + Ok((msg, data)) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok((msg, data)) => { + let is_conflict = msg.starts_with("CONFLICT:"); + this.apply_refresh_data(data); + if is_conflict { + let user_msg = msg.trim_start_matches("CONFLICT:").to_string(); + this.fail_op( + operation_id, + GitOperationKind::Merge, + format!("Merge conflicts in '{}'", branch_name), + user_msg, + (None, current_branch.clone(), false), + cx, + ); + } else { + this.complete_op( + operation_id, + GitOperationKind::Merge, + msg, + (Some("Repository state refreshed after merge.".into()), None, current_branch.clone()), + cx, + ); + } + cx.emit(GitProjectEvent::RepositoryChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Merge, + format!("Merge of '{}' failed", branch_name), + e.to_string(), + (None, current_branch.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Remove a remote by name. + pub fn remove_remote(&mut self, name: &str, cx: &mut Context) -> Task> { + log::info!("remove_remote: name={}", name); + let name = name.to_string(); + let branch_name = self.head_branch.clone(); + let operation_id = self.begin_operation( + GitOperationKind::RemoveRemote, + format!("Removing remote '{}'...", name), + Some(name.clone()), + branch_name.clone(), + cx, + ); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&repo_path)?; + repo.remote_delete(&name)?; + let data = gather_refresh_data(&repo_path, commit_limit)?; + Ok::<_, anyhow::Error>((data, name)) + }) + .await; + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok((data, name)) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::RemoveRemote, + format!("Removed remote '{}'", name), + ( + Some("Remote list refreshed.".into()), + Some(name.clone()), + branch_name.clone(), + ), + cx, + ); + cx.emit(GitProjectEvent::RefsChanged); + cx.notify(); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::RemoveRemote, + "Removing remote failed", + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + Ok(()) + }) + })? + }) + } + + // ============================================================================ + // Clone Operations + // ============================================================================ + + /// Clone a repository from a URL to a local path. + pub fn clone_repo( + &mut self, + url: &str, + path: &std::path::Path, + cx: &mut Context, + ) -> Task> { + log::info!("clone_repo: url={}, path={}", url, path.display()); + let url = url.to_string(); + let path = path.to_path_buf(); + let operation_id = self.begin_operation( + GitOperationKind::Clone, + format!("Cloning '{}'...", url), + None, + None, + cx, + ); + let auth = rgitui_settings::current_git_auth_runtime(); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let url_inner = url.clone(); + let path_inner = path.clone(); + let result: anyhow::Result<()> = cx + .background_executor() + .spawn(async move { + if let Err(git2_err) = git2::Repository::clone(&url_inner, &path_inner) { + log::info!( + "git2::Repository::clone failed ({}), falling back to system git", + git2_err + ); + let mut cmd = super::git_command(); + cmd.env("GIT_TERMINAL_PROMPT", "0"); + if !url_inner.starts_with("git@") && !url_inner.starts_with("ssh://") { + inject_https_credentials(&mut cmd, &auth, &url_inner); + } + // `--` stops a pasted URL beginning with `-` from being + // parsed as an option such as `--upload-pack=`. + cmd.args(["clone", "--", &url_inner, &path_inner.to_string_lossy()]); + let output = cmd.output().context("git clone failed")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git clone failed: {}", stderr); + } + } + Ok(()) + }) + .await; + // Propagate the actual clone outcome to the returned task (not just + // entity-aliveness) so the caller can report success/failure to the + // clone dialog while still showing the operation toast. + match result { + Ok(()) => { + cx.update(|cx| { + this.update(cx, |this, cx| { + this.complete_op( + operation_id, + GitOperationKind::Clone, + format!("Cloned '{}'", url), + (Some(format!("Created: {}", path.display())), None, None), + cx, + ); + }) + })?; + Ok(()) + } + Err(e) => { + let message = e.to_string(); + log::error!("clone_repo failed: {}", message); + let toast_message = message.clone(); + cx.update(|cx| { + this.update(cx, |this, cx| { + this.fail_op( + operation_id, + GitOperationKind::Clone, + "Clone failed", + toast_message, + (None, None, false), + cx, + ); + }) + })?; + Err(anyhow::anyhow!(message)) + } + } + }) + } + + // ============================================================================ + // Bisect Operations + // ============================================================================ + + /// Start a bisect session to find a commit that introduced a bug. + /// After starting, mark commits as good/bad with bisect_good/bisect_bad. + pub fn bisect_start(&mut self, cx: &mut Context) -> Task> { + log::info!("bisect_start"); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let operation_id = self.begin_operation( + GitOperationKind::Bisect, + "Starting bisect...", + None, + branch_name.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + let output = super::git_command() + .current_dir(&repo_path) + .args(["bisect", "start"]) + .output() + .context("Failed to execute git bisect start")?; + + let stderr = String::from_utf8_lossy(&output.stderr); + if !output.status.success() { + anyhow::bail!("git bisect start failed: {}", stderr.trim()); + } + + gather_refresh_data(&repo_path, commit_limit) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Bisect, + "Bisect started".to_string(), + ( + Some("Mark commits as 'good' or 'bad' to narrow down the problematic commit.".into()), + None, + branch_name.clone(), + ), + cx, + ); + cx.emit(GitProjectEvent::StatusChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Bisect, + "Failed to start bisect", + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Mark the specified commit (or current HEAD if None) as "good" during bisect. + pub fn bisect_good( + &mut self, + oid: Option, + cx: &mut Context, + ) -> Task> { + log::info!("bisect_good: oid={:?}", oid); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let short_id = oid + .map(|o| o.to_string()[..7].to_string()) + .unwrap_or_else(|| "HEAD".to_string()); + let operation_id = self.begin_operation( + GitOperationKind::Bisect, + format!("Marking {} as good...", short_id), + None, + branch_name.clone(), + cx, + ); + let oid_str = oid.map(|o| o.to_string()); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result<(Option, RefreshData)> = cx + .background_executor() + .spawn(async move { + let mut cmd = super::git_command(); + cmd.current_dir(&repo_path).args(["bisect", "good"]); + if let Some(ref oid) = oid_str { + cmd.arg(oid); + } + let output = cmd + .output() + .context("Failed to execute git bisect good")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if !output.status.success() { + anyhow::bail!("git bisect good failed: {}", stderr.trim()); + } + + // Check if bisect found the culprit + let found_match = stdout.contains("is the first bad commit"); + let message = if found_match { + Some(stdout.lines().take(10).collect::>().join("\n")) + } else { + None + }; + + let data = gather_refresh_data(&repo_path, commit_limit)?; + Ok((message, data)) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok((Some(found_msg), data)) => { + // Bisect found the bad commit + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Bisect, + "Bisect complete!".to_string(), + ( + Some(format!("Found the first bad commit:\n{}", found_msg)), + None, + branch_name.clone(), + ), + cx, + ); + cx.emit(GitProjectEvent::RepositoryChanged); + } + Ok((None, data)) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Bisect, + format!("Marked {} as good", short_id), + ( + Some("Bisect continues. Test the current commit and mark as good/bad.".into()), + None, + branch_name.clone(), + ), + cx, + ); + cx.emit(GitProjectEvent::RepositoryChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Bisect, + format!("Failed to mark {} as good", short_id), + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Mark the specified commit (or current HEAD if None) as "bad" during bisect. + pub fn bisect_bad( + &mut self, + oid: Option, + cx: &mut Context, + ) -> Task> { + log::info!("bisect_bad: oid={:?}", oid); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let short_id = oid + .map(|o| o.to_string()[..7].to_string()) + .unwrap_or_else(|| "HEAD".to_string()); + let operation_id = self.begin_operation( + GitOperationKind::Bisect, + format!("Marking {} as bad...", short_id), + None, + branch_name.clone(), + cx, + ); + let oid_str = oid.map(|o| o.to_string()); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result<(Option, RefreshData)> = cx + .background_executor() + .spawn(async move { + let mut cmd = super::git_command(); + cmd.current_dir(&repo_path).args(["bisect", "bad"]); + if let Some(ref oid) = oid_str { + cmd.arg(oid); + } + let output = cmd + .output() + .context("Failed to execute git bisect bad")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if !output.status.success() { + anyhow::bail!("git bisect bad failed: {}", stderr.trim()); + } + + // Check if bisect found the culprit + let found_match = stdout.contains("is the first bad commit"); + let message = if found_match { + Some(stdout.lines().take(10).collect::>().join("\n")) + } else { + None + }; + + let data = gather_refresh_data(&repo_path, commit_limit)?; + Ok((message, data)) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok((Some(found_msg), data)) => { + // Bisect found the bad commit + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Bisect, + "Bisect complete!".to_string(), + ( + Some(format!("Found the first bad commit:\n{}", found_msg)), + None, + branch_name.clone(), + ), + cx, + ); + cx.emit(GitProjectEvent::RepositoryChanged); + } + Ok((None, data)) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Bisect, + format!("Marked {} as bad", short_id), + ( + Some("Bisect continues. Test the current commit and mark as good/bad.".into()), + None, + branch_name.clone(), + ), + cx, + ); + cx.emit(GitProjectEvent::RepositoryChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Bisect, + format!("Failed to mark {} as bad", short_id), + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Mark the current commit (or specified commit) as skipped during bisect. + /// Skipped commits are excluded from the bisect search. + pub fn bisect_skip( + &mut self, + oid: Option, + cx: &mut Context, + ) -> Task> { + log::info!("bisect_skip: oid={:?}", oid); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let short_id = oid + .map(|o| o.to_string()[..7].to_string()) + .unwrap_or_else(|| "HEAD".to_string()); + let operation_id = self.begin_operation( + GitOperationKind::Bisect, + format!("Skipping {}...", short_id), + None, + branch_name.clone(), + cx, + ); + let oid_str = oid.map(|o| o.to_string()); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result<(Option, RefreshData)> = cx + .background_executor() + .spawn(async move { + let mut cmd = super::git_command(); + cmd.current_dir(&repo_path).args(["bisect", "skip"]); + if let Some(ref oid) = oid_str { + cmd.arg(oid); + } + let output = cmd.output().context("Failed to execute git bisect skip")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if !output.status.success() { + anyhow::bail!("git bisect skip failed: {}", stderr.trim()); + } + + // Check if bisect can no longer continue (only skipped commits remain) + let exhausted = stdout.contains("only skipped commits left to test") + || stderr.contains("only skipped commits left to test"); + let message = if exhausted { + Some( + "Bisect cannot continue: only skipped commits remain.\n\ + Consider using 'Bisect Reset' and manually narrowing down." + .into(), + ) + } else { + // git bisect skip outputs lines like: + // "Skipping commit " + // "Bisecting: N commits left to test" + let lines: Vec<_> = stdout.lines().filter(|l| !l.is_empty()).collect(); + if lines.is_empty() { + None + } else { + Some(lines.join("\n")) + } + }; + + let data = gather_refresh_data(&repo_path, commit_limit)?; + Ok((message, data)) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok((Some(msg), data)) if msg.contains("cannot continue") => { + this.apply_refresh_data(data); + this.fail_op( + operation_id, + GitOperationKind::Bisect, + "Bisect exhausted".to_string(), + msg, + (None, branch_name.clone(), false), + cx, + ); + cx.emit(GitProjectEvent::RepositoryChanged); + } + Ok((Some(msg), data)) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Bisect, + format!("Skipped {}", short_id), + (Some(msg), None, branch_name.clone()), + cx, + ); + cx.emit(GitProjectEvent::RepositoryChanged); + } + Ok((None, data)) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Bisect, + format!("Skipped {}", short_id), + ( + Some("Bisect continues. Test the current commit.".into()), + None, + branch_name.clone(), + ), + cx, + ); + cx.emit(GitProjectEvent::RepositoryChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Bisect, + format!("Failed to skip {}", short_id), + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Reset the bisect session and return to the original branch/commit. + pub fn bisect_reset(&mut self, cx: &mut Context) -> Task> { + log::info!("bisect_reset"); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let operation_id = self.begin_operation( + GitOperationKind::Bisect, + "Resetting bisect...", + None, + branch_name.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + let output = super::git_command() + .current_dir(&repo_path) + .args(["bisect", "reset"]) + .output() + .context("Failed to execute git bisect reset")?; + + let stderr = String::from_utf8_lossy(&output.stderr); + if !output.status.success() { + anyhow::bail!("git bisect reset failed: {}", stderr.trim()); + } + + gather_refresh_data(&repo_path, commit_limit) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Bisect, + "Bisect reset".to_string(), + ( + Some("Returned to original branch/commit.".into()), + None, + branch_name.clone(), + ), + cx, + ); + cx.emit(GitProjectEvent::RepositoryChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Bisect, + "Failed to reset bisect", + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Create a new Git worktree. + pub fn create_worktree( + &mut self, + name: String, + path: PathBuf, + branch: Option, + cx: &mut Context, + ) -> Task> { + log::info!("create_worktree: name={} path={}", name, path.display()); + let name_clone = name.clone(); + let operation_id = self.begin_operation( + GitOperationKind::Worktree, + format!("Creating worktree '{}'...", name), + None, + self.head_branch.clone(), + cx, + ); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result = cx + .background_executor() + .spawn(async move { + let repo = Repository::open(&repo_path)?; + + // Resolve branch reference before building options (lifetime constraint). + let reference = if let Some(ref branch_name) = branch { + repo.find_branch(branch_name, git2::BranchType::Local) + .ok() + .map(|b| b.into_reference()) + } else { + None + }; + + let mut opts = git2::WorktreeAddOptions::new(); + if let Some(ref r) = reference { + opts.reference(Some(r)); + } + + repo.worktree(&name, &path, Some(&opts))?; + gather_refresh_data(&repo_path, commit_limit) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Worktree, + format!("Created worktree '{}'", name_clone), + (None, None, this.head_branch.clone()), + cx, + ); + cx.emit(GitProjectEvent::RepositoryChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Worktree, + format!("Create worktree '{}' failed", name_clone), + e.to_string(), + (None, this.head_branch.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Remove a Git worktree. + pub fn remove_worktree(&mut self, path: PathBuf, cx: &mut Context) -> Task> { + log::info!("remove_worktree: path={}", path.display()); + let display_path = path.display().to_string(); + let operation_id = self.begin_operation( + GitOperationKind::Worktree, + format!("Removing worktree '{}'...", display_path), + None, + self.head_branch.clone(), + cx, + ); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let display_path_async = display_path.clone(); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result = cx + .background_executor() + .spawn(async move { + let output = super::git_command() + .current_dir(&repo_path) + .args(["worktree", "remove", "--force", &display_path_async]) + .output() + .context("Failed to execute git worktree remove")?; + + let stderr = String::from_utf8_lossy(&output.stderr); + if !output.status.success() { + anyhow::bail!("git worktree remove failed: {}", stderr.trim()); + } + + gather_refresh_data(&repo_path, commit_limit) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Worktree, + format!("Removed worktree '{}'", display_path), + (None, None, this.head_branch.clone()), + cx, + ); + cx.emit(GitProjectEvent::RepositoryChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Worktree, + format!("Remove worktree '{}' failed", display_path), + e.to_string(), + (None, this.head_branch.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Save an assembled conflict result and stage it as the sole stage-0 entry. + /// + /// The snapshot comes from the resolver load. Both the unmerged index OIDs + /// and the working-tree bytes must still match before this method writes, + /// so an old resolver cannot overwrite an external edit. + pub fn resolve_conflict_at( + &mut self, + path: String, + result: Option>, + result_mode: u32, + snapshot: ConflictSnapshot, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + self.finish_conflict_resolution( + path, + ConflictResolutionInput::Draft { + result, + result_mode, + snapshot, + }, + worktree_path, + cx, + ) + } + + /// Stage a result edited outside rgitui while keeping the conflict-specific + /// safety checks. Literal conflict markers are rejected. + pub fn stage_conflict_worktree_at( + &mut self, + path: String, + snapshot: ConflictSnapshot, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + self.finish_conflict_resolution( + path, + ConflictResolutionInput::WorkingTree { snapshot }, + worktree_path, + cx, + ) + } + + fn finish_conflict_resolution( + &mut self, + path: String, + input: ConflictResolutionInput, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + let worktree_path = worktree_path.to_path_buf(); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let file_path = PathBuf::from(&path); + let file_name = file_path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| path.clone()); + let branch_name = self.head_branch_at(&worktree_path); + let operation_id = self.begin_operation( + GitOperationKind::ResolveConflict, + format!("Saving conflict result for '{}'...", file_name), + None, + branch_name.clone(), + cx, + ); + + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + apply_conflict_resolution(&worktree_path, &file_path, input)?; + gather_refresh_data(&repo_path, commit_limit) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::ResolveConflict, + "Conflict resolved and staged", + ( + Some(format!("Saved and staged '{}'.", file_name)), + None, + branch_name.clone(), + ), + cx, + ); + cx.emit(GitProjectEvent::RepositoryChanged); + cx.emit(GitProjectEvent::StatusChanged); + } + Err(error) => { + this.fail_op( + operation_id, + GitOperationKind::ResolveConflict, + "Conflict resolution failed", + error.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Accept the "ours" version of a conflicted file, staging it as resolved. + pub fn accept_conflict_ours_at( + &mut self, + path: String, + snapshot: ConflictSnapshot, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!("accept_conflict_ours: path={}", path); + self.accept_conflict_side(path, ConflictSide::Ours, snapshot, worktree_path, cx) + } + + /// Accept the "theirs" version of a conflicted file, staging it as resolved. + pub fn accept_conflict_theirs_at( + &mut self, + path: String, + snapshot: ConflictSnapshot, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!("accept_conflict_theirs: path={}", path); + self.accept_conflict_side(path, ConflictSide::Theirs, snapshot, worktree_path, cx) + } + + fn accept_conflict_side( + &mut self, + path: String, + side: ConflictSide, + snapshot: ConflictSnapshot, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + // The conflicted index and the file to rewrite both belong to the + // checkout the conflict is in; the snapshot is still gathered from the + // project's own root, which is what every other operation does. + let worktree_path = worktree_path.to_path_buf(); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let file_path = PathBuf::from(&path); + let file_name = file_path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| path.clone()); + let branch_name = self.head_branch_at(&worktree_path); + let side_label = match side { + ConflictSide::Ours => "ours", + ConflictSide::Theirs => "theirs", + }; + let operation_id = self.begin_operation( + GitOperationKind::ResolveConflict, + format!( + "Resolving conflict using '{}' for '{}'...", + side_label, file_name + ), + None, + branch_name.clone(), + cx, + ); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result = cx + .background_executor() + .spawn(async move { + apply_conflict_side(&worktree_path, &file_path, side, &snapshot)?; + gather_refresh_data(&repo_path, commit_limit) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(data) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::ResolveConflict, + "Conflict resolved", + ( + Some(format!( + "Accepted '{}' version of '{}'.", + side_label, file_name + )), + None, + branch_name.clone(), + ), + cx, + ); + cx.emit(GitProjectEvent::RepositoryChanged); + cx.notify(); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::ResolveConflict, + "Conflict resolution failed", + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } +} + +enum ConflictResolutionInput { + Draft { + result: Option>, + result_mode: u32, + snapshot: ConflictSnapshot, + }, + WorkingTree { + snapshot: ConflictSnapshot, + }, +} + +struct GitIndexLock { + index_path: PathBuf, + lock_path: PathBuf, + file: Option, + committed: bool, +} + +impl GitIndexLock { + fn acquire(index_path: PathBuf) -> Result { + let mut lock_name = index_path.as_os_str().to_os_string(); + lock_name.push(".lock"); + let lock_path = PathBuf::from(lock_name); + let file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&lock_path) + .with_context(|| { + format!( + "Could not lock Git index '{}'; another Git operation may still be running", + index_path.display() + ) + })?; + let lock = Self { + index_path, + lock_path, + file: Some(file), + committed: false, + }; + // Git's lockfile commit replaces the index, so the lock must carry the + // index's permissions. In particular, preserve group write access for + // repositories using core.sharedRepository. + let permissions = std::fs::metadata(&lock.index_path)?.permissions(); + std::fs::set_permissions(&lock.lock_path, permissions).with_context(|| { + format!( + "Could not preserve permissions for Git index '{}'", + lock.index_path.display() + ) + })?; + Ok(lock) + } + + fn commit_from(mut self, prepared_index: &Path) -> Result<()> { + let mut source = std::fs::File::open(prepared_index)?; + let mut destination = self.file.take().expect("held Git index lock"); + destination.set_len(0)?; + destination.rewind()?; + std::io::copy(&mut source, &mut destination)?; + destination.sync_all()?; + drop(destination); + + // The lock file stays present until this atomic replacement, so every + // compliant Git writer is excluded from validation through commit. + std::fs::rename(&self.lock_path, &self.index_path).with_context(|| { + format!( + "Could not commit the locked Git index '{}'", + self.index_path.display() + ) + })?; + self.committed = true; + Ok(()) + } +} + +impl Drop for GitIndexLock { + fn drop(&mut self) { + if !self.committed { + self.file.take(); + let _ = std::fs::remove_file(&self.lock_path); + } + } +} + +fn write_conflict_index_resolution( + worktree_path: &Path, + file_path: &Path, + snapshot: &ConflictSnapshot, + resolved: Option<(u32, git2::Oid)>, +) -> Result<()> { + let repo = Repository::open(worktree_path)?; + let index_path = repo + .index()? + .path() + .map(Path::to_path_buf) + .context("Repository index has no on-disk path")?; + let index_lock = GitIndexLock::acquire(index_path.clone())?; + let index_parent = index_path + .parent() + .context("Repository index has no parent directory")?; + let prepared_index = tempfile::Builder::new() + .prefix("rgitui-index-") + .tempfile_in(index_parent)? + .into_temp_path(); + std::fs::copy(&index_path, &prepared_index)?; + + // The real index is locked before this copy is read. Validate the captured + // conflict against that immutable snapshot, then let Git mutate only the + // private copy. The same real lock remains held until the prepared bytes + // atomically replace the index below. + let mut locked_snapshot = git2::Index::open(&prepared_index)?; + reload_conflict_index(&mut locked_snapshot, snapshot, file_path)?; + drop(locked_snapshot); + + #[cfg(windows)] + let git_path = file_path + .as_os_str() + .as_encoded_bytes() + .iter() + .map(|byte| if *byte == b'\\' { b'/' } else { *byte }) + .collect::>(); + #[cfg(not(windows))] + let git_path = file_path.as_os_str().as_encoded_bytes().to_vec(); + + let mut input = Vec::new(); + write!(input, "0 {}\t", git2::Oid::zero())?; + input.extend_from_slice(&git_path); + input.push(0); + if let Some((mode, oid)) = resolved { + write!(input, "{mode:o} {oid}\t")?; + input.extend_from_slice(&git_path); + input.push(0); + } + + let mut command = super::git_command(); + command + .current_dir(worktree_path) + .args(["update-index", "-z", "--index-info"]) + .env("GIT_INDEX_FILE", &prepared_index) + .env("GIT_TERMINAL_PROMPT", "0") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()); + // The leading zero-mode record removes every conflict stage before the + // optional stage-zero record. Result objects already exist, so this work is + // independent of assembled-file size. + let mut child = command.spawn().with_context(|| { + format!( + "Failed to start Git while updating the conflict index for '{}'", + file_path.display() + ) + })?; + child + .stdin + .take() + .expect("piped update-index stdin") + .write_all(&input)?; + let output = child.wait_with_output()?; + if !output.status.success() { + let details = String::from_utf8_lossy(&output.stderr); + anyhow::bail!( + "Git could not update the conflict index for '{}': {}", + file_path.display(), + details.trim() + ); + } + index_lock.commit_from(&prepared_index) +} + +fn apply_conflict_side( + worktree_path: &Path, + file_path: &Path, + side: ConflictSide, + snapshot: &ConflictSnapshot, +) -> Result<()> { + ensure_repo_relative_path(file_path)?; + let repo = Repository::open(worktree_path)?; + let mut index = repo.index()?; + let initial_conflict = reload_conflict_index(&mut index, snapshot, file_path)?; + let initial_chosen = match side { + ConflictSide::Ours => initial_conflict.our.as_ref(), + ConflictSide::Theirs => initial_conflict.their.as_ref(), + }; + let worktree_file = AnchoredWorktreePath::bind( + worktree_path, + file_path, + initial_chosen.is_some_and(|entry| entry.mode & 0o170000 != 0o160000), + )?; + let original_worktree = capture_worktree_entry(&worktree_file)?; + if !original_worktree.matches_conflict_snapshot(&snapshot.worktree) { + anyhow::bail!( + "'{}' changed outside the conflict resolver. Reload it before choosing a side so no edits are overwritten.", + file_path.display() + ); + } + + // Filters may execute arbitrary processes and take long enough for another + // editor or Git operation to change state. Prepare bytes first, then reload + // both the worktree entry and the on-disk index immediately before writing. + let prepared_regular = match initial_chosen { + Some(entry) if entry.mode & 0o170000 == 0o100000 => { + let canonical = repo.find_blob(entry.id)?.content().to_vec(); + let worktree = smudge_canonical_bytes(worktree_path, file_path, &canonical)?; + Some(worktree) + } + _ => None, + }; + let prepared_symlink = match initial_chosen { + Some(entry) if entry.mode & 0o170000 == 0o120000 => { + Some(repo.find_blob(entry.id)?.content().to_vec()) + } + _ => None, + }; + ensure_worktree_entry_matches(&worktree_file, &original_worktree)?; + let conflict = reload_conflict_index(&mut index, snapshot, file_path)?; + let conflict_has_gitlink = [ + conflict.ancestor.as_ref(), + conflict.our.as_ref(), + conflict.their.as_ref(), + ] + .into_iter() + .flatten() + .any(|entry| entry.mode & 0o170000 == 0o160000); + let chosen = match side { + ConflictSide::Ours => conflict.our.as_ref(), + ConflictSide::Theirs => conflict.their.as_ref(), + }; + + if let Some(entry) = chosen { + if entry.mode & 0o170000 == 0o100000 { + let selected_mode = entry.mode; + let worktree_content = prepared_regular + .as_ref() + .expect("a verified regular side was prepared"); + let applied_worktree = replace_regular_worktree_file( + &worktree_file, + worktree_content, + selected_mode, + &original_worktree, + )?; + let refreshed_conflict = reload_conflict_index_after_worktree_change( + &mut index, + snapshot, + file_path, + &worktree_file, + &original_worktree, + &applied_worktree, + )?; + let refreshed_entry = match side { + ConflictSide::Ours => refreshed_conflict.our.as_ref(), + ConflictSide::Theirs => refreshed_conflict.their.as_ref(), + } + .expect("the verified selected side still exists"); + if let Err(error) = write_conflict_index_resolution( + worktree_path, + file_path, + snapshot, + Some((selected_mode, refreshed_entry.id)), + ) { + return Err(rollback_worktree_after_error( + &worktree_file, + &original_worktree, + &applied_worktree, + error, + )); + } + } else if entry.mode & 0o170000 == 0o160000 { + // A gitlink's OID names a commit, not a blob. Resolving it means + // selecting the stage-0 gitlink; the submodule working directory is + // intentionally left untouched. + write_conflict_index_resolution( + worktree_path, + file_path, + snapshot, + Some((entry.mode, entry.id)), + )?; + } else if entry.mode & 0o170000 == 0o120000 { + // A Git symlink blob stores its target verbatim. Materialize it + // ourselves with create-new semantics so an editor save cannot be + // overwritten between snapshot verification and publication. + // Repositories with core.symlinks=false use Git's regular-file + // representation instead. + let selected_mode = entry.mode; + let selected_id = entry.id; + let target = prepared_symlink + .as_deref() + .expect("a verified symlink side was prepared"); + let applied_worktree = if checkout_uses_symlinks(&repo) { + replace_worktree_symlink(&worktree_file, target, &original_worktree)? + } else { + replace_regular_worktree_file(&worktree_file, target, 0o100644, &original_worktree)? + }; + let refreshed_conflict = reload_conflict_index_after_worktree_change( + &mut index, + snapshot, + file_path, + &worktree_file, + &original_worktree, + &applied_worktree, + )?; + let refreshed_entry = match side { + ConflictSide::Ours => refreshed_conflict.our.as_ref(), + ConflictSide::Theirs => refreshed_conflict.their.as_ref(), + } + .expect("the verified selected side still exists"); + debug_assert_eq!(refreshed_entry.id, selected_id); + let index_result = write_conflict_index_resolution( + worktree_path, + file_path, + snapshot, + Some((selected_mode, selected_id)), + ); + if let Err(error) = index_result { + return Err(rollback_worktree_after_error( + &worktree_file, + &original_worktree, + &applied_worktree, + error, + )); + } + return Ok(()); + } else { + anyhow::bail!( + "Cannot resolve '{}' using unsupported Git mode {:o}.", + file_path.display(), + entry.mode + ); + } + } else { + // A checked-out gitlink directory is owned by the nested repository and + // must remain as local, untracked worktree state. A regular file or + // symlink at the same path is not a checked-out submodule, however, and + // should be removed like any other selected deletion. + let preserve_gitlink_directory = conflict_has_gitlink + && worktree_file + .symlink_metadata()? + .is_some_and(|metadata| metadata.is_dir()); + if !preserve_gitlink_directory { + remove_worktree_entry_if_matches(&worktree_file, &original_worktree)?; + } + let applied_worktree = (!preserve_gitlink_directory) + .then(|| capture_worktree_entry(&worktree_file)) + .transpose()?; + if let Some(applied_worktree) = applied_worktree.as_ref() { + reload_conflict_index_after_worktree_change( + &mut index, + snapshot, + file_path, + &worktree_file, + &original_worktree, + applied_worktree, + )?; + } + if applied_worktree.is_none() { + reload_conflict_index(&mut index, snapshot, file_path)?; + } + let index_result = + write_conflict_index_resolution(worktree_path, file_path, snapshot, None); + if let Err(error) = index_result { + if let Some(applied_worktree) = applied_worktree.as_ref() { + return Err(rollback_worktree_after_error( + &worktree_file, + &original_worktree, + applied_worktree, + error, + )); + } + return Err(error); + } + } + Ok(()) +} + +fn apply_conflict_resolution( + worktree_path: &Path, + file_path: &Path, + input: ConflictResolutionInput, +) -> Result<()> { + ensure_repo_relative_path(file_path)?; + let repo = Repository::open(worktree_path)?; + let mut index = repo.index()?; + let snapshot = match &input { + ConflictResolutionInput::Draft { snapshot, .. } + | ConflictResolutionInput::WorkingTree { snapshot } => snapshot.clone(), + }; + let conflict = reload_conflict_index(&mut index, &snapshot, file_path)?; + + let create_parents = matches!( + &input, + ConflictResolutionInput::Draft { + result: Some(_), + .. + } + ); + let worktree_file = AnchoredWorktreePath::bind(worktree_path, file_path, create_parents)?; + let original_worktree = capture_worktree_entry(&worktree_file)?; + let current_worktree = original_worktree.snapshot_bytes().map(<[u8]>::to_vec); + let (result, result_mode, worktree_result) = match input { + ConflictResolutionInput::Draft { + result, + result_mode, + snapshot: loaded_snapshot, + } => { + if !original_worktree.matches_conflict_snapshot(&loaded_snapshot.worktree) { + anyhow::bail!( + "'{}' changed outside the conflict resolver. Reload it before saving so no edits are overwritten.", + file_path.display() + ); + } + let worktree_result = result + .as_ref() + .map(|bytes| smudge_canonical_bytes(worktree_path, file_path, bytes)) + .transpose()?; + (result, result_mode, worktree_result) + } + ConflictResolutionInput::WorkingTree { .. } => { + let content = current_worktree.clone().ok_or_else(|| { + anyhow::anyhow!( + "'{}' does not exist in the working tree. Choose the deleted side explicitly instead.", + file_path.display() + ) + })?; + let marker_size = conflict_marker_size(&repo, file_path, &snapshot); + if contains_conflict_markers(&content, marker_size) { + anyhow::bail!( + "'{}' still contains conflict markers. Remove them in your editor before staging the working copy.", + file_path.display() + ); + } + let fallback_mode = conflict + .our + .as_ref() + .or(conflict.their.as_ref()) + .or(conflict.ancestor.as_ref()) + .map(|entry| entry.mode) + .unwrap_or(0o100644); + let mode = regular_worktree_mode(&repo, &worktree_file, fallback_mode)?; + let canonical_content = clean_worktree_bytes(worktree_path, file_path, &content)?; + if contains_conflict_markers(&canonical_content, marker_size) { + anyhow::bail!( + "'{}' still contains conflict markers after applying Git's clean filters. Remove them in your editor before staging the working copy.", + file_path.display() + ); + } + (Some(canonical_content), mode, None) + } + }; + + // Store the assembled result before the final index validation. Hashing a + // large resolution may be expensive; the subsequent update-index command + // can now hold the index lock for a short, size-independent transaction. + let result_oid = result.as_ref().map(|bytes| repo.blob(bytes)).transpose()?; + + ensure_worktree_entry_matches(&worktree_file, &original_worktree)?; + let conflict = reload_conflict_index(&mut index, &snapshot, file_path)?; + + match result_oid { + Some(result_oid) => { + if result_mode & 0o170000 != 0o100000 { + anyhow::bail!( + "'{}' is not a regular file. Choose Current or Incoming as a whole-file resolution.", + file_path.display() + ); + } + conflict + .our + .as_ref() + .or(conflict.their.as_ref()) + .or(conflict.ancestor.as_ref()) + .ok_or_else(|| anyhow::anyhow!("Conflict has no index entry to resolve"))?; + + let applied_worktree = worktree_result + .as_ref() + .map(|worktree_bytes| { + replace_regular_worktree_file( + &worktree_file, + worktree_bytes, + result_mode, + &original_worktree, + ) + }) + .transpose()?; + if let Some(applied_worktree) = applied_worktree.as_ref() { + reload_conflict_index_after_worktree_change( + &mut index, + &snapshot, + file_path, + &worktree_file, + &original_worktree, + applied_worktree, + )?; + } + + if let Err(error) = write_conflict_index_resolution( + worktree_path, + file_path, + &snapshot, + Some((result_mode, result_oid)), + ) { + if let Some(applied_worktree) = applied_worktree.as_ref() { + return Err(rollback_worktree_after_error( + &worktree_file, + &original_worktree, + applied_worktree, + error, + )); + } + return Err(error); + } + } + None => { + remove_worktree_entry_if_matches(&worktree_file, &original_worktree)?; + let applied_worktree = capture_worktree_entry(&worktree_file)?; + reload_conflict_index_after_worktree_change( + &mut index, + &snapshot, + file_path, + &worktree_file, + &original_worktree, + &applied_worktree, + )?; + let index_result = + write_conflict_index_resolution(worktree_path, file_path, &snapshot, None); + if let Err(error) = index_result { + return Err(rollback_worktree_after_error( + &worktree_file, + &original_worktree, + &applied_worktree, + error, + )); + } + } + } + Ok(()) +} + +fn verify_conflict_snapshot( + conflict: &git2::IndexConflict, + snapshot: &ConflictSnapshot, + file_path: &Path, +) -> Result<()> { + let actual = ( + conflict + .ancestor + .as_ref() + .map(|entry| (entry.id, entry.mode)), + conflict.our.as_ref().map(|entry| (entry.id, entry.mode)), + conflict.their.as_ref().map(|entry| (entry.id, entry.mode)), + ); + let expected = ( + snapshot.ancestor_oid.zip(snapshot.ancestor_mode), + snapshot.ours_oid.zip(snapshot.ours_mode), + snapshot.theirs_oid.zip(snapshot.theirs_mode), + ); + if actual != expected { + anyhow::bail!( + "The conflict for '{}' changed while the resolver was open. Reload it before saving.", + file_path.display() + ); + } + Ok(()) +} + +fn reload_conflict_index( + index: &mut git2::Index, + snapshot: &ConflictSnapshot, + file_path: &Path, +) -> Result { + index.read(true)?; + let conflict = index.conflict_get(file_path).map_err(|_| { + anyhow::anyhow!( + "The conflict for '{}' changed while the resolution was being prepared. Reload it before saving.", + file_path.display() + ) + })?; + verify_conflict_snapshot(&conflict, snapshot, file_path)?; + Ok(conflict) +} + +fn reload_conflict_index_after_worktree_change( + index: &mut git2::Index, + snapshot: &ConflictSnapshot, + file_path: &Path, + worktree_file: &AnchoredWorktreePath, + original: &WorktreeEntryBackup, + applied: &WorktreeEntryBackup, +) -> Result { + reload_conflict_index(index, snapshot, file_path) + .map_err(|error| rollback_worktree_after_error(worktree_file, original, applied, error)) +} + +fn regular_worktree_mode( + repo: &Repository, + path: &AnchoredWorktreePath, + fallback_mode: u32, +) -> Result { + let metadata = path.symlink_metadata()?.ok_or_else(|| { + anyhow::anyhow!("'{}' no longer exists in the working tree", path.display()) + })?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + anyhow::bail!( + "'{}' is not a regular file. Choose Current or Incoming as a whole-file resolution.", + path.display() + ); + } + + #[cfg(unix)] + { + use cap_std::fs::PermissionsExt as _; + let tracks_filemode = repo + .config() + .ok() + .and_then(|config| config.get_bool("core.filemode").ok()) + .unwrap_or(true); + if tracks_filemode { + return Ok(if metadata.permissions().mode() & 0o111 != 0 { + 0o100755 + } else { + 0o100644 + }); + } + } + + let _ = repo; + Ok(fallback_mode) +} + +fn ensure_repo_relative_path(path: &Path) -> Result<()> { + use std::path::Component; + if path.as_os_str().is_empty() + || path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + anyhow::bail!( + "Refusing to resolve an invalid repository path: '{}'", + path.display() + ); + } + Ok(()) +} + +/// A repository path whose final parent is held open as a directory handle. +/// +/// All resolver reads, compare-and-swap renames, temporary files, and final +/// publication are relative to this handle. Replacing an ancestor pathname +/// while a filter runs therefore cannot redirect a write through a symlink or +/// into a directory outside the worktree. +struct AnchoredWorktreePath { + root: cap_std::fs::Dir, + parent: Option, + parent_components: Vec, + name: std::ffi::OsString, + display_path: PathBuf, +} + +impl AnchoredWorktreePath { + fn bind(worktree_path: &Path, file_path: &Path, create_parents: bool) -> Result { + use cap_fs_ext::DirExt as _; + use std::path::Component; + + let root = cap_std::fs::Dir::open_ambient_dir(worktree_path, cap_std::ambient_authority()) + .with_context(|| format!("Could not open worktree '{}'", worktree_path.display()))?; + let name = file_path + .file_name() + .ok_or_else(|| anyhow::anyhow!("'{}' has no file name", file_path.display()))? + .to_os_string(); + let mut parent = Some(root.try_clone()?); + let mut parent_components = Vec::new(); + + for component in file_path.parent().into_iter().flat_map(Path::components) { + let Component::Normal(component) = component else { + if matches!(component, Component::CurDir) { + continue; + } + unreachable!("repository-relative path was validated first"); + }; + parent_components.push(component.to_os_string()); + let Some(current) = parent.as_ref() else { + continue; + }; + match current.open_dir_nofollow(component) { + Ok(next) => parent = Some(next), + Err(error) if error.kind() == std::io::ErrorKind::NotFound && !create_parents => { + parent = None; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + match current.create_dir(component) { + Ok(()) => {} + Err(create_error) + if create_error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(create_error) => return Err(create_error.into()), + } + parent = Some(current.open_dir_nofollow(component).with_context(|| { + format!( + "Refusing to resolve '{}' because parent '{}' could not be opened without following symbolic links", + file_path.display(), + worktree_path + .join(file_path.parent().unwrap_or_else(|| Path::new(""))) + .display() + ) + })?); + } + Err(error) => { + return Err(anyhow::anyhow!( + "Refusing to resolve '{}' because a parent directory could not be opened without following symbolic links: {error}", + file_path.display() + )); + } + } + } + + Ok(Self { + root, + parent, + parent_components, + name, + display_path: worktree_path.join(file_path), + }) + } + + #[cfg(test)] + fn bind_absolute(path: &Path, create_parent: bool) -> Result { + let parent = path + .parent() + .ok_or_else(|| anyhow::anyhow!("'{}' has no parent directory", path.display()))?; + let name = path + .file_name() + .ok_or_else(|| anyhow::anyhow!("'{}' has no file name", path.display()))?; + Self::bind(parent, Path::new(name), create_parent) + } + + fn display(&self) -> std::path::Display<'_> { + self.display_path.display() + } + + fn sibling_display(&self, name: &Path) -> PathBuf { + self.display_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(name) + } + + fn parent(&self) -> Result<&cap_std::fs::Dir> { + self.parent.as_ref().ok_or_else(|| { + anyhow::anyhow!("Parent directory for '{}' does not exist", self.display()) + }) + } + + fn symlink_metadata(&self) -> Result> { + let Some(parent) = self.parent.as_ref() else { + return Ok(None); + }; + match parent.symlink_metadata(&self.name) { + Ok(metadata) => Ok(Some(metadata)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } + } + + fn verify_parent_binding(&self) -> Result<()> { + use cap_fs_ext::DirExt as _; + + let Some(bound_parent) = self.parent.as_ref() else { + return Ok(()); + }; + let mut current = self.root.try_clone()?; + for component in &self.parent_components { + current = current.open_dir_nofollow(component).map_err(|error| { + anyhow::anyhow!( + "Refusing to publish '{}' because a parent directory changed: {error}", + self.display() + ) + })?; + } + let current_identity = same_file::Handle::from_file(current.try_clone()?.into_std_file())?; + let bound_identity = + same_file::Handle::from_file(bound_parent.try_clone()?.into_std_file())?; + if current_identity != bound_identity { + anyhow::bail!( + "Refusing to publish '{}' because its parent directory changed while the conflict result was being prepared.", + self.display() + ); + } + Ok(()) + } +} + +#[derive(Debug)] +enum WorktreeEntryBackup { + Missing, + Regular { + bytes: Vec, + permissions: std::fs::Permissions, + }, + Symlink { + target: PathBuf, + kind: WorktreeSymlinkKind, + }, + Other, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum WorktreeSymlinkKind { + File, + #[cfg(windows)] + Directory, +} + +impl WorktreeEntryBackup { + fn snapshot_bytes(&self) -> Option<&[u8]> { + match self { + Self::Regular { bytes, .. } => Some(bytes), + Self::Symlink { target, .. } => Some(target.as_os_str().as_encoded_bytes()), + Self::Missing | Self::Other => None, + } + } + + fn exactly_matches(&self, other: &Self) -> bool { + match (self, other) { + (Self::Missing, Self::Missing) | (Self::Other, Self::Other) => true, + ( + Self::Regular { + bytes: left_bytes, + permissions: left_permissions, + }, + Self::Regular { + bytes: right_bytes, + permissions: right_permissions, + }, + ) => { + left_bytes == right_bytes && permissions_match(left_permissions, right_permissions) + } + ( + Self::Symlink { + target: left_target, + kind: left_kind, + }, + Self::Symlink { + target: right_target, + kind: right_kind, + }, + ) => left_target == right_target && left_kind == right_kind, + _ => false, + } + } + + fn matches_conflict_snapshot(&self, snapshot: &ConflictWorktreeSnapshot) -> bool { + match (self, snapshot) { + (Self::Missing, ConflictWorktreeSnapshot::Missing) + | (Self::Other, ConflictWorktreeSnapshot::Other) => true, + ( + Self::Regular { bytes, permissions }, + ConflictWorktreeSnapshot::Regular { + bytes: snapshot_bytes, + executable, + readonly, + }, + ) => { + bytes == snapshot_bytes + && permissions_executable(permissions) == *executable + && permissions.readonly() == *readonly + } + ( + Self::Symlink { target, .. }, + ConflictWorktreeSnapshot::Symlink { target: snapshot }, + ) => target.as_os_str().as_encoded_bytes() == snapshot, + _ => false, + } + } +} + +#[cfg(unix)] +fn permissions_executable(permissions: &std::fs::Permissions) -> Option { + use std::os::unix::fs::PermissionsExt; + Some(permissions.mode() & 0o111 != 0) +} + +#[cfg(not(unix))] +fn permissions_executable(_permissions: &std::fs::Permissions) -> Option { + None +} + +#[cfg(unix)] +fn permissions_match(left: &std::fs::Permissions, right: &std::fs::Permissions) -> bool { + use std::os::unix::fs::PermissionsExt; + left.mode() == right.mode() +} + +#[cfg(not(unix))] +fn permissions_match(left: &std::fs::Permissions, right: &std::fs::Permissions) -> bool { + left.readonly() == right.readonly() +} + +#[cfg(unix)] +fn worktree_symlink_kind( + _path: &AnchoredWorktreePath, + _name: &Path, +) -> Result { + Ok(WorktreeSymlinkKind::File) +} + +#[cfg(windows)] +fn worktree_symlink_kind(path: &AnchoredWorktreePath, name: &Path) -> Result { + use std::os::windows::fs::FileTypeExt as _; + + // cap-std preserves the no-follow parent binding, but its portable metadata + // API does not expose Windows' file-vs-directory symlink bit. This lookup is + // used only to preserve rollback kind; every mutation remains relative to + // the verified directory handle below. + let metadata = std::fs::symlink_metadata(path.sibling_display(name))?; + if metadata.file_type().is_symlink_dir() { + Ok(WorktreeSymlinkKind::Directory) + } else { + Ok(WorktreeSymlinkKind::File) + } +} + +fn capture_worktree_entry(path: &AnchoredWorktreePath) -> Result { + capture_worktree_entry_named(path, Path::new(&path.name)) +} + +fn capture_worktree_entry_named( + path: &AnchoredWorktreePath, + name: &Path, +) -> Result { + use cap_fs_ext::{FollowSymlinks, OpenOptionsFollowExt as _}; + + let Some(parent) = path.parent.as_ref() else { + return Ok(WorktreeEntryBackup::Missing); + }; + let metadata = match parent.symlink_metadata(name) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(WorktreeEntryBackup::Missing); + } + Err(error) => return Err(error.into()), + }; + if metadata.file_type().is_symlink() { + return Ok(WorktreeEntryBackup::Symlink { + target: parent.read_link_contents(name)?, + kind: worktree_symlink_kind(path, name)?, + }); + } + if metadata.is_file() { + let mut options = cap_std::fs::OpenOptions::new(); + options.read(true).follow(FollowSymlinks::No); + let mut file = parent.open_with(name, &options)?.into_std(); + let metadata = file.metadata()?; + if !metadata.is_file() { + anyhow::bail!("'{}' changed while it was being inspected", path.display()); + } + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + return Ok(WorktreeEntryBackup::Regular { + bytes, + permissions: metadata.permissions(), + }); + } + Ok(WorktreeEntryBackup::Other) +} + +fn replace_regular_worktree_file( + path: &AnchoredWorktreePath, + bytes: &[u8], + mode: u32, + expected: &WorktreeEntryBackup, +) -> Result { + write_regular_worktree_file_inner(path, bytes, Some(mode), Some(expected)) +} + +fn write_regular_worktree_file_inner( + path: &AnchoredWorktreePath, + bytes: &[u8], + mode: Option, + expected: Option<&WorktreeEntryBackup>, +) -> Result { + static NEXT_TEMP_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + + let parent = path.parent()?; + if matches!(path.symlink_metadata()?, Some(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink()) + { + anyhow::bail!( + "Refusing to replace '{}' because it is a directory.", + path.display() + ); + } + + let mut temp = None; + for _ in 0..128 { + let id = NEXT_TEMP_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let candidate = PathBuf::from(format!(".rgitui-conflict-{}-{id}.tmp", std::process::id())); + let mut options = cap_std::fs::OpenOptions::new(); + options.write(true).create_new(true); + match parent.open_with(&candidate, &options) { + Ok(file) => { + temp = Some((candidate, file.into_std())); + break; + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error.into()), + } + } + let (temp_path, mut temp_file) = temp.ok_or_else(|| { + anyhow::anyhow!( + "Could not allocate a temporary conflict result beside '{}'", + path.display() + ) + })?; + if let Err(error) = temp_file.write_all(bytes) { + drop(temp_file); + let _ = parent.remove_file(&temp_path); + return Err(error.into()); + } + + if let Some(mode) = mode { + if let Err(error) = set_executable_bit(&temp_file, mode) { + drop(temp_file); + let _ = parent.remove_file(&temp_path); + return Err(error); + } + } + drop(temp_file); + let published_entry = match capture_worktree_entry_named(path, &temp_path) { + Ok(entry) => entry, + Err(error) => { + let _ = parent.remove_file(&temp_path); + return Err(error); + } + }; + if let Some(expected) = expected { + if let Err(error) = ensure_worktree_entry_matches(path, expected) { + let _ = parent.remove_file(&temp_path); + return Err(error); + } + } + + // Reserve the current pathname by moving it aside, then compare the exact + // displaced entry with the caller's snapshot. Publishing uses a hard link, + // whose create-new semantics cannot overwrite an editor save that races us. + let current_exists = path.symlink_metadata()?.is_some(); + let backup_path = if current_exists { + let backup_path = loop { + let id = NEXT_TEMP_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let candidate = PathBuf::from(format!( + ".rgitui-conflict-backup-{}-{id}.tmp", + std::process::id() + )); + match parent.symlink_metadata(&candidate) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => break candidate, + Ok(_) => continue, + Err(error) => { + let _ = parent.remove_file(&temp_path); + return Err(error.into()); + } + } + }; + if let Err(error) = parent.rename(&path.name, parent, &backup_path) { + let _ = parent.remove_file(&temp_path); + return Err(error.into()); + } + Some(backup_path) + } else { + None + }; + + let displaced_entry = match backup_path.as_ref() { + Some(backup_path) => match capture_worktree_entry_named(path, backup_path) { + Ok(entry) => entry, + Err(capture_error) => { + let _ = parent.remove_file(&temp_path); + if parent.symlink_metadata(&path.name).is_ok() { + anyhow::bail!( + "Could not inspect the displaced entry for '{}' ({capture_error}); it was retained at '{}' because a newer path entry appeared.", + path.display(), + path.sibling_display(backup_path).display() + ); + } + if let Err(restore_error) = parent.rename(backup_path, parent, &path.name) { + anyhow::bail!( + "Could not inspect the displaced entry for '{}' ({capture_error}) or restore its backup at '{}' ({restore_error}).", + path.display(), + path.sibling_display(backup_path).display() + ); + } + return Err(capture_error); + } + }, + None => WorktreeEntryBackup::Missing, + }; + if expected.is_some_and(|expected| !displaced_entry.exactly_matches(expected)) { + let _ = parent.remove_file(&temp_path); + if let Some(backup_path) = backup_path.as_ref() { + if let Err(error) = restore_displaced_entry(path, backup_path, &displaced_entry) { + anyhow::bail!( + "'{}' changed while the conflict resolution was being prepared. The displaced entry was retained at '{}' because it could not be restored: {error}", + path.display(), + path.sibling_display(backup_path).display() + ); + } + } + anyhow::bail!( + "'{}' changed while the conflict resolution was being prepared. Reload it so no edits are overwritten.", + path.display() + ); + } + + if let Err(publish_error) = + publish_regular_file_noclobber(path, &temp_path, bytes, &published_entry) + { + let _ = parent.remove_file(&temp_path); + if let Some(backup_path) = backup_path.as_ref() { + if let Err(restore_error) = restore_displaced_entry(path, backup_path, &displaced_entry) + { + anyhow::bail!( + "Failed to publish '{}' ({publish_error}) and could not restore its backup at '{}' ({restore_error}).", + path.display(), + path.sibling_display(backup_path).display() + ); + } + } + return Err(publish_error); + } + let _ = parent.remove_file(&temp_path); + if let Some(backup_path) = backup_path { + if let Err(error) = remove_worktree_file_or_symlink_named(path, &backup_path) { + log::warn!( + "Published '{}' but could not remove backup '{}': {}", + path.display(), + path.sibling_display(&backup_path).display(), + error + ); + } + } + Ok(published_entry) +} + +fn publish_regular_file_noclobber( + path: &AnchoredWorktreePath, + temp_path: &Path, + bytes: &[u8], + published_entry: &WorktreeEntryBackup, +) -> Result<()> { + publish_regular_file_noclobber_inner(path, temp_path, bytes, published_entry, true) +} + +fn publish_regular_file_noclobber_inner( + path: &AnchoredWorktreePath, + temp_path: &Path, + bytes: &[u8], + published_entry: &WorktreeEntryBackup, + try_hard_link: bool, +) -> Result<()> { + let parent = path.parent()?; + if try_hard_link && parent.hard_link(temp_path, parent, &path.name).is_ok() { + return Ok(()); + } + + // A hard link is the preferred atomic create-new publication, but it is + // unavailable on FAT/exFAT and on some network filesystems. Fall back to a + // create-new file so a racing editor save is still never overwritten. The + // displaced original remains at its backup path until this copy and its + // exact-content check both succeed. + if parent.symlink_metadata(&path.name).is_ok() { + anyhow::bail!( + "'{}' changed while the conflict result was being published. The newer entry was preserved.", + path.display() + ); + } + let WorktreeEntryBackup::Regular { permissions, .. } = published_entry else { + anyhow::bail!("Prepared conflict result was not a regular file") + }; + create_regular_file_noclobber(path, bytes, permissions) +} + +fn remove_worktree_entry_if_matches( + path: &AnchoredWorktreePath, + expected: &WorktreeEntryBackup, +) -> Result<()> { + if matches!(expected, WorktreeEntryBackup::Other) { + anyhow::bail!( + "Refusing to remove '{}' because it is a directory or another unsupported entry. Remove it manually after checking for untracked files.", + path.display() + ); + } + let backup_path = displace_worktree_entry_if_matches(path, expected, "delete")?; + let Some(backup_path) = backup_path else { + return Ok(()); + }; + if let Err(remove_error) = remove_worktree_file_or_symlink_named(path, &backup_path) { + return match restore_displaced_entry(path, &backup_path, expected) { + Ok(()) => Err(anyhow::anyhow!( + "The selected deletion was not staged because '{}' could not be removed: {remove_error}. The original entry was restored.", + path.display() + )), + Err(restore_error) => Err(anyhow::anyhow!( + "The selected deletion was not staged because the displaced entry at '{}' could not be removed ({remove_error}) or restored ({restore_error}).", + path.sibling_display(&backup_path).display() + )), + }; + } + Ok(()) +} + +fn checkout_uses_symlinks(repo: &Repository) -> bool { + repo.config() + .ok() + .and_then(|config| config.get_bool("core.symlinks").ok()) + .unwrap_or(!cfg!(windows)) +} + +fn replace_worktree_symlink( + path: &AnchoredWorktreePath, + target: &[u8], + expected: &WorktreeEntryBackup, +) -> Result { + let target = symlink_target_from_bytes(target)?; + let kind = selected_symlink_kind(&target, &path.display_path)?; + publish_special_worktree_entry(path, expected, || { + create_worktree_symlink(path, Path::new(&path.name), &target, kind)?; + Ok(()) + }) +} + +#[cfg(unix)] +fn symlink_target_from_bytes(target: &[u8]) -> Result { + use std::os::unix::ffi::OsStringExt; + Ok(std::ffi::OsString::from_vec(target.to_vec()).into()) +} + +#[cfg(windows)] +fn symlink_target_from_bytes(target: &[u8]) -> Result { + let target = String::from_utf8(target.to_vec()) + .context("Cannot create a Windows symbolic link with a non-UTF-8 target")?; + Ok(target.into()) +} + +#[cfg(unix)] +fn selected_symlink_kind(_target: &Path, _path: &Path) -> Result { + Ok(WorktreeSymlinkKind::File) +} + +#[cfg(windows)] +fn selected_symlink_kind(target: &Path, path: &Path) -> Result { + let resolved_target = if target.is_absolute() { + target.to_path_buf() + } else { + path.parent().unwrap_or_else(|| Path::new(".")).join(target) + }; + match std::fs::metadata(resolved_target) { + Ok(metadata) if metadata.is_dir() => Ok(WorktreeSymlinkKind::Directory), + Ok(_) => Ok(WorktreeSymlinkKind::File), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + // This matches libgit2's Windows checkout fallback for a dangling + // Git symlink, whose target type cannot otherwise be inferred. + Ok(WorktreeSymlinkKind::Directory) + } + Err(error) => Err(error.into()), + } +} + +fn publish_special_worktree_entry( + path: &AnchoredWorktreePath, + expected: &WorktreeEntryBackup, + publish: F, +) -> Result +where + F: FnOnce() -> Result<()>, +{ + if matches!(expected, WorktreeEntryBackup::Other) { + anyhow::bail!( + "Refusing to replace '{}' because it is a directory or another unsupported entry.", + path.display() + ); + } + + let original_backup = displace_worktree_entry_if_matches(path, expected, "special")?; + let operation = publish().and_then(|()| capture_worktree_entry(path)); + let applied = match operation { + Ok(applied) => applied, + Err(operation_error) => { + let current = capture_worktree_entry(path); + return match (current, original_backup.as_deref()) { + (Ok(WorktreeEntryBackup::Missing), Some(backup_path)) => { + match restore_displaced_entry(path, backup_path, expected) { + Ok(()) => Err(operation_error), + Err(restore_error) => Err(anyhow::anyhow!( + "{operation_error}; the original entry remains recoverable at '{}' because it could not be restored: {restore_error}", + path.sibling_display(backup_path).display() + )), + } + } + (Ok(WorktreeEntryBackup::Missing), None) => Err(operation_error), + (Ok(_), Some(backup_path)) => Err(anyhow::anyhow!( + "{operation_error}; an entry that appeared at '{}' was preserved, and the original remains recoverable at '{}'.", + path.display(), + path.sibling_display(backup_path).display() + )), + (Ok(_), None) => Err(anyhow::anyhow!( + "{operation_error}; an entry that appeared at '{}' was preserved.", + path.display() + )), + (Err(inspect_error), Some(backup_path)) => Err(anyhow::anyhow!( + "{operation_error}; could not inspect '{}': {inspect_error}. The original remains recoverable at '{}'.", + path.display(), + path.sibling_display(backup_path).display() + )), + (Err(inspect_error), None) => Err(anyhow::anyhow!( + "{operation_error}; could not inspect '{}': {inspect_error}.", + path.display() + )), + }; + } + }; + + if let Some(backup_path) = original_backup { + if let Err(error) = remove_worktree_file_or_symlink_named(path, &backup_path) { + log::warn!( + "Published special conflict result '{}' but could not remove backup '{}': {}", + path.display(), + path.sibling_display(&backup_path).display(), + error + ); + } + } + Ok(applied) +} + +fn displace_worktree_entry_if_matches( + path: &AnchoredWorktreePath, + expected: &WorktreeEntryBackup, + label: &str, +) -> Result> { + static NEXT_BACKUP_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + + if matches!(expected, WorktreeEntryBackup::Missing) { + ensure_worktree_entry_matches(path, expected)?; + return Ok(None); + } + path.verify_parent_binding()?; + let parent = path.parent()?; + let backup_path = loop { + let id = NEXT_BACKUP_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let candidate = PathBuf::from(format!( + ".rgitui-conflict-{label}-{}-{id}.tmp", + std::process::id() + )); + match parent.symlink_metadata(&candidate) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => break candidate, + Ok(_) => continue, + Err(error) => return Err(error.into()), + } + }; + + // Moving the pathname aside is the compare-and-swap point: whichever + // exact entry occupied the path is captured without first deleting it. + parent.rename(&path.name, parent, &backup_path)?; + let displaced = capture_worktree_entry_named(path, &backup_path)?; + if !displaced.exactly_matches(expected) { + match restore_displaced_entry(path, &backup_path, &displaced) { + Ok(()) => anyhow::bail!( + "'{}' changed while the conflict resolution was being prepared. Reload it so no edits are overwritten.", + path.display() + ), + Err(error) => anyhow::bail!( + "'{}' changed while the conflict resolution was being prepared. Its displaced entry was retained at '{}' because it could not be restored: {error}", + path.display(), + path.sibling_display(&backup_path).display() + ), + } + } + Ok(Some(backup_path)) +} + +fn create_regular_file_noclobber( + path: &AnchoredWorktreePath, + bytes: &[u8], + permissions: &std::fs::Permissions, +) -> Result<()> { + path.verify_parent_binding()?; + let mut options = cap_std::fs::OpenOptions::new(); + options.write(true).create_new(true); + let mut destination = path.parent()?.open_with(&path.name, &options)?.into_std(); + let identity = same_file::Handle::from_file(destination.try_clone()?)?; + let operation = destination + .write_all(bytes) + .and_then(|_| destination.set_permissions(permissions.clone())); + drop(destination); + + let expected = WorktreeEntryBackup::Regular { + bytes: bytes.to_vec(), + permissions: permissions.clone(), + }; + let operation = operation + .map_err(anyhow::Error::from) + .and_then(|_| ensure_created_file_matches(path, &identity, &expected)); + if let Err(error) = operation { + return match remove_created_file_if_same(path, &identity) { + Ok(()) => Err(error), + Err(cleanup_error) => Err(anyhow::anyhow!("{error}; {cleanup_error}")), + }; + } + Ok(()) +} + +fn ensure_created_file_matches( + path: &AnchoredWorktreePath, + identity: &same_file::Handle, + expected: &WorktreeEntryBackup, +) -> Result<()> { + let current_identity = regular_file_identity(path, Path::new(&path.name))?; + if ¤t_identity != identity { + anyhow::bail!( + "A newer entry replaced '{}' while the conflict result was being written; it was preserved.", + path.display() + ); + } + ensure_worktree_entry_matches(path, expected) +} + +fn remove_created_file_if_same( + path: &AnchoredWorktreePath, + identity: &same_file::Handle, +) -> Result<()> { + let current_identity = match regular_file_identity(path, Path::new(&path.name)) { + Ok(identity) => identity, + Err(error) + if error + .downcast_ref::() + .is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) => + { + return Ok(()) + } + Err(error) => return Err(error), + }; + if ¤t_identity != identity { + anyhow::bail!( + "A newer entry replaced '{}'; it was preserved.", + path.display() + ); + } + + let current = capture_worktree_entry(path)?; + let backup = displace_worktree_entry_if_matches(path, ¤t, "failed")? + .ok_or_else(|| anyhow::anyhow!("The created file disappeared before cleanup"))?; + let displaced_identity = regular_file_identity(path, &backup)?; + if &displaced_identity != identity { + let displaced = capture_worktree_entry_named(path, &backup)?; + restore_displaced_entry(path, &backup, &displaced)?; + anyhow::bail!( + "A newer entry replaced '{}' during cleanup; it was preserved.", + path.display() + ); + } + remove_worktree_file_or_symlink_named(path, &backup) +} + +fn regular_file_identity(path: &AnchoredWorktreePath, name: &Path) -> Result { + use cap_fs_ext::{FollowSymlinks, OpenOptionsFollowExt as _}; + + let mut options = cap_std::fs::OpenOptions::new(); + options.read(true).follow(FollowSymlinks::No); + let file = path.parent()?.open_with(name, &options)?.into_std(); + Ok(same_file::Handle::from_file(file)?) +} + +fn restore_displaced_entry( + path: &AnchoredWorktreePath, + backup_path: &Path, + displaced: &WorktreeEntryBackup, +) -> Result<()> { + let parent = path.parent()?; + if parent.symlink_metadata(&path.name).is_ok() { + anyhow::bail!( + "a newer entry appeared at '{}'; it was preserved", + path.display() + ); + } + match displaced { + WorktreeEntryBackup::Regular { bytes, permissions } => { + if parent.hard_link(backup_path, parent, &path.name).is_err() { + create_regular_file_noclobber(path, bytes, permissions)?; + } + remove_worktree_file_or_symlink_named(path, backup_path)?; + } + WorktreeEntryBackup::Symlink { target, kind } => { + create_worktree_symlink(path, Path::new(&path.name), target, *kind)?; + remove_worktree_file_or_symlink_named(path, backup_path)?; + } + WorktreeEntryBackup::Missing => {} + WorktreeEntryBackup::Other => anyhow::bail!( + "unsupported entry remains safely stored at '{}'", + path.sibling_display(backup_path).display() + ), + } + Ok(()) +} + +fn ensure_worktree_entry_matches( + path: &AnchoredWorktreePath, + expected: &WorktreeEntryBackup, +) -> Result<()> { + path.verify_parent_binding()?; + let current = capture_worktree_entry(path)?; + if !current.exactly_matches(expected) { + anyhow::bail!( + "'{}' changed while the conflict resolution was being prepared. Reload it so no edits are overwritten.", + path.display() + ); + } + Ok(()) +} + +fn remove_worktree_file_or_symlink_named(path: &AnchoredWorktreePath, name: &Path) -> Result<()> { + use cap_fs_ext::DirExt as _; + + let parent = path.parent()?; + match parent.symlink_metadata(name) { + Ok(metadata) if metadata.file_type().is_symlink() || metadata.is_file() => { + parent.remove_file_or_symlink(name)?; + } + Ok(_) => anyhow::bail!( + "Refusing to remove '{}' because it is a directory. Remove it manually after checking for untracked files.", + path.sibling_display(name).display() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + Ok(()) +} + +fn conflict_marker_size(repo: &Repository, path: &Path, snapshot: &ConflictSnapshot) -> usize { + // Attributes may be edited after Git wrote the conflicted worktree file. + // Recover the width from the complete marker grammar captured when the + // resolver opened, then fall back to the current attribute for unusual or + // incomplete worktree states. + if let ConflictWorktreeSnapshot::Regular { bytes, .. } = &snapshot.worktree { + if let Some(size) = complete_conflict_marker_size(bytes) { + return size; + } + } + + let value = repo + .get_attr( + path, + "conflict-marker-size", + git2::AttrCheckFlags::FILE_THEN_INDEX, + ) + .ok() + .flatten(); + match git2::AttrValue::from_string(value) { + git2::AttrValue::String(value) => value + .parse::() + .ok() + .filter(|size| *size > 0) + .unwrap_or(7), + _ => 7, + } +} + +fn complete_conflict_marker_size(content: &[u8]) -> Option { + #[derive(Clone, Copy)] + enum State { + Resolved, + Ours(usize), + Ancestor(usize), + Theirs(usize), + } + + fn labeled_marker_width(line: &[u8], marker: u8) -> Option { + let width = line.iter().take_while(|byte| **byte == marker).count(); + if width == 0 { + return None; + } + let suffix = &line[width..]; + (suffix.is_empty() + || suffix + .first() + .is_some_and(|byte| matches!(*byte, b' ' | b'\t'))) + .then_some(width) + } + + fn is_bare_marker(line: &[u8], marker: u8, width: usize) -> bool { + line.len() == width && line.iter().all(|byte| *byte == marker) + } + + let mut state = State::Resolved; + for line in content.split(|byte| *byte == b'\n') { + let line = line.strip_suffix(b"\r").unwrap_or(line); + let next_open = labeled_marker_width(line, b'<'); + state = match state { + State::Resolved => next_open.map_or(State::Resolved, State::Ours), + State::Ours(width) if is_bare_marker(line, b'|', width) => State::Ancestor(width), + State::Ours(width) if is_bare_marker(line, b'=', width) => State::Theirs(width), + State::Ancestor(width) if is_bare_marker(line, b'=', width) => State::Theirs(width), + State::Theirs(width) if labeled_marker_width(line, b'>') == Some(width) => { + return Some(width); + } + current => next_open.map_or(current, State::Ours), + }; + } + None +} + +fn contains_conflict_markers(content: &[u8], marker_size: usize) -> bool { + content.split(|byte| *byte == b'\n').any(|line| { + let line = line.strip_suffix(b"\r").unwrap_or(line); + b"<|=>".iter().any(|marker| { + if line.len() < marker_size || !line[..marker_size].iter().all(|byte| byte == marker) { + return false; + } + + let suffix = &line[marker_size..]; + if *marker == b'=' { + // The separator has no label. Requiring the end of the line + // avoids treating longer runs used as prose or headings as + // unresolved conflict markers. + suffix.is_empty() + } else { + // Start, ancestor, and end markers may be followed by a label, + // which Git separates from the exact-width marker with + // whitespace. A longer run of the delimiter is not a marker. + suffix.is_empty() + || suffix + .first() + .is_some_and(|byte| matches!(*byte, b' ' | b'\t')) + } + }) + }) +} + +fn restore_worktree_entry_if_unchanged( + path: &AnchoredWorktreePath, + original: &WorktreeEntryBackup, + applied: &WorktreeEntryBackup, +) -> Result<()> { + let applied_backup = + displace_worktree_entry_if_matches(path, applied, "rollback").map_err(|error| { + anyhow::anyhow!( + "Could not roll back '{}': {error} The newer worktree entry was preserved.", + path.display() + ) + })?; + + let restore_result = match original { + WorktreeEntryBackup::Missing => Ok(()), + WorktreeEntryBackup::Regular { bytes, permissions } => { + create_regular_file_noclobber(path, bytes, permissions) + } + WorktreeEntryBackup::Symlink { target, kind } => { + create_worktree_symlink(path, Path::new(&path.name), target, *kind) + .map_err(anyhow::Error::from) + } + WorktreeEntryBackup::Other => Err(anyhow::anyhow!( + "Cannot restore unsupported worktree entry '{}'", + path.display() + )), + }; + if let Err(error) = restore_result { + let recovery = applied_backup + .as_ref() + .map(|backup| { + anyhow::anyhow!( + "The displaced resolver result remains recoverable at '{}'.", + path.sibling_display(backup).display() + ) + }) + .unwrap_or_else(|| anyhow::anyhow!("The newer worktree entry, if any, was preserved.")); + return Err(anyhow::anyhow!( + "Could not restore '{}' during rollback: {error} {recovery}", + path.display() + )); + } + if let Some(applied_backup) = applied_backup { + remove_worktree_file_or_symlink_named(path, &applied_backup).with_context(|| { + format!( + "Rollback restored '{}' but could not remove the displaced resolver result at '{}'", + path.display(), + path.sibling_display(&applied_backup).display() + ) + })?; + } + Ok(()) +} + +fn rollback_worktree_after_error( + path: &AnchoredWorktreePath, + original: &WorktreeEntryBackup, + applied: &WorktreeEntryBackup, + operation_error: anyhow::Error, +) -> anyhow::Error { + match restore_worktree_entry_if_unchanged(path, original, applied) { + Ok(()) => operation_error, + Err(rollback_error) => anyhow::anyhow!("{operation_error}; {rollback_error}"), + } +} + +#[cfg(unix)] +fn create_worktree_symlink( + path: &AnchoredWorktreePath, + name: &Path, + target: &Path, + _kind: WorktreeSymlinkKind, +) -> std::io::Result<()> { + path.parent() + .map_err(|error| std::io::Error::other(error.to_string()))? + .symlink_contents(target, name) +} + +#[cfg(windows)] +fn create_worktree_symlink( + path: &AnchoredWorktreePath, + name: &Path, + target: &Path, + kind: WorktreeSymlinkKind, +) -> std::io::Result<()> { + let parent = path + .parent() + .map_err(|error| std::io::Error::other(error.to_string()))?; + + match kind { + WorktreeSymlinkKind::Directory => parent.symlink_dir(target, name), + WorktreeSymlinkKind::File => parent.symlink_file(target, name), + } +} + +#[cfg(unix)] +fn set_executable_bit(file: &std::fs::File, mode: u32) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let mut permissions = file.metadata()?.permissions(); + let mut permissions_mode = permissions.mode(); + if mode & 0o111 != 0 { + permissions_mode |= 0o111; + } else { + permissions_mode &= !0o111; + } + permissions.set_mode(permissions_mode); + file.set_permissions(permissions)?; + Ok(()) +} + +#[cfg(not(unix))] +fn set_executable_bit(_file: &std::fs::File, _mode: u32) -> Result<()> { + Ok(()) +} + +enum ConflictSide { + Ours, + Theirs, +} + +fn sign_with_gpg(content: &str, key_id: &str) -> Result { + use std::io::Write; + use std::process::Stdio; + + let mut cmd = std::process::Command::new("gpg"); + cmd.args(["--status-fd=2", "-bsau", key_id]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x08000000); + } + let mut child = cmd + .spawn() + .context("Failed to start gpg. Is GPG installed?")?; + + child.stdin.take().unwrap().write_all(content.as_bytes())?; + let output = child.wait_with_output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("GPG signing failed: {}", stderr); + } + + Ok(String::from_utf8(output.stdout)?) +} + +/// Return local branches that contain the given commit. +/// +/// Uses the git2 merge-base check: if `merge_base(branch_tip, commit_oid) == commit_oid`, +/// then `commit_oid` is an ancestor of `branch_tip` — meaning the branch contains the commit. +/// +/// Remote branches are excluded since the UX is about "which of my branches has this commit". +pub fn branches_containing_commit( + repo_path: &std::path::Path, + oid: git2::Oid, +) -> Result> { + let repo = Repository::open(repo_path) + .with_context(|| format!("Failed to open repository at {}", repo_path.display()))?; + + // Verify the commit exists (will error gracefully if not found) + repo.find_commit(oid)?; + let mut containing = Vec::new(); + + let branch_iter = repo.branches(Some(git2::BranchType::Local))?; + for branch_result in branch_iter { + let (branch, _branch_type) = branch_result?; + let name = branch.name()?.unwrap_or("").to_string(); + if name.is_empty() { + continue; + } + + let Some(tip_oid) = branch.get().target() else { + continue; + }; + + // Skip if tip is the commit itself (avoid self-reference) + if tip_oid == oid { + let upstream = if let Ok(upstream_ref) = branch.upstream() { + upstream_ref.name().ok().flatten().map(|s| s.to_string()) + } else { + None + }; + containing.push(BranchInfo { + name, + is_head: branch.is_head(), + is_remote: false, + upstream, + ahead: None, + behind: None, + tip_oid: Some(tip_oid), + author_email: None, + last_commit_time: None, + is_merged_into_main: None, + is_merged_into_head: None, + }); + continue; + } + + // merge_base(tip, commit) returns the common ancestor. + // If it equals our commit oid, then commit is an ancestor of tip. + if let Ok(merge_base) = repo.merge_base(tip_oid, oid) { + if merge_base == oid { + let upstream = if let Ok(upstream_ref) = branch.upstream() { + upstream_ref.name().ok().flatten().map(|s| s.to_string()) + } else { + None + }; + containing.push(BranchInfo { + name, + is_head: branch.is_head(), + is_remote: false, + upstream, + ahead: None, + behind: None, + tip_oid: Some(tip_oid), + author_email: None, + last_commit_time: None, + is_merged_into_main: None, + is_merged_into_head: None, + }); + } + } + } + + containing.sort_by(|a, b| b.is_head.cmp(&a.is_head).then(a.name.cmp(&b.name))); + + Ok(containing) +} + +/// Count the entries `git clean -n` reports it would remove. +/// +/// The dry run writes `Would remove ` lines to **stdout**; stderr stays +/// empty on success. Directories are reported the same way as files, so this +/// counts entries rather than individual files. +fn count_would_remove(stdout: &str) -> usize { + stdout + .lines() + .filter(|line| line.trim_start().starts_with("Would remove ")) + .count() +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use std::fs; + use std::io::Write; + use std::process::Stdio; + + use rgitui_test_support::TempRepo; + + use super::run_continue_subcommand; + + fn anchored(path: &std::path::Path) -> super::AnchoredWorktreePath { + super::AnchoredWorktreePath::bind_absolute(path, true).unwrap() + } + + /// Run a git subcommand in `dir`, asserting nothing about its exit status — + /// the conflict-stopping steps these tests set up are expected to fail. + fn run_git(dir: &std::path::Path, args: &[&str]) -> std::process::Output { + super::super::git_command() + .current_dir(dir) + .args(args) + .env("GIT_EDITOR", "true") + .output() + .expect("failed to run git") + } + + fn install_gitlink_conflict( + fixture: &TempRepo, + path: &str, + ancestor: git2::Oid, + ours: git2::Oid, + theirs: git2::Oid, + ) { + install_index_conflict(fixture, path, 0o160000, ancestor, ours, theirs); + } + + fn install_index_conflict( + fixture: &TempRepo, + path: &str, + mode: u32, + ancestor: git2::Oid, + ours: git2::Oid, + theirs: git2::Oid, + ) { + let entries = format!( + "{mode:o} {ancestor} 1\t{path}\n{mode:o} {ours} 2\t{path}\n{mode:o} {theirs} 3\t{path}\n" + ); + let mut child = super::super::git_command() + .current_dir(fixture.path()) + .args(["update-index", "--index-info"]) + .stdin(Stdio::piped()) + .spawn() + .expect("start git update-index"); + child + .stdin + .take() + .expect("update-index stdin") + .write_all(entries.as_bytes()) + .expect("write conflict entries"); + assert!(child.wait().expect("wait for update-index").success()); + } + + fn install_gitlink_modify_delete_conflict( + fixture: &TempRepo, + path: &str, + ancestor: git2::Oid, + ours: git2::Oid, + ) { + let entries = format!("160000 {ancestor} 1\t{path}\n160000 {ours} 2\t{path}\n"); + let mut child = super::super::git_command() + .current_dir(fixture.path()) + .args(["update-index", "--index-info"]) + .stdin(Stdio::piped()) + .spawn() + .expect("start git update-index"); + child + .stdin + .take() + .expect("update-index stdin") + .write_all(entries.as_bytes()) + .expect("write conflict entries"); + assert!(child.wait().expect("wait for update-index").success()); + } + + /// A repo whose `feature` branch and `main` both change `file.txt`'s only + /// line, so cherry-picking `feature` onto `main` stops on a conflict. + fn make_repo_with_conflicting_pick() -> TempRepo { + let fixture = TempRepo::init(); + fixture.write_file("file.txt", "base\n"); + fixture.stage("file.txt"); + fixture.commit("base"); + + run_git(fixture.path(), &["checkout", "-b", "feature"]); + fixture.write_file("file.txt", "feature\n"); + fixture.stage("file.txt"); + fixture.commit("feature change"); + + run_git(fixture.path(), &["checkout", "main"]); + fixture.write_file("file.txt", "main\n"); + fixture.stage("file.txt"); + fixture.commit("main change"); + + fixture + } + + fn make_repo_with_conflicting_merge() -> TempRepo { + let fixture = TempRepo::init(); + fixture.commit_file("file.txt", "base\n", "base"); + + assert!(run_git(fixture.path(), &["checkout", "-b", "incoming"]) + .status + .success()); + fixture.commit_file("file.txt", "incoming\n", "incoming change"); + + assert!(run_git(fixture.path(), &["checkout", "main"]) + .status + .success()); + fixture.commit_file("file.txt", "current\n", "current change"); + let merge = run_git(fixture.path(), &["merge", "incoming"]); + assert!( + !merge.status.success(), + "merge unexpectedly succeeded: stdout={} stderr={}", + String::from_utf8_lossy(&merge.stdout), + String::from_utf8_lossy(&merge.stderr) + ); + fixture + } + + fn make_repo_with_eol_conflicting_merge(eol: &str) -> TempRepo { + let fixture = TempRepo::init(); + fixture.write_file(".gitattributes", &format!("file.txt text eol={eol}\n")); + fixture.write_file("file.txt", "base\n"); + fixture.stage(".gitattributes"); + fixture.stage("file.txt"); + fixture.commit("base with attributes"); + + assert!(run_git(fixture.path(), &["checkout", "-b", "incoming"]) + .status + .success()); + fixture.commit_file("file.txt", "incoming\n", "incoming change"); + assert!(run_git(fixture.path(), &["checkout", "main"]) + .status + .success()); + fixture.commit_file("file.txt", "current\n", "current change"); + assert!(!run_git(fixture.path(), &["merge", "incoming"]) + .status + .success()); + fixture + } + + #[test] + fn assembled_resolution_is_written_and_staged_byte_exactly() { + let fixture = make_repo_with_conflicting_merge(); + let relative_path = std::path::Path::new("file.txt"); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("conflict model"); + let resolved = b"current\r\nincoming".to_vec(); + + super::apply_conflict_resolution( + fixture.path(), + relative_path, + super::ConflictResolutionInput::Draft { + result: Some(resolved.clone()), + result_mode: diff.result_mode, + snapshot: diff.snapshot, + }, + ) + .expect("save resolution"); + + assert_eq!( + fs::read(fixture.path().join(relative_path)).unwrap(), + resolved + ); + let repo = git2::Repository::open(fixture.path()).unwrap(); + let index = repo.index().unwrap(); + assert!(index.conflict_get(relative_path).is_err()); + let entry = index.get_path(relative_path, 0).expect("stage-0 entry"); + assert_eq!(repo.find_blob(entry.id).unwrap().content(), resolved); + } + + #[test] + fn assembled_canonical_result_is_smudged_for_the_worktree() { + let fixture = make_repo_with_eol_conflicting_merge("crlf"); + let relative_path = std::path::Path::new("file.txt"); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("conflict model"); + let canonical = b"resolved\n".to_vec(); + + super::apply_conflict_resolution( + fixture.path(), + relative_path, + super::ConflictResolutionInput::Draft { + result: Some(canonical.clone()), + result_mode: diff.result_mode, + snapshot: diff.snapshot, + }, + ) + .expect("save filtered resolution"); + + assert_eq!( + fs::read(fixture.path().join(relative_path)).unwrap(), + b"resolved\r\n" + ); + let repo = git2::Repository::open(fixture.path()).unwrap(); + let index = repo.index().unwrap(); + let entry = index.get_path(relative_path, 0).expect("stage-0 entry"); + assert_eq!(repo.find_blob(entry.id).unwrap().content(), canonical); + } + + #[test] + fn whole_regular_side_is_smudged_for_the_worktree() { + let fixture = make_repo_with_eol_conflicting_merge("crlf"); + let relative_path = std::path::Path::new("file.txt"); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("conflict model"); + + super::apply_conflict_side( + fixture.path(), + relative_path, + super::ConflictSide::Ours, + &diff.snapshot, + ) + .expect("choose filtered current side"); + + assert_eq!( + fs::read(fixture.path().join(relative_path)).unwrap(), + b"current\r\n" + ); + let repo = git2::Repository::open(fixture.path()).unwrap(); + let index = repo.index().unwrap(); + let entry = index.get_path(relative_path, 0).expect("stage-0 entry"); + assert_eq!(repo.find_blob(entry.id).unwrap().content(), b"current\n"); + } + + #[test] + fn editor_worktree_result_is_cleaned_before_staging() { + let fixture = make_repo_with_eol_conflicting_merge("lf"); + let relative_path = std::path::Path::new("file.txt"); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("conflict model"); + fs::write(fixture.path().join(relative_path), b"manual result\r\n").unwrap(); + + super::apply_conflict_resolution( + fixture.path(), + relative_path, + super::ConflictResolutionInput::WorkingTree { + snapshot: diff.snapshot, + }, + ) + .expect("stage filtered editor result"); + + assert_eq!( + fs::read(fixture.path().join(relative_path)).unwrap(), + b"manual result\r\n" + ); + let repo = git2::Repository::open(fixture.path()).unwrap(); + let index = repo.index().unwrap(); + let entry = index.get_path(relative_path, 0).expect("stage-0 entry"); + assert_eq!( + repo.find_blob(entry.id).unwrap().content(), + b"manual result\n" + ); + } + + #[test] + fn editor_worktree_result_rejects_markers_created_by_clean_conversion() { + let fixture = make_repo_with_conflicting_merge(); + let relative_path = std::path::Path::new("file.txt"); + fs::write( + fixture.path().join(".gitattributes"), + b"file.txt text working-tree-encoding=UTF-16LE\n", + ) + .unwrap(); + let encoded = super::smudge_canonical_bytes( + fixture.path(), + relative_path, + b"<<<<<<< current\nstill unresolved\n=======\nincoming\n>>>>>>> incoming\n", + ) + .expect("encode marker-bearing worktree file"); + fs::write(fixture.path().join(relative_path), encoded).unwrap(); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("conflict model"); + + let error = super::apply_conflict_resolution( + fixture.path(), + relative_path, + super::ConflictResolutionInput::WorkingTree { + snapshot: diff.snapshot, + }, + ) + .unwrap_err(); + + assert!(error + .to_string() + .contains("after applying Git's clean filters")); + assert!(git2::Repository::open(fixture.path()) + .unwrap() + .index() + .unwrap() + .conflict_get(relative_path) + .is_ok()); + } + + #[cfg(unix)] + #[test] + fn editor_worktree_result_uses_the_current_executable_bit() { + use std::os::unix::fs::PermissionsExt; + + let fixture = TempRepo::init(); + let relative_path = std::path::Path::new("file.txt"); + fixture.write_file("file.txt", "base\n"); + fs::set_permissions( + fixture.path().join(relative_path), + fs::Permissions::from_mode(0o755), + ) + .unwrap(); + fixture.stage(relative_path); + fixture.commit("executable base"); + assert!( + run_git(fixture.path(), &["config", "core.filemode", "true"]) + .status + .success() + ); + assert!(run_git(fixture.path(), &["checkout", "-b", "incoming"]) + .status + .success()); + fixture.commit_file("file.txt", "incoming\n", "incoming change"); + assert!(run_git(fixture.path(), &["checkout", "main"]) + .status + .success()); + fixture.commit_file("file.txt", "current\n", "current change"); + assert!(!run_git(fixture.path(), &["merge", "incoming"]) + .status + .success()); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("conflict model"); + fs::write(fixture.path().join(relative_path), b"manual result\n").unwrap(); + fs::set_permissions( + fixture.path().join(relative_path), + fs::Permissions::from_mode(0o644), + ) + .unwrap(); + + super::apply_conflict_resolution( + fixture.path(), + relative_path, + super::ConflictResolutionInput::WorkingTree { + snapshot: diff.snapshot, + }, + ) + .expect("stage non-executable editor result"); + + let entry = git2::Repository::open(fixture.path()) + .unwrap() + .index() + .unwrap() + .get_path(relative_path, 0) + .expect("stage-0 entry"); + assert_eq!(entry.mode, 0o100644); + } + + #[test] + fn stale_resolver_does_not_overwrite_external_edits() { + let fixture = make_repo_with_conflicting_merge(); + let relative_path = std::path::Path::new("file.txt"); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("conflict model"); + fs::write(fixture.path().join(relative_path), b"edited elsewhere\n").unwrap(); + + let error = super::apply_conflict_resolution( + fixture.path(), + relative_path, + super::ConflictResolutionInput::Draft { + result: Some(b"resolver result\n".to_vec()), + result_mode: diff.result_mode, + snapshot: diff.snapshot, + }, + ) + .unwrap_err(); + + assert!(error.to_string().contains("changed outside")); + assert_eq!( + fs::read(fixture.path().join(relative_path)).unwrap(), + b"edited elsewhere\n" + ); + assert!(git2::Repository::open(fixture.path()) + .unwrap() + .index() + .unwrap() + .conflict_get(relative_path) + .is_ok()); + } + + #[test] + fn stale_whole_side_resolution_does_not_overwrite_external_edits() { + let fixture = make_repo_with_conflicting_merge(); + let relative_path = std::path::Path::new("file.txt"); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("conflict model"); + fs::write(fixture.path().join(relative_path), b"edited elsewhere\n").unwrap(); + + let error = super::apply_conflict_side( + fixture.path(), + relative_path, + super::ConflictSide::Ours, + &diff.snapshot, + ) + .unwrap_err(); + + assert!(error.to_string().contains("changed outside")); + assert_eq!( + fs::read(fixture.path().join(relative_path)).unwrap(), + b"edited elsewhere\n" + ); + assert!(git2::Repository::open(fixture.path()) + .unwrap() + .index() + .unwrap() + .conflict_get(relative_path) + .is_ok()); + } + + #[cfg(unix)] + #[test] + fn stale_whole_side_resolution_rejects_a_same_bytes_symlink_replacement() { + use std::os::unix::ffi::OsStringExt; + use std::os::unix::fs::symlink; + + let fixture = make_repo_with_conflicting_merge(); + let relative_path = std::path::Path::new("file.txt"); + let full_path = fixture.path().join(relative_path); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("conflict model"); + let target = std::ffi::OsString::from_vec( + diff.snapshot + .worktree + .bytes() + .expect("regular worktree bytes") + .to_vec(), + ); + fs::remove_file(&full_path).unwrap(); + symlink(target, &full_path).unwrap(); + + let error = super::apply_conflict_side( + fixture.path(), + relative_path, + super::ConflictSide::Ours, + &diff.snapshot, + ) + .unwrap_err(); + + assert!(error.to_string().contains("changed outside")); + assert!(fs::symlink_metadata(&full_path) + .unwrap() + .file_type() + .is_symlink()); + } + + #[cfg(unix)] + #[test] + fn stale_whole_side_resolution_rejects_permission_only_changes() { + use std::os::unix::fs::PermissionsExt; + + let fixture = make_repo_with_conflicting_merge(); + let relative_path = std::path::Path::new("file.txt"); + let full_path = fixture.path().join(relative_path); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("conflict model"); + fs::set_permissions(&full_path, fs::Permissions::from_mode(0o755)).unwrap(); + + let error = super::apply_conflict_side( + fixture.path(), + relative_path, + super::ConflictSide::Ours, + &diff.snapshot, + ) + .unwrap_err(); + + assert!(error.to_string().contains("changed outside")); + assert_ne!( + fs::metadata(&full_path).unwrap().permissions().mode() & 0o111, + 0 + ); + } + + #[test] + fn whole_side_resolution_rejects_replaced_index_conflict() { + let fixture = make_repo_with_conflicting_merge(); + let relative_path = std::path::Path::new("file.txt"); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("conflict model"); + let repo = git2::Repository::open(fixture.path()).unwrap(); + let mut index = repo.index().unwrap(); + let conflict = index.conflict_get(relative_path).unwrap(); + let mut replacement = conflict.our.expect("ours entry"); + replacement.id = repo.blob(b"replacement current\n").unwrap(); + index.add(&replacement).unwrap(); + index.write().unwrap(); + + let error = super::apply_conflict_side( + fixture.path(), + relative_path, + super::ConflictSide::Ours, + &diff.snapshot, + ) + .unwrap_err(); + + assert!(error.to_string().contains("changed while")); + assert!(git2::Repository::open(fixture.path()) + .unwrap() + .index() + .unwrap() + .conflict_get(relative_path) + .is_ok()); + } + + #[test] + fn whole_side_resolution_rejects_conflict_mode_changes_with_the_same_oid() { + let fixture = make_repo_with_conflicting_merge(); + let relative_path = std::path::Path::new("file.txt"); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("conflict model"); + let repo = git2::Repository::open(fixture.path()).unwrap(); + let mut index = repo.index().unwrap(); + let conflict = index.conflict_get(relative_path).unwrap(); + let mut replacement = conflict.our.expect("ours entry"); + replacement.mode = 0o120000; + index.add(&replacement).unwrap(); + index.write().unwrap(); + + let error = super::apply_conflict_side( + fixture.path(), + relative_path, + super::ConflictSide::Ours, + &diff.snapshot, + ) + .unwrap_err(); + + assert!(error.to_string().contains("changed while")); + assert!(git2::Repository::open(fixture.path()) + .unwrap() + .index() + .unwrap() + .conflict_get(relative_path) + .is_ok()); + } + + #[test] + fn conflict_index_transaction_preserves_a_stage_after_result_hashing() { + let fixture = make_repo_with_conflicting_merge(); + let relative_path = std::path::Path::new("file.txt"); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("conflict model"); + let repo = git2::Repository::open(fixture.path()).unwrap(); + let result_oid = repo.blob(b"resolved after a large preparation\n").unwrap(); + + // This stage lands after result preparation. The transaction must read + // it under index.lock rather than overwrite it from a stale Index. + fs::write(fixture.path().join("unrelated.txt"), b"staged later\n").unwrap(); + assert!(run_git(fixture.path(), &["add", "unrelated.txt"]) + .status + .success()); + let mut index = repo.index().unwrap(); + let conflict = super::reload_conflict_index(&mut index, &diff.snapshot, relative_path) + .expect("refresh conflict index"); + let mode = conflict.our.as_ref().expect("ours entry").mode; + + super::write_conflict_index_resolution( + fixture.path(), + relative_path, + &diff.snapshot, + Some((mode, result_oid)), + ) + .expect("commit conflict resolution transaction"); + + let index = git2::Repository::open(fixture.path()) + .unwrap() + .index() + .unwrap(); + assert!(index.conflict_get(relative_path).is_err()); + assert_eq!( + index.get_path(relative_path, 0).expect("resolved entry").id, + result_oid + ); + let unrelated = index + .get_path(std::path::Path::new("unrelated.txt"), 0) + .expect("concurrent stage was preserved"); + assert_eq!( + repo.find_blob(unrelated.id).unwrap().content(), + b"staged later\n" + ); + } + + #[cfg(unix)] + #[test] + fn conflict_index_transaction_preserves_index_permissions() { + use std::os::unix::fs::PermissionsExt as _; + + let fixture = make_repo_with_conflicting_merge(); + let relative_path = std::path::Path::new("file.txt"); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("conflict model"); + let repo = git2::Repository::open(fixture.path()).unwrap(); + let index_path = repo.index().unwrap().path().unwrap().to_path_buf(); + fs::set_permissions(&index_path, fs::Permissions::from_mode(0o664)).unwrap(); + let result_oid = repo.blob(b"shared repository result\n").unwrap(); + let mode = repo + .index() + .unwrap() + .conflict_get(relative_path) + .unwrap() + .our + .expect("ours entry") + .mode; + + super::write_conflict_index_resolution( + fixture.path(), + relative_path, + &diff.snapshot, + Some((mode, result_oid)), + ) + .expect("commit conflict resolution transaction"); + + assert_eq!( + fs::metadata(index_path).unwrap().permissions().mode() & 0o777, + 0o664 + ); + } + + #[test] + fn locked_conflict_index_transaction_rejects_a_changed_conflict() { + let fixture = make_repo_with_conflicting_merge(); + let relative_path = std::path::Path::new("file.txt"); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("conflict model"); + let repo = git2::Repository::open(fixture.path()).unwrap(); + let result_oid = repo.blob(b"stale resolver result\n").unwrap(); + let replacement_oid = repo.blob(b"new concurrent side\n").unwrap(); + let mut index = repo.index().unwrap(); + let conflict = index.conflict_get(relative_path).unwrap(); + let mut replacement = conflict.our.expect("ours entry"); + let mode = replacement.mode; + replacement.id = replacement_oid; + index.add(&replacement).unwrap(); + index.write().unwrap(); + + let error = super::write_conflict_index_resolution( + fixture.path(), + relative_path, + &diff.snapshot, + Some((mode, result_oid)), + ) + .unwrap_err(); + + assert!(error.to_string().contains("changed while")); + assert!(!repo.path().join("index.lock").exists()); + let index = git2::Repository::open(fixture.path()) + .unwrap() + .index() + .unwrap(); + assert_eq!( + index + .conflict_get(relative_path) + .expect("newer conflict remains") + .our + .expect("newer ours entry") + .id, + replacement_oid + ); + assert!(index.get_path(relative_path, 0).is_none()); + } + + #[test] + fn conflict_index_transaction_handles_a_nested_path_with_spaces() { + let fixture = TempRepo::init(); + let repo = git2::Repository::open(fixture.path()).unwrap(); + let ancestor = repo.blob(b"ancestor\n").unwrap(); + let ours = repo.blob(b"current\n").unwrap(); + let theirs = repo.blob(b"incoming\n").unwrap(); + let resolved = repo.blob(b"resolved\n").unwrap(); + install_index_conflict( + &fixture, + "nested/file with space.txt", + 0o100644, + ancestor, + ours, + theirs, + ); + let relative_path = std::path::Path::new("nested").join("file with space.txt"); + let snapshot = crate::types::ConflictSnapshot { + ancestor_oid: Some(ancestor), + ancestor_mode: Some(0o100644), + ours_oid: Some(ours), + ours_mode: Some(0o100644), + theirs_oid: Some(theirs), + theirs_mode: Some(0o100644), + worktree: crate::types::ConflictWorktreeSnapshot::Missing, + }; + + super::write_conflict_index_resolution( + fixture.path(), + &relative_path, + &snapshot, + Some((0o100644, resolved)), + ) + .expect("commit nested conflict resolution transaction"); + + let index = git2::Repository::open(fixture.path()) + .unwrap() + .index() + .unwrap(); + assert!(index.conflict_get(&relative_path).is_err()); + assert_eq!( + index + .get_path(&relative_path, 0) + .expect("resolved nested entry") + .id, + resolved + ); + } + + #[test] + fn staging_working_copy_rejects_unresolved_markers() { + let fixture = make_repo_with_conflicting_merge(); + let relative_path = std::path::Path::new("file.txt"); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("conflict model"); + + let error = super::apply_conflict_resolution( + fixture.path(), + relative_path, + super::ConflictResolutionInput::WorkingTree { + snapshot: diff.snapshot, + }, + ) + .unwrap_err(); + + assert!(error + .to_string() + .contains("still contains conflict markers")); + assert!(git2::Repository::open(fixture.path()) + .unwrap() + .index() + .unwrap() + .conflict_get(relative_path) + .is_ok()); + } + + #[test] + fn staging_working_copy_accepts_longer_delimiter_runs_as_content() { + let fixture = make_repo_with_conflicting_merge(); + let relative_path = std::path::Path::new("file.txt"); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("conflict model"); + let resolved = b"resolved\n====================\nstill resolved\n"; + fs::write(fixture.path().join(relative_path), resolved).unwrap(); + + super::apply_conflict_resolution( + fixture.path(), + relative_path, + super::ConflictResolutionInput::WorkingTree { + snapshot: diff.snapshot, + }, + ) + .expect("stage the manually resolved working copy"); + + let repo = git2::Repository::open(fixture.path()).unwrap(); + let index = repo.index().unwrap(); + assert!(index.conflict_get(relative_path).is_err()); + let staged = index.get_path(relative_path, 0).expect("stage-zero entry"); + assert_eq!(repo.find_blob(staged.id).unwrap().content(), resolved); + } + + #[test] + fn every_partial_conflict_marker_is_rejected_on_its_own() { + for marker in [ + b"<<<<<<< current\n".as_slice(), + b"||||||| base\n".as_slice(), + b"=======\n".as_slice(), + b">>>>>>> incoming\n".as_slice(), + ] { + assert!(super::contains_conflict_markers(marker, 7)); + } + assert!(!super::contains_conflict_markers(b"ordinary content\n", 7)); + } + + #[test] + fn longer_delimiter_runs_are_not_conflict_markers() { + for content in [ + b"<<<<<<<< Markdown heading\n".as_slice(), + b"|||||||| generated data\n".as_slice(), + b"====================\n".as_slice(), + b">>>>>>>> Markdown heading\n".as_slice(), + b"======= not-a-separator-label\n".as_slice(), + ] { + assert!(!super::contains_conflict_markers(content, 7)); + } + + assert!(super::contains_conflict_markers(b"<<<<<<< current\n", 7)); + assert!(super::contains_conflict_markers(b"=======\n", 7)); + assert!(super::contains_conflict_markers(b">>>>>>> incoming\n", 7)); + } + + #[test] + fn staging_working_copy_honours_custom_conflict_marker_size() { + let fixture = make_repo_with_conflicting_merge(); + let relative_path = std::path::Path::new("file.txt"); + fs::write( + fixture.path().join(".gitattributes"), + b"file.txt conflict-marker-size=3\n", + ) + .unwrap(); + assert!(run_git( + fixture.path(), + &["checkout", "--conflict=merge", "--", "file.txt"] + ) + .status + .success()); + assert_eq!( + super::complete_conflict_marker_size( + &fs::read(fixture.path().join(relative_path)).unwrap() + ), + Some(3) + ); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("conflict model"); + + let error = super::apply_conflict_resolution( + fixture.path(), + relative_path, + super::ConflictResolutionInput::WorkingTree { + snapshot: diff.snapshot, + }, + ) + .unwrap_err(); + + assert!(error + .to_string() + .contains("still contains conflict markers")); + assert!(git2::Repository::open(fixture.path()) + .unwrap() + .index() + .unwrap() + .conflict_get(relative_path) + .is_ok()); + } + + #[test] + fn staging_working_copy_retains_the_conflicts_original_marker_size() { + let fixture = make_repo_with_conflicting_merge(); + let relative_path = std::path::Path::new("file.txt"); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("conflict model"); + fs::write( + fixture.path().join(".gitattributes"), + b"file.txt conflict-marker-size=3\n", + ) + .unwrap(); + let repo = git2::Repository::open(fixture.path()).unwrap(); + assert_eq!( + super::conflict_marker_size(&repo, relative_path, &diff.snapshot), + 7 + ); + + let error = super::apply_conflict_resolution( + fixture.path(), + relative_path, + super::ConflictResolutionInput::WorkingTree { + snapshot: diff.snapshot, + }, + ) + .unwrap_err(); + + assert!(error + .to_string() + .contains("still contains conflict markers")); + assert!(git2::Repository::open(fixture.path()) + .unwrap() + .index() + .unwrap() + .conflict_get(relative_path) + .is_ok()); + } + + #[test] + fn choosing_deleted_side_removes_file_and_index_conflict() { + let fixture = TempRepo::init(); + fixture.commit_file("deleted.txt", "base\n", "base"); + assert!(run_git(fixture.path(), &["checkout", "-b", "incoming"]) + .status + .success()); + assert!(run_git(fixture.path(), &["rm", "deleted.txt"]) + .status + .success()); + assert!( + run_git(fixture.path(), &["commit", "-m", "delete incoming"]) + .status + .success() + ); + assert!(run_git(fixture.path(), &["checkout", "main"]) + .status + .success()); + fixture.commit_file("deleted.txt", "current edit\n", "edit current"); + let merge = run_git(fixture.path(), &["merge", "incoming"]); + assert!( + !merge.status.success(), + "merge unexpectedly succeeded: stdout={} stderr={}", + String::from_utf8_lossy(&merge.stdout), + String::from_utf8_lossy(&merge.stderr) + ); + + let relative_path = std::path::Path::new("deleted.txt"); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("conflict model"); + super::apply_conflict_side( + fixture.path(), + relative_path, + super::ConflictSide::Theirs, + &diff.snapshot, + ) + .expect("choose deleted side"); + + assert!(!fixture.path().join(relative_path).exists()); + let index = git2::Repository::open(fixture.path()) + .unwrap() + .index() + .unwrap(); + assert!(index.conflict_get(relative_path).is_err()); + assert!(index.get_path(relative_path, 0).is_none()); + } + + #[cfg(unix)] + #[test] + fn choosing_deleted_side_removes_a_dangling_symlink() { + use std::os::unix::fs::symlink; + + let fixture = TempRepo::init(); + let relative_path = std::path::Path::new("link"); + let full_path = fixture.path().join(relative_path); + symlink("missing-base", &full_path).unwrap(); + fixture.stage(relative_path); + fixture.commit("base symlink"); + + assert!(run_git(fixture.path(), &["checkout", "-b", "incoming"]) + .status + .success()); + assert!(run_git(fixture.path(), &["rm", "link"]).status.success()); + assert!( + run_git(fixture.path(), &["commit", "-m", "delete incoming"]) + .status + .success() + ); + + assert!(run_git(fixture.path(), &["checkout", "main"]) + .status + .success()); + fs::remove_file(&full_path).unwrap(); + symlink("missing-current", &full_path).unwrap(); + fixture.stage(relative_path); + fixture.commit("change current symlink"); + assert!(!run_git(fixture.path(), &["merge", "incoming"]) + .status + .success()); + assert!(!full_path.exists(), "fixture must use a dangling symlink"); + assert!(fs::symlink_metadata(&full_path).is_ok()); + + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("conflict model"); + super::apply_conflict_side( + fixture.path(), + relative_path, + super::ConflictSide::Theirs, + &diff.snapshot, + ) + .expect("choose deleted side"); + + assert!(fs::symlink_metadata(&full_path).is_err()); + assert!(git2::Repository::open(fixture.path()) + .unwrap() + .index() + .unwrap() + .conflict_get(relative_path) + .is_err()); + } + + #[cfg(unix)] + #[test] + fn regular_side_replaces_symlink_without_writing_through_it() { + use std::os::unix::fs::symlink; + + let fixture = make_repo_with_conflicting_merge(); + let relative_path = std::path::Path::new("file.txt"); + let full_path = fixture.path().join(relative_path); + let target_path = fixture.path().join("outside.txt"); + fs::write(&target_path, b"must stay unchanged\n").unwrap(); + fs::remove_file(&full_path).unwrap(); + symlink("outside.txt", &full_path).unwrap(); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("conflict model"); + + super::apply_conflict_side( + fixture.path(), + relative_path, + super::ConflictSide::Ours, + &diff.snapshot, + ) + .expect("choose regular side"); + + assert_eq!(fs::read(&target_path).unwrap(), b"must stay unchanged\n"); + let metadata = fs::symlink_metadata(&full_path).unwrap(); + assert!(metadata.is_file()); + assert!(!metadata.file_type().is_symlink()); + assert_eq!(fs::read(&full_path).unwrap(), b"current\n"); + } + + #[cfg(unix)] + #[test] + fn conflict_resolution_rejects_symlinked_parent_directory() { + use std::os::unix::fs::symlink; + + let fixture = TempRepo::init(); + let target = fixture.path().join("target"); + fs::create_dir(&target).unwrap(); + symlink("target", fixture.path().join("linked")).unwrap(); + + let error = super::AnchoredWorktreePath::bind( + fixture.path(), + std::path::Path::new("linked/file.txt"), + true, + ) + .err() + .expect("symlinked parent must be rejected"); + assert!(error + .to_string() + .contains("without following symbolic links")); + } + + #[cfg(unix)] + #[test] + fn conflict_publication_rejects_a_parent_replaced_by_a_symlink() { + use std::os::unix::fs::symlink; + + let fixture = TempRepo::init(); + let outside = tempfile::tempdir().unwrap(); + let nested = fixture.path().join("nested"); + let moved = fixture.path().join("moved"); + let path = nested.join("file.txt"); + let outside_path = outside.path().join("file.txt"); + fs::create_dir(&nested).unwrap(); + fs::write(&path, b"captured\n").unwrap(); + fs::write(&outside_path, b"outside\n").unwrap(); + let path_access = super::AnchoredWorktreePath::bind( + fixture.path(), + std::path::Path::new("nested/file.txt"), + true, + ) + .unwrap(); + let captured = super::capture_worktree_entry(&path_access).unwrap(); + + fs::rename(&nested, &moved).unwrap(); + symlink(outside.path(), &nested).unwrap(); + + let error = super::replace_regular_worktree_file( + &path_access, + b"resolver result\n", + 0o100644, + &captured, + ) + .unwrap_err(); + + assert!(error.to_string().contains("parent directory changed")); + assert_eq!(fs::read(&outside_path).unwrap(), b"outside\n"); + assert_eq!(fs::read(moved.join("file.txt")).unwrap(), b"captured\n"); + } + + #[test] + fn regular_worktree_compare_and_swap_preserves_a_later_edit() { + let fixture = TempRepo::init(); + let path = fixture.path().join("file.txt"); + fs::write(&path, b"captured\n").unwrap(); + let path_access = anchored(&path); + let captured = super::capture_worktree_entry(&path_access).unwrap(); + fs::write(&path, b"saved later\n").unwrap(); + + let error = super::replace_regular_worktree_file( + &path_access, + b"resolver result\n", + 0o100644, + &captured, + ) + .unwrap_err(); + + assert!(error.to_string().contains("changed while")); + assert_eq!(fs::read(&path).unwrap(), b"saved later\n"); + } + + #[test] + fn deletion_compare_and_swap_preserves_a_later_edit() { + let fixture = TempRepo::init(); + let path = fixture.path().join("file.txt"); + fs::write(&path, b"captured\n").unwrap(); + let path_access = anchored(&path); + let captured = super::capture_worktree_entry(&path_access).unwrap(); + fs::write(&path, b"saved later\n").unwrap(); + + let error = super::remove_worktree_entry_if_matches(&path_access, &captured).unwrap_err(); + + assert!(error.to_string().contains("changed while")); + assert_eq!(fs::read(&path).unwrap(), b"saved later\n"); + } + + #[test] + fn rejected_directory_deletion_leaves_the_directory_in_place() { + let fixture = TempRepo::init(); + let path = fixture.path().join("directory"); + fs::create_dir(&path).unwrap(); + fs::write(path.join("keep.txt"), b"keep\n").unwrap(); + let path_access = anchored(&path); + let captured = super::capture_worktree_entry(&path_access).unwrap(); + + let error = super::remove_worktree_entry_if_matches(&path_access, &captured).unwrap_err(); + + assert!(error.to_string().contains("directory")); + assert_eq!(fs::read(path.join("keep.txt")).unwrap(), b"keep\n"); + assert!(!fs::read_dir(fixture.path()).unwrap().any(|entry| { + entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(".rgitui-conflict-delete-") + })); + } + + #[test] + fn special_publication_preserves_an_entry_that_races_checkout() { + let fixture = TempRepo::init(); + let path = fixture.path().join("link"); + fs::write(&path, b"original\n").unwrap(); + let path_access = anchored(&path); + let captured = super::capture_worktree_entry(&path_access).unwrap(); + + let error = super::publish_special_worktree_entry(&path_access, &captured, || { + fs::write(&path, b"saved later\n")?; + anyhow::bail!("safe checkout refused the racing entry") + }) + .unwrap_err(); + + assert!(error.to_string().contains("was preserved")); + assert!(error.to_string().contains("remains recoverable")); + assert_eq!(fs::read(&path).unwrap(), b"saved later\n"); + let backup = fs::read_dir(fixture.path()) + .unwrap() + .map(|entry| entry.unwrap().path()) + .find(|entry| { + entry + .file_name() + .unwrap() + .to_string_lossy() + .starts_with(".rgitui-conflict-special-") + }) + .expect("original backup"); + assert_eq!(fs::read(backup).unwrap(), b"original\n"); + } + + #[test] + fn regular_publication_falls_back_when_hard_links_are_unavailable() { + let fixture = TempRepo::init(); + let temp_path = fixture.path().join("prepared.tmp"); + let path = fixture.path().join("file.txt"); + fs::write(&temp_path, b"resolver result\n").unwrap(); + let path_access = anchored(&path); + let prepared = super::capture_worktree_entry(&anchored(&temp_path)).unwrap(); + + super::publish_regular_file_noclobber_inner( + &path_access, + std::path::Path::new("prepared.tmp"), + b"resolver result\n", + &prepared, + false, + ) + .unwrap(); + + assert_eq!(fs::read(&path).unwrap(), b"resolver result\n"); + assert_eq!(fs::read(&temp_path).unwrap(), b"resolver result\n"); + } + + #[test] + fn regular_publication_fallback_never_overwrites_a_new_path() { + let fixture = TempRepo::init(); + let temp_path = fixture.path().join("prepared.tmp"); + let path = fixture.path().join("file.txt"); + fs::write(&temp_path, b"resolver result\n").unwrap(); + fs::write(&path, b"saved later\n").unwrap(); + let path_access = anchored(&path); + let prepared = super::capture_worktree_entry(&anchored(&temp_path)).unwrap(); + + let error = super::publish_regular_file_noclobber_inner( + &path_access, + std::path::Path::new("prepared.tmp"), + b"resolver result\n", + &prepared, + false, + ) + .unwrap_err(); + + assert!(error.to_string().contains("newer entry was preserved")); + assert_eq!(fs::read(&path).unwrap(), b"saved later\n"); + } + + #[test] + fn failed_fallback_cleanup_removes_only_the_file_it_created() { + let fixture = TempRepo::init(); + let path = fixture.path().join("file.txt"); + let displaced = fixture.path().join("displaced.txt"); + let created = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .unwrap(); + let identity = same_file::Handle::from_file(created.try_clone().unwrap()).unwrap(); + drop(created); + + let path_access = anchored(&path); + super::remove_created_file_if_same(&path_access, &identity).unwrap(); + assert!(fs::symlink_metadata(&path).is_err()); + + fs::write(&path, b"first fallback\n").unwrap(); + let first = same_file::Handle::from_path(&path).unwrap(); + fs::rename(&path, &displaced).unwrap(); + fs::write(&path, b"saved later\n").unwrap(); + + let error = super::remove_created_file_if_same(&path_access, &first).unwrap_err(); + assert!(error.to_string().contains("newer entry")); + assert_eq!(fs::read(&path).unwrap(), b"saved later\n"); + assert_eq!(fs::read(&displaced).unwrap(), b"first fallback\n"); + } + + #[test] + fn guarded_rollback_preserves_an_edit_made_after_resolution() { + let fixture = TempRepo::init(); + let path = fixture.path().join("file.txt"); + fs::write(&path, b"original\n").unwrap(); + let path_access = anchored(&path); + let original = super::capture_worktree_entry(&path_access).unwrap(); + let applied = super::replace_regular_worktree_file( + &path_access, + b"resolver result\n", + 0o100644, + &original, + ) + .unwrap(); + fs::write(&path, b"saved after resolution\n").unwrap(); + + let error = super::restore_worktree_entry_if_unchanged(&path_access, &original, &applied) + .unwrap_err(); + + assert!(error + .to_string() + .contains("newer worktree entry was preserved")); + assert_eq!(fs::read(&path).unwrap(), b"saved after resolution\n"); + } + + #[cfg(windows)] + #[test] + fn rollback_restores_a_dangling_windows_directory_symlink() { + use std::os::windows::fs::{symlink_dir, FileTypeExt}; + + let fixture = TempRepo::init(); + let path = fixture.path().join("link"); + if symlink_dir("missing-target", &path).is_err() { + return; + } + let path_access = anchored(&path); + let original = super::capture_worktree_entry(&path_access).unwrap(); + let applied = super::replace_regular_worktree_file( + &path_access, + b"resolver result\n", + 0o100644, + &original, + ) + .unwrap(); + + super::restore_worktree_entry_if_unchanged(&path_access, &original, &applied).unwrap(); + + let metadata = fs::symlink_metadata(&path).unwrap(); + assert!(metadata.file_type().is_symlink_dir()); + assert_eq!( + fs::read_link(&path).unwrap(), + std::path::Path::new("missing-target") + ); + } + + #[test] + fn choosing_a_gitlink_side_stages_the_commit_without_blob_lookup() { + let fixture = TempRepo::init(); + let ancestor = fixture.commit_file("seed.txt", "ancestor\n", "ancestor"); + let ours = fixture.commit_file("seed.txt", "ours\n", "ours"); + let theirs = fixture.commit_file("seed.txt", "theirs\n", "theirs"); + install_gitlink_conflict(&fixture, "module", ancestor, ours, theirs); + let relative_path = std::path::Path::new("module"); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("conflict model"); + + super::apply_conflict_side( + fixture.path(), + relative_path, + super::ConflictSide::Theirs, + &diff.snapshot, + ) + .expect("choose incoming gitlink"); + + let index = git2::Repository::open(fixture.path()) + .unwrap() + .index() + .unwrap(); + assert!(index.conflict_get(relative_path).is_err()); + let entry = index.get_path(relative_path, 0).expect("stage-0 gitlink"); + assert_eq!(entry.mode, 0o160000); + assert_eq!(entry.id, theirs); + } + + #[test] + fn choosing_deleted_gitlink_side_leaves_checked_out_directory_untracked() { + let fixture = TempRepo::init(); + let ancestor = fixture.commit_file("seed.txt", "ancestor\n", "ancestor"); + let ours = fixture.commit_file("seed.txt", "ours\n", "ours"); + install_gitlink_modify_delete_conflict(&fixture, "module", ancestor, ours); + let relative_path = std::path::Path::new("module"); + fs::create_dir(fixture.path().join(relative_path)).unwrap(); + fs::write( + fixture.path().join(relative_path).join("local.txt"), + b"local work\n", + ) + .unwrap(); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("gitlink delete conflict model"); + + super::apply_conflict_side( + fixture.path(), + relative_path, + super::ConflictSide::Theirs, + &diff.snapshot, + ) + .expect("choose deleted gitlink side"); + + assert_eq!( + fs::read(fixture.path().join(relative_path).join("local.txt")).unwrap(), + b"local work\n" + ); + let index = git2::Repository::open(fixture.path()) + .unwrap() + .index() + .unwrap(); + assert!(index.conflict_get(relative_path).is_err()); + assert!(index.get_path(relative_path, 0).is_none()); + } + + #[test] + fn choosing_deleted_gitlink_side_removes_a_regular_worktree_file() { + let fixture = TempRepo::init(); + let ancestor = fixture.commit_file("seed.txt", "ancestor\n", "ancestor"); + let ours = fixture.commit_file("seed.txt", "ours\n", "ours"); + install_gitlink_modify_delete_conflict(&fixture, "module", ancestor, ours); + let relative_path = std::path::Path::new("module"); + fs::write( + fixture.path().join(relative_path), + b"not a submodule directory\n", + ) + .unwrap(); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("gitlink delete conflict model"); + + super::apply_conflict_side( + fixture.path(), + relative_path, + super::ConflictSide::Theirs, + &diff.snapshot, + ) + .expect("choose deleted gitlink side"); + + assert!(!fixture.path().join(relative_path).exists()); + let index = git2::Repository::open(fixture.path()) + .unwrap() + .index() + .unwrap(); + assert!(index.conflict_get(relative_path).is_err()); + assert!(index.get_path(relative_path, 0).is_none()); + } + + #[test] + fn failed_special_entry_checkout_leaves_the_conflict_unresolved() { + let fixture = TempRepo::init(); + let repo = git2::Repository::open(fixture.path()).unwrap(); + let ancestor = repo.blob(b"ancestor-target").unwrap(); + let ours = repo.blob(b"current-target").unwrap(); + let theirs = repo.blob(b"incoming-target").unwrap(); + drop(repo); + install_index_conflict(&fixture, "link", 0o120000, ancestor, ours, theirs); + let relative_path = std::path::Path::new("link"); + fs::create_dir(fixture.path().join(relative_path)).unwrap(); + fs::write( + fixture.path().join(relative_path).join("keep.txt"), + b"keep\n", + ) + .unwrap(); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("special conflict model"); + + let error = super::apply_conflict_side( + fixture.path(), + relative_path, + super::ConflictSide::Ours, + &diff.snapshot, + ) + .unwrap_err(); + + assert!(error.to_string().contains("directory")); + assert_eq!( + fs::read(fixture.path().join(relative_path).join("keep.txt")).unwrap(), + b"keep\n" + ); + let index = git2::Repository::open(fixture.path()) + .unwrap() + .index() + .unwrap(); + assert!(index.conflict_get(relative_path).is_ok()); + assert!(index.get_path(relative_path, 0).is_none()); + } + + #[test] + fn choosing_a_symlink_side_uses_safe_special_publication() { + let fixture = TempRepo::init(); + let repo = git2::Repository::open(fixture.path()).unwrap(); + repo.config() + .unwrap() + .set_bool("core.symlinks", false) + .unwrap(); + let ancestor = repo.blob(b"ancestor-target").unwrap(); + let ours = repo.blob(b"current-target").unwrap(); + let theirs = repo.blob(b"incoming-target").unwrap(); + drop(repo); + install_index_conflict(&fixture, "link", 0o120000, ancestor, ours, theirs); + let relative_path = std::path::Path::new("link"); + fs::write(fixture.path().join(relative_path), b"original worktree\n").unwrap(); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("special conflict model"); + + super::apply_conflict_side( + fixture.path(), + relative_path, + super::ConflictSide::Theirs, + &diff.snapshot, + ) + .expect("choose incoming symlink"); + + assert_eq!( + fs::read(fixture.path().join(relative_path)).unwrap(), + b"incoming-target" + ); + let index = git2::Repository::open(fixture.path()) + .unwrap() + .index() + .unwrap(); + assert!(index.conflict_get(relative_path).is_err()); + let entry = index.get_path(relative_path, 0).expect("stage-0 symlink"); + assert_eq!(entry.mode, 0o120000); + assert_eq!(entry.id, theirs); + } + + #[cfg(unix)] + #[test] + fn choosing_a_symlink_side_creates_the_selected_link() { + let fixture = TempRepo::init(); + let repo = git2::Repository::open(fixture.path()).unwrap(); + repo.config() + .unwrap() + .set_bool("core.symlinks", true) + .unwrap(); + let ancestor = repo.blob(b"ancestor-target").unwrap(); + let ours = repo.blob(b"current-target").unwrap(); + let theirs = repo.blob(b"incoming-target").unwrap(); + drop(repo); + install_index_conflict(&fixture, "link", 0o120000, ancestor, ours, theirs); + let relative_path = std::path::Path::new("link"); + fs::write(fixture.path().join(relative_path), b"original worktree\n").unwrap(); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("special conflict model"); + + super::apply_conflict_side( + fixture.path(), + relative_path, + super::ConflictSide::Theirs, + &diff.snapshot, + ) + .expect("choose incoming symlink"); + + let full_path = fixture.path().join(relative_path); + assert!(fs::symlink_metadata(&full_path) + .unwrap() + .file_type() + .is_symlink()); + assert_eq!( + fs::read_link(&full_path).unwrap(), + std::path::Path::new("incoming-target") + ); + let index = git2::Repository::open(fixture.path()) + .unwrap() + .index() + .unwrap(); + assert!(index.conflict_get(relative_path).is_err()); + let entry = index.get_path(relative_path, 0).expect("stage-0 symlink"); + assert_eq!(entry.mode, 0o120000); + assert_eq!(entry.id, theirs); + } + + #[test] + fn failed_special_entry_index_write_restores_the_worktree() { + let fixture = TempRepo::init(); + let repo = git2::Repository::open(fixture.path()).unwrap(); + let ancestor = repo.blob(b"ancestor-target").unwrap(); + let ours = repo.blob(b"current-target").unwrap(); + let theirs = repo.blob(b"incoming-target").unwrap(); + drop(repo); + install_index_conflict(&fixture, "link", 0o120000, ancestor, ours, theirs); + let relative_path = std::path::Path::new("link"); + fs::write(fixture.path().join(relative_path), b"original worktree\n").unwrap(); + let diff = super::super::compute_three_way_conflict_diff(fixture.path(), relative_path) + .expect("special conflict model"); + let lock_path = git2::Repository::open(fixture.path()) + .unwrap() + .path() + .join("index.lock"); + let lock = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&lock_path) + .unwrap(); + + let error = super::apply_conflict_side( + fixture.path(), + relative_path, + super::ConflictSide::Ours, + &diff.snapshot, + ) + .unwrap_err(); + + drop(lock); + fs::remove_file(lock_path).unwrap(); + assert!(!error.to_string().is_empty()); + assert_eq!( + fs::read(fixture.path().join(relative_path)).unwrap(), + b"original worktree\n" + ); + assert!(git2::Repository::open(fixture.path()) + .unwrap() + .index() + .unwrap() + .conflict_get(relative_path) + .is_ok()); + } + + #[test] + fn run_continue_subcommand_finishes_a_resolved_cherry_pick() { + let fixture = make_repo_with_conflicting_pick(); + let path = fixture.path(); + + let picked = run_git(path, &["rev-parse", "feature"]); + let picked = String::from_utf8_lossy(&picked.stdout).trim().to_string(); + run_git(path, &["cherry-pick", &picked]); + + let repo = git2::Repository::open(path).unwrap(); + assert_eq!(repo.state(), git2::RepositoryState::CherryPick); + drop(repo); + + // Resolve the conflict the way the user would, then continue. + fs::write(path.join("file.txt"), "resolved\n").unwrap(); + run_git(path, &["add", "file.txt"]); + + let summary = run_continue_subcommand(path, "cherry-pick").unwrap(); + assert!(!summary.is_empty()); + + let repo = git2::Repository::open(path).unwrap(); + assert_eq!(repo.state(), git2::RepositoryState::Clean); + let head = repo.head().unwrap().peel_to_commit().unwrap(); + assert_eq!(head.summary(), Some("feature change")); + } + + #[test] + fn run_continue_subcommand_reports_why_git_refused() { + // Nothing is in progress, so `git cherry-pick --continue` fails; the + // error has to carry git's reason rather than a bare exit status. + let fixture = make_test_repo(); + let error = run_continue_subcommand(fixture.path(), "cherry-pick").unwrap_err(); + let message = error.to_string(); + assert!( + message.contains("cherry-pick --continue failed"), + "unexpected error: {message}" + ); + assert!(message.len() > "git cherry-pick --continue failed: ".len()); + } + + /// A repo whose single commit has an empty tree, so a test is free to create + /// and stage `file.txt` itself without colliding with an existing entry. + fn make_test_repo() -> TempRepo { + let repo = TempRepo::init(); + repo.commit("initial commit"); + repo + } + + /// Make a repo whose `feature` branch adds `path_in_repo`, with HEAD left on + /// `main` one commit behind and the file absent from the work tree — the + /// shape a fast-forward merge sees. + fn make_repo_with_ff_branch(path_in_repo: &str, contents: &str) -> TempRepo { + let fixture = TempRepo::init(); + let base = fixture.commit("initial"); + + // Scoped so every borrow of `fixture` — and every libgit2 handle with a + // `Drop` that keeps one alive — is released before the fixture moves out. + { + let repo = fixture.repo(); + // Build the branch commit straight from a tree so the work tree + // keeps looking like `main`. + let blob = repo.blob(contents.as_bytes()).unwrap(); + let mut builder = repo.treebuilder(None).unwrap(); + builder.insert(path_in_repo, blob, 0o100644).unwrap(); + let branch_tree_oid = builder.write().unwrap(); + fixture.commit_tree( + Some("refs/heads/feature"), + "add file", + branch_tree_oid, + &[base], + ); + } + + fixture + } + + #[test] + fn checkout_tree_safe_refuses_to_clobber_an_untracked_file() { + let fixture = make_repo_with_ff_branch("notes.txt", "FROM BRANCH\n"); + let untracked = fixture.path().join("notes.txt"); + fs::write(&untracked, "PRECIOUS UNTRACKED\n").unwrap(); + + let repo = git2::Repository::open(fixture.path()).unwrap(); + let target = repo.revparse_single("refs/heads/feature").unwrap(); + let err = super::checkout_tree_safe(&repo, &target, "Merge").unwrap_err(); + + let msg = err.to_string(); + assert!( + msg.contains("notes.txt"), + "error should name the file: {msg}" + ); + assert!( + msg.starts_with("Merge would overwrite"), + "error should be actionable: {msg}" + ); + assert_eq!( + fs::read_to_string(&untracked).unwrap(), + "PRECIOUS UNTRACKED\n", + "a refused checkout must leave the untracked file alone" + ); + } + + #[test] + fn checkout_tree_safe_applies_when_nothing_collides() { + let fixture = make_repo_with_ff_branch("notes.txt", "FROM BRANCH\n"); + + let repo = git2::Repository::open(fixture.path()).unwrap(); + let target = repo.revparse_single("refs/heads/feature").unwrap(); + super::checkout_tree_safe(&repo, &target, "Merge").unwrap(); + + assert_eq!( + fs::read_to_string(fixture.path().join("notes.txt")).unwrap(), + "FROM BRANCH\n" + ); + } + + #[test] + fn format_path_list_summarises_beyond_three() { + assert_eq!(super::format_path_list(&["a.txt".to_string()]), "a.txt"); + + let many: Vec = (0..5).map(|i| format!("f{i}.txt")).collect(); + assert_eq!( + super::format_path_list(&many), + "f0.txt, f1.txt, f2.txt and 2 more" + ); + } + + /// Collect commits via revwalk. index 0 = newest. + fn collect_commits(repo: &git2::Repository) -> Vec { + let mut rw = repo.revwalk().unwrap(); + rw.push_head().unwrap(); + rw.collect::, _>>().unwrap() + } + + // ------------------------------------------------------------------------- + // Stash tests + // ------------------------------------------------------------------------- + + #[test] + fn stash_save_and_pop() { + let fixture = make_test_repo(); + let path = fixture.path(); + let mut repo = git2::Repository::open(path).unwrap(); + + fs::write(path.join("file.txt"), "hello").unwrap(); + repo.index() + .unwrap() + .add_path(std::path::Path::new("file.txt")) + .unwrap(); + repo.index().unwrap().write().unwrap(); + + let sig = repo.signature().unwrap(); + repo.stash_save(&sig, "WIP: test stash", None).unwrap(); + + // After stash save, pop it. The file will be restored with "hello". + repo.stash_pop(0, None).unwrap(); + + // File should be restored with the stashed content + let content = fs::read_to_string(path.join("file.txt")).unwrap(); + assert_eq!(content, "hello"); + } + + #[test] + #[ignore = "git2's stash_apply requires clean index+working-tree AND checks index state match; the stash index differs from post-reset index causing 'uncommitted changes' error. Test git2 semantics, not application behavior."] + fn stash_apply_keeps_stash() { + let fixture = make_test_repo(); + let path = fixture.path(); + let mut repo = git2::Repository::open(path).unwrap(); + + fs::write(path.join("file.txt"), "hello").unwrap(); + repo.index() + .unwrap() + .add_path(std::path::Path::new("file.txt")) + .unwrap(); + repo.index().unwrap().write().unwrap(); + + let sig = repo.signature().unwrap(); + repo.stash_save(&sig, "WIP: test stash", None).unwrap(); + + // After stash_save: working tree is clean (empty), index is dirty (staged hello). + // git2's stash_apply requires clean index. Clean the index by committing the + // staged change, then reset the index to HEAD to make it clean. + { + let head_oid = repo.head().unwrap().target().unwrap(); + let head_commit = repo.find_commit(head_oid).unwrap(); + let tree_oid = repo.index().unwrap().write_tree().unwrap(); + let tree = repo.find_tree(tree_oid).unwrap(); + repo.commit( + Some("HEAD"), + &sig, + &sig, + head_commit.message().unwrap(), + &tree, + &[&head_commit], + ) + .unwrap(); + } + // Commit done; working tree still has "hello", index is now empty. + // Now reset the index to match HEAD (which now points to commit with hello). + { + let head_oid = repo.head().unwrap().target().unwrap(); + let head_obj = repo.find_object(head_oid, None).unwrap(); + repo.reset(&head_obj, git2::ResetType::Soft, None).unwrap(); + drop(head_obj); + } + // Now: working tree has "hello", index is clean, HEAD points to commit with hello. + // Apply stash: stash has (working_tree=hello, index=[hello staged]). + // After apply: working_tree=hello, index=[hello staged], stash still present. + repo.stash_apply(0, None).unwrap(); + + // Verify stash_pop succeeds (stash is still present, working tree matches). + repo.stash_pop(0, None).unwrap(); + + let content = fs::read_to_string(path.join("file.txt")).unwrap(); + assert_eq!(content, "hello"); + } + + #[test] + fn stash_drop_removes_entry() { + let fixture = make_test_repo(); + let path = fixture.path(); + let mut repo = git2::Repository::open(path).unwrap(); + + fs::write(path.join("file.txt"), "changes").unwrap(); + repo.index() + .unwrap() + .add_path(std::path::Path::new("file.txt")) + .unwrap(); + repo.index().unwrap().write().unwrap(); + + let sig = repo.signature().unwrap(); + repo.stash_save(&sig, "WIP: test", None).unwrap(); + + repo.stash_drop(0).unwrap(); + + // Trying to pop should now fail + assert!(repo.stash_pop(0, None).is_err()); + } + + #[test] + fn stash_branch_creates_branch_from_stash() { + let fixture = make_test_repo(); + let path = fixture.path(); + let mut repo = git2::Repository::open(path).unwrap(); + + fs::write(path.join("file.txt"), "stash changes").unwrap(); + repo.index() + .unwrap() + .add_path(std::path::Path::new("file.txt")) + .unwrap(); + repo.index().unwrap().write().unwrap(); + + let sig = repo.signature().unwrap(); + repo.stash_save(&sig, "WIP: test", None).unwrap(); + + // Get stash OID using a separate repo instance for stash_foreach + let stash_oid = { + let mut r = git2::Repository::open(path).unwrap(); + let mut found = git2::Oid::zero(); + r.stash_foreach(|_i, _msg, oid| { + found = *oid; + false + }) + .unwrap(); + found + }; + + // `git stash branch` creates the branch at the stash's BASE commit + // (the stash WIP commit's first parent), not at the WIP commit itself. + let stash_wip = repo.find_commit(stash_oid).unwrap(); + let base = stash_wip.parent(0).unwrap(); + let base_oid = base.id(); + repo.branch("stash-branch", &base, false).unwrap(); + + let branch = repo + .find_branch("stash-branch", git2::BranchType::Local) + .unwrap(); + assert_eq!(branch.get().target().unwrap(), base_oid); + assert_ne!(base_oid, stash_oid); + } + + // ------------------------------------------------------------------------- + // Reset tests + // ------------------------------------------------------------------------- + + #[test] + fn reset_hard_discards_changes() { + let fixture = make_test_repo(); + let path = fixture.path(); + let repo = git2::Repository::open(path).unwrap(); + + fs::write(path.join("file.txt"), "modified").unwrap(); + repo.index() + .unwrap() + .add_path(std::path::Path::new("file.txt")) + .unwrap(); + repo.index().unwrap().write().unwrap(); + + let head_oid = repo.head().unwrap().target().unwrap(); + let head_commit = repo.find_commit(head_oid).unwrap(); + repo.reset(head_commit.as_object(), git2::ResetType::Hard, None) + .unwrap(); + + assert!(!path.join("file.txt").exists()); + assert!(repo.index().unwrap().is_empty()); + } + + #[test] + fn reset_soft_preserves_index() { + // Reset soft to a prior commit: re-stage the index after reset, then commit. + // Note: git2's reset(Soft) does NOT stage the diff like CLI git reset --soft. + // It moves HEAD and resets the index to match the target commit. We re-stage + // the file after reset to emulate the CLI behavior and verify it works. + let fixture = TempRepo::with_commits(2); + let path = fixture.path(); + let repo = git2::Repository::open(path).unwrap(); + + // Add a change on top of the current HEAD + fs::write(path.join("file.txt"), "extra").unwrap(); + repo.index() + .unwrap() + .add_path(std::path::Path::new("file.txt")) + .unwrap(); + repo.index().unwrap().write().unwrap(); + + // Get current HEAD + let head_oid = repo.head().unwrap().target().unwrap(); + + // Reset soft to the first (older) commit — this moves HEAD and resets the + // index to match the target commit's tree (no diff is auto-staged by git2). + let commits = collect_commits(&repo); + let old_oid = *commits.last().unwrap(); // oldest = first commit + let old_commit = repo.find_commit(old_oid).unwrap(); + repo.reset(old_commit.as_object(), git2::ResetType::Soft, None) + .unwrap(); + + // git2 reset(Soft) clears the index to match the target tree. Re-stage + // the file and commit to verify the working tree change is preserved. + repo.index() + .unwrap() + .add_path(std::path::Path::new("file.txt")) + .unwrap(); + repo.index().unwrap().write().unwrap(); + + // Commit the re-staged change + let new_head_oid = repo.head().unwrap().target().unwrap(); + let tree_oid = repo.index().unwrap().write_tree().unwrap(); + fixture.commit_tree( + Some("HEAD"), + "reset soft with re-staged change", + tree_oid, + &[new_head_oid], + ); + + // The new commit should have the "extra" file in its tree + let new_tree = repo + .find_commit(repo.head().unwrap().target().unwrap()) + .unwrap() + .tree() + .unwrap(); + assert!(new_tree.get_name("file.txt").is_some()); + assert_ne!(repo.head().unwrap().target().unwrap(), head_oid); + } + + #[test] + fn reset_mixed_unsets_index() { + // Mixed reset: index is cleared, but working tree file stays. + let fixture = make_test_repo(); + let path = fixture.path(); + let repo = git2::Repository::open(path).unwrap(); + + fs::write(path.join("file.txt"), "modified").unwrap(); + repo.index() + .unwrap() + .add_path(std::path::Path::new("file.txt")) + .unwrap(); + repo.index().unwrap().write().unwrap(); + + let head_oid = repo.head().unwrap().target().unwrap(); + let head_commit = repo.find_commit(head_oid).unwrap(); + + // Mixed reset to HEAD: unstages the index entry + repo.reset(head_commit.as_object(), git2::ResetType::Mixed, None) + .unwrap(); + + // Index should be empty (changes unstaged) + assert!(repo.index().unwrap().is_empty()); + } + + #[test] + fn reset_to_commit_moves_head() { + let fixture = TempRepo::with_commits(3); + let path = fixture.path(); + let repo = git2::Repository::open(path).unwrap(); + + let commits = collect_commits(&repo); + let first_oid = *commits.last().unwrap(); // oldest + let first_commit = repo.find_commit(first_oid).unwrap(); + + repo.reset(first_commit.as_object(), git2::ResetType::Hard, None) + .unwrap(); + + let head_oid = repo.head().unwrap().target().unwrap(); + assert_eq!(head_oid, first_oid); + } + + // ------------------------------------------------------------------------- + // Merge tests + // ------------------------------------------------------------------------- + + #[test] + fn merge_branch_fast_forward() { + let fixture = make_test_repo(); + let path = fixture.path(); + let repo = git2::Repository::open(path).unwrap(); + + let head_oid = repo.head().unwrap().target().unwrap(); + + fs::write(path.join("file.txt"), "branch changes").unwrap(); + let tree_oid = repo.index().unwrap().write_tree().unwrap(); + fixture.commit_tree( + Some("refs/heads/feature"), + "feature commit", + tree_oid, + &[head_oid], + ); + + let feature_branch = repo + .find_branch("feature", git2::BranchType::Local) + .unwrap(); + let annotated = repo + .reference_to_annotated_commit(feature_branch.get()) + .unwrap(); + let (analysis, _) = repo.merge_analysis(&[&annotated]).unwrap(); + assert!(analysis.is_fast_forward()); + + let mut reference = repo.find_reference("refs/heads/main").unwrap(); + reference + .set_target(annotated.id(), "fast-forward") + .unwrap(); + repo.set_head("refs/heads/main").unwrap(); + } + + #[test] + #[ignore = "git2's merge does not create conflict markers for trivial content changes; libgit2 auto-merges non-overlapping modifications without conflict markers. Use GitProject::merge_branch integration tests instead."] + fn merge_branch_with_conflict() { + let fixture = make_test_repo(); + let path = fixture.path(); + let repo = git2::Repository::open(path).unwrap(); + + // Commit on main with a single-line file + fs::write(path.join("file.txt"), "line one\n").unwrap(); + { + let mut idx = repo.index().unwrap(); + idx.add_path(std::path::Path::new("file.txt")).unwrap(); + idx.write().unwrap(); + } + let tree_oid = repo.index().unwrap().write_tree().unwrap(); + let head_oid = repo.head().unwrap().target().unwrap(); + fixture.commit_tree( + Some("refs/heads/main"), + "main change", + tree_oid, + &[head_oid], + ); + + // Feature branch: modify the SAME LINE differently (creates real conflict) + fs::write(path.join("file.txt"), "main one\n").unwrap(); + { + let mut idx = repo.index().unwrap(); + idx.add_path(std::path::Path::new("file.txt")).unwrap(); + idx.write().unwrap(); + } + let tree_oid = repo.index().unwrap().write_tree().unwrap(); + let head_oid = repo.head().unwrap().target().unwrap(); + fixture.commit_tree( + Some("refs/heads/feature"), + "feature change", + tree_oid, + &[head_oid], + ); + + // Switch back to main (force checkout) + { + let mut opts = git2::build::CheckoutBuilder::new(); + opts.force(); + repo.checkout_head(Some(&mut opts)).unwrap(); + } + + let feature_branch = repo + .find_branch("feature", git2::BranchType::Local) + .unwrap(); + let annotated = repo + .reference_to_annotated_commit(feature_branch.get()) + .unwrap(); + repo.merge(&[&annotated], None, None).unwrap(); + + assert!(repo.index().unwrap().has_conflicts()); + + // Abort + let head_oid = repo.head().unwrap().target().unwrap(); + let head_commit = repo.find_commit(head_oid).unwrap(); + repo.reset( + head_commit.as_object(), + git2::ResetType::Hard, + Some(git2::build::CheckoutBuilder::new().force()), + ) + .unwrap(); + repo.cleanup_state().unwrap(); + } + + #[test] + #[ignore = "git2's merge does not produce conflict markers for non-overlapping content changes (unlike CLI git). Tests git2 merge semantics, not application behavior."] + fn abort_operation_cleans_merge_state() { + let fixture = make_test_repo(); + let path = fixture.path(); + let repo = git2::Repository::open(path).unwrap(); + + // Commit on main with single-line file + fs::write(path.join("file.txt"), "line one\n").unwrap(); + { + let mut idx = repo.index().unwrap(); + idx.add_path(std::path::Path::new("file.txt")).unwrap(); + idx.write().unwrap(); + } + let tree_oid = repo.index().unwrap().write_tree().unwrap(); + let head_oid = repo.head().unwrap().target().unwrap(); + fixture.commit_tree( + Some("refs/heads/main"), + "main change", + tree_oid, + &[head_oid], + ); + + // Feature branch: modify the SAME LINE differently (creates real conflict) + fs::write(path.join("file.txt"), "other one\n").unwrap(); + { + let mut idx = repo.index().unwrap(); + idx.add_path(std::path::Path::new("file.txt")).unwrap(); + idx.write().unwrap(); + } + let tree_oid = repo.index().unwrap().write_tree().unwrap(); + let head_oid = repo.head().unwrap().target().unwrap(); + fixture.commit_tree( + Some("refs/heads/feature"), + "feature change", + tree_oid, + &[head_oid], + ); + + // Switch back to main (force checkout) + { + let mut opts = git2::build::CheckoutBuilder::new(); + opts.force(); + repo.checkout_head(Some(&mut opts)).unwrap(); + } + + let feature_branch = repo + .find_branch("feature", git2::BranchType::Local) + .unwrap(); + let annotated = repo + .reference_to_annotated_commit(feature_branch.get()) + .unwrap(); + repo.merge(&[&annotated], None, None).unwrap(); + + assert!(repo.index().unwrap().has_conflicts()); + + let head_oid = repo.head().unwrap().target().unwrap(); + let head_commit = repo.find_commit(head_oid).unwrap(); + repo.reset( + head_commit.as_object(), + git2::ResetType::Hard, + Some(git2::build::CheckoutBuilder::new().force()), + ) + .unwrap(); + repo.cleanup_state().unwrap(); + + assert_eq!(repo.state(), git2::RepositoryState::Clean); + } + + // ------------------------------------------------------------------------- + // Cherry-pick / revert tests + // ------------------------------------------------------------------------- + + #[test] + fn cherry_pick_creates_new_commit() { + let fixture = TempRepo::with_commits(2); + let path = fixture.path(); + let repo = git2::Repository::open(path).unwrap(); + + let commits = collect_commits(&repo); + let oldest_oid = *commits.last().unwrap(); + let commit = repo.find_commit(oldest_oid).unwrap(); + + repo.cherrypick(&commit, None).unwrap(); + let new_head_oid = repo.head().unwrap().target().unwrap(); + assert_ne!(new_head_oid, oldest_oid); + repo.cleanup_state().unwrap(); + } + + #[test] + fn revert_creates_undo_commit() { + let fixture = TempRepo::with_commits(2); + let path = fixture.path(); + let repo = git2::Repository::open(path).unwrap(); + + let commits = collect_commits(&repo); + let oldest_oid = *commits.last().unwrap(); + let commit = repo.find_commit(oldest_oid).unwrap(); + + let mut opts = git2::RevertOptions::new(); + repo.revert(&commit, Some(&mut opts)).unwrap(); + let new_head_oid = repo.head().unwrap().target().unwrap(); + assert_ne!(new_head_oid, oldest_oid); + repo.cleanup_state().unwrap(); + } + + // ------------------------------------------------------------------------- + // Tag tests + // ------------------------------------------------------------------------- + + #[test] + fn create_and_delete_tag() { + let fixture = make_test_repo(); + let path = fixture.path(); + let head = fixture.head_oid(); + let repo = git2::Repository::open(path).unwrap(); + + let obj = repo.find_object(head, None).unwrap(); + repo.tag_lightweight("v1.0.0", &obj, false).unwrap(); + + let tag_ref = repo.find_reference("refs/tags/v1.0.0").unwrap(); + assert_eq!(tag_ref.target().unwrap(), head); + + repo.tag_delete("v1.0.0").unwrap(); + assert!(repo.find_reference("refs/tags/v1.0.0").is_err()); + } + + #[test] + fn create_multiple_tags() { + let fixture = make_test_repo(); + let path = fixture.path(); + let head = fixture.head_oid(); + let repo = git2::Repository::open(path).unwrap(); + + let obj = repo.find_object(head, None).unwrap(); + repo.tag_lightweight("v1.0.0", &obj, false).unwrap(); + repo.tag_lightweight("v1.0.1", &obj, false).unwrap(); + repo.tag_lightweight("v2.0.0", &obj, false).unwrap(); + + let tag_names = repo.tag_names(None).unwrap(); + let tags: Vec<_> = tag_names.iter().flatten().collect(); + assert!(tags.contains(&"v1.0.0")); + assert!(tags.contains(&"v1.0.1")); + assert!(tags.contains(&"v2.0.0")); + } + + // ------------------------------------------------------------------------- + // Branch tests + // ------------------------------------------------------------------------- + + #[test] + fn create_and_delete_branch() { + let fixture = make_test_repo(); + let path = fixture.path(); + let head = fixture.head_oid(); + let repo = git2::Repository::open(path).unwrap(); + + let commit = repo.find_commit(head).unwrap(); + repo.branch("feature", &commit, false).unwrap(); + + let branch = repo + .find_branch("feature", git2::BranchType::Local) + .unwrap(); + assert_eq!(branch.get().target().unwrap(), head); + + let mut b = repo + .find_branch("feature", git2::BranchType::Local) + .unwrap(); + b.delete().unwrap(); + assert!(repo + .find_branch("feature", git2::BranchType::Local) + .is_err()); + } + + #[test] + fn rename_branch() { + let fixture = make_test_repo(); + let path = fixture.path(); + let head = fixture.head_oid(); + let repo = git2::Repository::open(path).unwrap(); + + let commit = repo.find_commit(head).unwrap(); + repo.branch("old-name", &commit, false).unwrap(); + + let mut branch = repo + .find_branch("old-name", git2::BranchType::Local) + .unwrap(); + branch.rename("new-name", false).unwrap(); + + assert!(repo + .find_branch("old-name", git2::BranchType::Local) + .is_err()); + let renamed = repo + .find_branch("new-name", git2::BranchType::Local) + .unwrap(); + assert_eq!(renamed.get().target().unwrap(), head); + } + + #[test] + fn create_branch_at_specific_commit() { + let fixture = TempRepo::with_commits(3); + let path = fixture.path(); + let repo = git2::Repository::open(path).unwrap(); + + let commits = collect_commits(&repo); + let second_oid = *commits.get(1).unwrap(); // second commit from newest + + let commit = repo.find_commit(second_oid).unwrap(); + repo.branch("at-second", &commit, false).unwrap(); + + let branch = repo + .find_branch("at-second", git2::BranchType::Local) + .unwrap(); + assert_eq!(branch.get().target().unwrap(), second_oid); + } + + // ------------------------------------------------------------------------- + // Worktree tests + // ------------------------------------------------------------------------- + + #[test] + fn create_and_remove_worktree() { + let fixture = make_test_repo(); + let path = fixture.path(); + let head = fixture.head_oid(); + let repo = git2::Repository::open(path).unwrap(); + + let worktree_path = path.join("../worktree-dir"); + + repo.worktree("worktree-dir", &worktree_path, None).unwrap(); + assert!(worktree_path.exists()); + + let wt_repo = git2::Repository::open(&worktree_path).unwrap(); + assert_eq!(wt_repo.head().unwrap().target().unwrap(), head); + + let output = std::process::Command::new("git") + .current_dir(path) + .args([ + "worktree", + "remove", + "--force", + worktree_path.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!(output.status.success()); + assert!(!worktree_path.exists()); + } + + // ------------------------------------------------------------------------- + // Discard changes tests + // ------------------------------------------------------------------------- + + #[test] + fn discard_changes_removes_staged_file() { + let fixture = make_test_repo(); + let path = fixture.path(); + let repo = git2::Repository::open(path).unwrap(); + + fs::write(path.join("newfile.txt"), "content").unwrap(); + repo.index() + .unwrap() + .add_path(std::path::Path::new("newfile.txt")) + .unwrap(); + repo.index().unwrap().write().unwrap(); + + let mut checkout_opts = git2::build::CheckoutBuilder::new(); + checkout_opts.force(); + repo.checkout_head(Some(&mut checkout_opts)).unwrap(); + + assert!(!path.join("newfile.txt").exists()); + } + + // ------------------------------------------------------------------------- + // Bisect tests + // ------------------------------------------------------------------------- + + #[test] + fn bisect_start_and_reset() { + let fixture = TempRepo::with_commits(5); + let path = fixture.path(); + let repo = git2::Repository::open(path).unwrap(); + + let commits = collect_commits(&repo); + let oldest_oid = *commits.last().unwrap(); + let newest_oid = commits[0]; + + let output = std::process::Command::new("git") + .current_dir(path) + .args(["bisect", "start"]) + .output() + .unwrap(); + assert!(output.status.success()); + + let output = std::process::Command::new("git") + .current_dir(path) + .args(["bisect", "bad"]) + .output() + .unwrap(); + assert!(output.status.success()); + + let output = std::process::Command::new("git") + .current_dir(path) + .args(["bisect", "good", &oldest_oid.to_string()]) + .output() + .unwrap(); + assert!(output.status.success()); + + assert!(path.join(".git").join("BISECT_START").exists()); + + let output = std::process::Command::new("git") + .current_dir(path) + .args(["bisect", "reset"]) + .output() + .unwrap(); + assert!(output.status.success()); + + let head_oid = repo.head().unwrap().target().unwrap(); + assert_eq!(head_oid, newest_oid); + } + + // ------------------------------------------------------------------------- + // Checkout tests + // ------------------------------------------------------------------------- + + #[test] + fn checkout_branch_switches_head() { + let fixture = make_test_repo(); + let path = fixture.path(); + let repo = git2::Repository::open(path).unwrap(); + + let head_oid = repo.head().unwrap().target().unwrap(); + + fs::write(path.join("file.txt"), "feature").unwrap(); + { + let mut idx = repo.index().unwrap(); + idx.add_path(std::path::Path::new("file.txt")).unwrap(); + idx.write().unwrap(); + } + let tree_oid = repo.index().unwrap().write_tree().unwrap(); + fixture.commit_tree( + Some("refs/heads/feature"), + "feature commit", + tree_oid, + &[head_oid], + ); + + // Note: repo.commit does NOT update the working tree, so working tree still + // has "feature" while HEAD (main) has no file.txt. safe() would refuse this + // checkout because working tree differs from HEAD. Use force() instead. + let obj = repo.revparse_single("refs/heads/feature").unwrap(); + let mut checkout_opts = git2::build::CheckoutBuilder::new(); + checkout_opts.force(); + repo.checkout_tree(&obj, Some(&mut checkout_opts)).unwrap(); + repo.set_head("refs/heads/feature").unwrap(); + + let current = repo.head().unwrap().shorthand().unwrap().to_string(); + assert_eq!(current, "feature"); + } + + #[test] + fn checkout_commit_detaches_head() { + let fixture = TempRepo::with_commits(3); + let path = fixture.path(); + let repo = git2::Repository::open(path).unwrap(); + + let commits = collect_commits(&repo); + let second_oid = commits[1]; + + let commit = repo.find_commit(second_oid).unwrap(); + let mut checkout_opts = git2::build::CheckoutBuilder::new(); + checkout_opts.safe(); + repo.checkout_tree(commit.as_object(), Some(&mut checkout_opts)) + .unwrap(); + repo.set_head_detached(second_oid).unwrap(); + + assert!(repo.head_detached().unwrap()); + let head_oid = repo.head().unwrap().target().unwrap(); + assert_eq!(head_oid, second_oid); + } + + // ------------------------------------------------------------------------- + // Commit tests + // ------------------------------------------------------------------------- + + #[test] + fn commit_creates_new_commit() { + let fixture = make_test_repo(); + let path = fixture.path(); + let head = fixture.head_oid(); + let repo = git2::Repository::open(path).unwrap(); + + fs::write(path.join("file.txt"), "hello world").unwrap(); + repo.index() + .unwrap() + .add_path(std::path::Path::new("file.txt")) + .unwrap(); + repo.index().unwrap().write().unwrap(); + + let sig = repo.signature().unwrap(); + let tree_oid = repo.index().unwrap().write_tree().unwrap(); + let tree = repo.find_tree(tree_oid).unwrap(); + let parent = repo.find_commit(head).unwrap(); + + let new_oid = repo + .commit(Some("HEAD"), &sig, &sig, "add file.txt", &tree, &[&parent]) + .unwrap(); + + assert_ne!(new_oid, head); + let new_commit = repo.find_commit(new_oid).unwrap(); + assert_eq!(new_commit.summary().unwrap(), "add file.txt"); + } + + #[test] + fn amend_commit_updates_message() { + let fixture = make_test_repo(); + let path = fixture.path(); + let head = fixture.head_oid(); + let repo = git2::Repository::open(path).unwrap(); + + let sig = repo.signature().unwrap(); + let parent = repo.find_commit(head).unwrap(); + + let new_oid = repo + .commit( + Some("HEAD"), + &sig, + &sig, + "original message", + &parent.tree().unwrap(), + &[&parent], + ) + .unwrap(); + + let new_commit = repo.find_commit(new_oid).unwrap(); + new_commit + .amend( + Some("HEAD"), + Some(&sig), + Some(&sig), + None, + Some("updated message"), + None, + ) + .unwrap(); + + // amend() creates a NEW commit with a new SHA. Look up the amended commit + // from HEAD (which now points to the new commit), not from the old OID. + let amended = repo + .find_commit(repo.head().unwrap().target().unwrap()) + .unwrap(); + assert_eq!(amended.summary().unwrap(), "updated message"); + } + + #[test] + fn count_would_remove_counts_dry_run_entries() { + let stdout = "Would remove build/\nWould remove notes.txt\nWould remove a b c.txt\n"; + assert_eq!(super::count_would_remove(stdout), 3); + } + + #[test] + fn count_would_remove_ignores_unrelated_output() { + assert_eq!(super::count_would_remove(""), 0); + assert_eq!(super::count_would_remove("nothing to do\n"), 0); + // "Would skip" is emitted for ignored entries when -x is not passed. + assert_eq!(super::count_would_remove("Would skip repository sub/\n"), 0); + // Only a line that starts with the marker counts; a filename that + // merely mentions it must not. + assert_eq!( + super::count_would_remove("Would remove docs/Would remove me.txt\n"), + 1 + ); + } + + /// `git clean -n` writes its report to stdout, not stderr. Reading the wrong + /// stream made `clean_untracked` a silent no-op that still reported success. + #[test] + fn git_clean_dry_run_reports_on_stdout() { + let fixture = make_test_repo(); + let path = fixture.path(); + fs::write(path.join("untracked.txt"), "scratch").unwrap(); + fs::create_dir(path.join("untracked_dir")).unwrap(); + fs::write(path.join("untracked_dir").join("inner.txt"), "scratch").unwrap(); + + let output = super::super::git_command() + .current_dir(path) + .args(["clean", "-n", "-fd"]) + .output() + .unwrap(); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert_eq!( + super::count_would_remove(&stdout), + 2, + "expected both untracked entries on stdout, got: {stdout:?}" + ); + assert_eq!( + super::count_would_remove(&stderr), + 0, + "stderr should carry no report, got: {stderr:?}" + ); + } +} diff --git a/third_party/rgitui/crates/rgitui_git/src/project/mod.rs b/third_party/rgitui/crates/rgitui_git/src/project/mod.rs new file mode 100644 index 000000000..69fb405aa --- /dev/null +++ b/third_party/rgitui/crates/rgitui_git/src/project/mod.rs @@ -0,0 +1,1882 @@ +mod argsafe; +mod auth; +mod bisect; +mod blame; +#[cfg(feature = "perf")] +mod census; +mod diff; +mod file_history; +mod history_cache; +mod local_ops; +mod network; +mod rebase; +mod reflog; +mod refresh; +mod search; +mod submodule; +mod watcher; +mod worktree_patch; + +use anyhow::{Context as _, Result}; +use git2::{Repository, StatusOptions}; +use gpui::{AsyncApp, Context, EventEmitter, Task, WeakEntity}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use crate::types::*; + +use argsafe::{validate_branch_name, validate_remote_name}; + +/// Normalize UNC paths (`\\server\share\…` → `//server/share/…`) for libgit2 +/// on Windows. No-op on other platforms. +pub fn normalize_repo_path(path: PathBuf) -> PathBuf { + #[cfg(target_os = "windows")] + { + let s = path.to_string_lossy(); + // Only rewrite share-style UNC paths (\\server\share\...). + // Skip extended-length (\\?\...) and device (\\.\...) prefixes — libgit2 + // does not accept the slash-converted form of these, and Rust's + // `canonicalize` routinely produces \\?\ paths we must not mangle. + if s.starts_with("\\\\") { + let third = s.as_bytes().get(2).copied(); + if third != Some(b'?') && third != Some(b'.') { + return PathBuf::from(s.replace('\\', "/")); + } + } + } + path +} + +/// Create a `git` [`Command`] with `CREATE_NO_WINDOW` set on Windows so that +/// spawning it from a GUI application never flashes a visible console window. +pub fn git_command() -> Command { + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + let mut cmd = Command::new("git"); + cmd.creation_flags(0x08000000); + cmd + } + #[cfg(not(target_os = "windows"))] + { + Command::new("git") + } +} + +pub use bisect::{compute_bisect_log, is_bisect_in_progress, BisectDecision, BisectLogEntry}; +pub use blame::{compute_blame, BlameEntry, BlameLine}; +pub use diff::{ + compute_commit_diff, compute_file_diff, compute_staged_diff_text, compute_stash_diff, + compute_three_way_conflict_diff, +}; +pub use file_history::{compute_file_history, compute_file_history_at}; +pub use local_ops::branches_containing_commit; +pub use reflog::{compute_reflog, ReflogEntryInfo}; +pub use refresh::gather_refresh_data; +pub use refresh::gather_refresh_data_lightweight; +pub use refresh::{enrich_commit_info, extract_co_authors}; +pub use search::git_grep; +pub use submodule::{ + compute_submodules, submodule_init, submodule_init_all, submodule_update, submodule_update_all, + SubmoduleInfo, +}; +pub use worktree_patch::{ + apply_worktree_patch, restore_worktree_files, snapshots_fit_undo_stack, + WorktreeFilePermissions, WorktreeFileSnapshot, WorktreeFileState, WorktreePatchDirection, + WorktreePatchOutcome, WorktreePatchScope, WorktreePatchSource, MAX_UNDO_SNAPSHOT_BYTES, +}; + +fn parse_remote_tracking_ref(name: &str) -> Option<(String, String)> { + let trimmed = name.strip_prefix("refs/remotes/").unwrap_or(name); + let mut parts = trimmed.splitn(2, '/'); + let remote = parts.next()?.trim(); + let branch = parts.next()?.trim(); + if remote.is_empty() || branch.is_empty() { + return None; + } + Some((remote.to_string(), branch.to_string())) +} + +fn head_branch_name(repo: &Repository) -> Result { + let head = repo.head()?; + if !head.is_branch() { + anyhow::bail!("HEAD is detached. Switch to a branch before running this operation."); + } + head.shorthand() + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("Failed to determine the current branch name")) +} + +/// Whether the working tree or index carries changes to **tracked** files. +/// +/// Untracked and ignored files are deliberately excluded. Counting every +/// untracked file as "dirty" made a single un-ignored scratch file block branch +/// switching entirely, with no way to proceed short of deleting it. +/// +/// This is only safe because every operation gated on it refuses on its own, +/// and names the file, when it would actually overwrite an untracked file: the +/// CLI-backed operations (pull, rebase) do so natively, and the git2-backed +/// checkout and fast-forward merge paths go through +/// [`local_ops::checkout_tree_safe`], which never force-checkouts. Any new +/// caller must hold to that — a forced checkout behind this relaxed guard +/// silently destroys untracked files. +fn repo_has_worktree_changes(repo: &Repository) -> Result { + let mut opts = StatusOptions::new(); + opts.include_untracked(false) + .recurse_untracked_dirs(false) + .include_ignored(false) + .include_unmodified(false); + Ok(!repo.statuses(Some(&mut opts))?.is_empty()) +} + +fn ensure_clean_worktree(repo: &Repository, operation: &str) -> Result<()> { + if repo_has_worktree_changes(repo)? { + anyhow::bail!( + "{} requires a clean working tree. Commit, stash, or discard your changes to \ + tracked files first.", + operation + ); + } + Ok(()) +} + +fn default_remote_name(repo: &Repository) -> Result { + if let Ok(branch_name) = head_branch_name(repo) { + if let Ok(branch) = repo.find_branch(&branch_name, git2::BranchType::Local) { + if let Ok(upstream) = branch.upstream() { + if let Some(upstream_name) = upstream.name()?.and_then(parse_remote_tracking_ref) { + validate_remote_name(&upstream_name.0)?; + return Ok(upstream_name.0); + } + } + } + } + + let remote_names = repo.remotes()?; + if remote_names.is_empty() { + anyhow::bail!("No remotes configured. Add one with: git remote add origin ") + } + + if remote_names.iter().flatten().any(|name| name == "origin") { + return Ok("origin".to_string()); + } + + let name = remote_names + .iter() + .flatten() + .next() + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("No usable git remotes are configured."))?; + // Read straight out of .git/config, which git does not validate. + validate_remote_name(&name)?; + Ok(name) +} + +fn pull_target(repo: &Repository, preferred_remote: Option<&str>) -> Result<(String, String)> { + let branch_name = head_branch_name(repo)?; + if let Ok(branch) = repo.find_branch(&branch_name, git2::BranchType::Local) { + if let Ok(upstream) = branch.upstream() { + if let Some(upstream_name) = upstream.name()?.and_then(parse_remote_tracking_ref) { + if preferred_remote + .map(|remote| remote == upstream_name.0) + .unwrap_or(true) + { + validate_remote_name(&upstream_name.0)?; + validate_branch_name(&upstream_name.1)?; + return Ok(upstream_name); + } + } + } + } + + let remote_name = preferred_remote + .map(str::to_string) + .unwrap_or(default_remote_name(repo)?); + validate_remote_name(&remote_name)?; + validate_branch_name(&branch_name)?; + Ok((remote_name, branch_name)) +} + +fn push_target( + repo: &Repository, + preferred_remote: Option<&str>, +) -> Result<(String, String, bool)> { + let branch_name = head_branch_name(repo)?; + if let Ok(branch) = repo.find_branch(&branch_name, git2::BranchType::Local) { + if let Ok(upstream) = branch.upstream() { + if let Some((remote_name, remote_branch)) = + upstream.name()?.and_then(parse_remote_tracking_ref) + { + if preferred_remote + .map(|remote| remote == remote_name) + .unwrap_or(true) + { + validate_remote_name(&remote_name)?; + validate_branch_name(&remote_branch)?; + return Ok((remote_name, remote_branch, false)); + } + } + } + } + + let remote_name = preferred_remote + .map(str::to_string) + .unwrap_or(default_remote_name(repo)?); + validate_remote_name(&remote_name)?; + validate_branch_name(&branch_name)?; + Ok((remote_name, branch_name, true)) +} + +/// All the data gathered during a refresh, designed to be Send so it can +/// be computed on a background thread and then applied on the main thread. +pub struct RefreshData { + /// Checked-out branch, or `None` when HEAD is detached. + pub head_branch: Option, + pub head_detached: bool, + /// The local branch HEAD was last switched away from, if it still exists. + pub previous_branch: Option, + pub repo_state: RepoState, + pub branches: Vec, + pub tags: Vec, + pub remotes: Vec, + pub stashes: Vec, + pub worktrees: Vec, + pub status: WorkingTreeStatus, + pub recent_commits: Vec, + /// Whether there are more commits beyond the loaded limit. + pub has_more_commits: bool, + /// The remote default branch (e.g. "main", "master"), detected from + /// `refs/remotes/origin/HEAD` symbolic target. `None` if not set. + pub default_branch: Option, + /// Current Git user email, read from repo config then global config. + /// Used for "My Branches" / "My Commits" filtering. + pub current_user_email: Option, +} + +/// Events emitted by GitProject. +#[derive(Debug, Clone)] +pub enum GitProjectEvent { + /// A repository mutation changed one or more of HEAD, refs, or status. + /// Subscribers should refresh their repository-derived snapshots once. + RepositoryChanged, + StatusChanged, + HeadChanged, + RefsChanged, + /// Emitted after ahead/behind for all branches has been recomputed in the background. + AheadBehindRefreshed, + OperationUpdated(GitOperationUpdate), + /// An apply/revert rewrote working-tree files on disk. Carries what those + /// files held beforehand, which is the only record of it: the previous + /// contents are not recoverable from git state. + WorktreePatchApplied { + /// Undo label, e.g. "Applied hunk 2 of src/main.rs from a1b2c3d". + label: String, + snapshots: Vec, + /// Repository entity that started the asynchronous mutation. + repo_path: PathBuf, + /// Effective worktree captured when the operation started. + worktree_path: PathBuf, + }, +} + +/// The core Git project state holder. +pub struct GitProject { + repo_path: PathBuf, + + // Cached state + head_branch: Option, + head_detached: bool, + previous_branch: Option, + repo_state: RepoState, + branches: Vec, + tags: Vec, + remotes: Vec, + stashes: Vec, + worktrees: Vec, + status: Arc, + recent_commits: Arc>, + /// Whether the repository has more commits beyond the loaded set. + has_more_commits: bool, + /// Number of commits currently loaded (used by incremental load-more). + commit_offset: usize, + next_operation_id: u64, + /// Where each recent operation ran, so a retry can go back to the same + /// checkout rather than to whatever is on screen when the failure lands. + operation_worktrees: std::collections::HashMap, + /// Remote default branch (e.g. "main"), from `refs/remotes/origin/HEAD`. + default_branch: Option, + /// Current Git user email, read from repo config then global config. + /// Used for "My Branches" / "My Commits" filtering. + current_user_email: Option, + /// Author filter for "My Commits" — when set, only commits by this author are loaded. + commit_author_filter: Option, + /// Maximum number of commits to load (configurable via settings). + commit_limit: usize, + /// Per-worktree status cache shared between GitProject::refresh() and the watcher loop. + /// Keyed by worktree path; value is (fingerprint, cached WorkingTreeStatus). + worktree_status_cache: Arc>, + /// Monotonic counter bumped on every `apply_refresh_data`. The watcher uses it + /// to discard a stale lightweight refresh that would otherwise clobber a newer + /// full/operation refresh that landed while the watcher's snapshot was in flight. + refresh_generation: u64, + /// Identifies the currently valid commit query (refs/filter/pagination session). + /// Async commit results captured under an older generation must not be applied. + commit_query_generation: u64, + /// Suppresses duplicate pagination requests while one page is already in flight. + load_more_in_flight: bool, + /// The linked worktree the UI is currently inspecting, if any. The watcher + /// always watches this checkout — even when the `watch_all_worktrees` + /// setting is off — so the file list and diff of the worktree the user is + /// actually looking at stay live. + inspected_worktree: Option, + /// How many background refreshes are in flight — history paging and the + /// ahead/behind walk, neither of which reports through `OperationUpdated`. + /// + /// Held behind an `Arc` so [`BackgroundWorkGuard`] can decrement it from + /// `Drop`: a task cancelled by a newer refresh has to lower the count too, + /// or anything waiting for the project to go quiet would wait forever. + background_work: Arc, +} + +/// Keeps [`GitProject::has_background_work`] true for as long as it is alive. +/// +/// Move one into a spawned refresh so the count falls when the task finishes +/// *or* when its future is dropped part-way through. +pub(crate) struct BackgroundWorkGuard(Arc); + +impl Drop for BackgroundWorkGuard { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::Release); + } +} + +impl EventEmitter for GitProject {} + +impl GitProject { + /// Create a minimal non-functional instance for error recovery paths. + /// The resulting entity should be dropped immediately; it exists only + /// to satisfy GPUI's `cx.new()` requirement of returning a value. + pub fn empty_at(path: PathBuf) -> Self { + Self { + repo_path: path, + head_branch: None, + head_detached: false, + previous_branch: None, + repo_state: RepoState::Clean, + branches: Vec::new(), + tags: Vec::new(), + remotes: Vec::new(), + stashes: Vec::new(), + worktrees: Vec::new(), + status: Arc::new(WorkingTreeStatus::default()), + recent_commits: Arc::new(Vec::new()), + has_more_commits: false, + commit_offset: 0, + next_operation_id: 1, + operation_worktrees: std::collections::HashMap::new(), + default_branch: None, + current_user_email: None, + commit_author_filter: None, + commit_limit: 1000, + worktree_status_cache: Arc::new(Mutex::new(HashMap::new())), + refresh_generation: 0, + commit_query_generation: 0, + load_more_in_flight: false, + inspected_worktree: None, + background_work: Arc::new(AtomicUsize::new(0)), + } + } + + /// Open a repository at the given path. + pub fn open(path: PathBuf, commit_limit: usize, cx: &mut Context) -> Result { + // Normalise UNC paths on Windows so that libgit2 can open WSL2 repos. + let path = normalize_repo_path(path); + let repo = Repository::open(&path) + .with_context(|| format!("Failed to open repository at {}", path.display()))?; + + let repo_path = repo.workdir().unwrap_or_else(|| repo.path()).to_path_buf(); + + let mut project = Self { + repo_path, + head_branch: None, + head_detached: false, + previous_branch: None, + repo_state: RepoState::Clean, + branches: Vec::new(), + tags: Vec::new(), + remotes: Vec::new(), + stashes: Vec::new(), + worktrees: Vec::new(), + status: Arc::new(WorkingTreeStatus::default()), + recent_commits: Arc::new(Vec::new()), + has_more_commits: false, + commit_offset: 0, + next_operation_id: 1, + operation_worktrees: std::collections::HashMap::new(), + default_branch: None, + current_user_email: None, + commit_author_filter: None, + commit_limit: refresh::normalize_commit_limit(commit_limit), + worktree_status_cache: Arc::new(Mutex::new(HashMap::new())), + refresh_generation: 0, + commit_query_generation: 0, + load_more_in_flight: false, + inspected_worktree: None, + background_work: Arc::new(AtomicUsize::new(0)), + }; + + if let Some(cached) = history_cache::load(&project.repo_path, project.commit_limit) { + project.recent_commits = Arc::new(cached.commits); + project.commit_offset = project.recent_commits.len(); + project.has_more_commits = cached.has_more_commits; + project.default_branch = cached.default_branch; + log::debug!( + "hydrated {} commits from persistent history cache", + project.recent_commits.len() + ); + } + + project.start_watcher(cx); + + Ok(project) + } + + pub(crate) fn begin_operation( + &mut self, + kind: GitOperationKind, + summary: impl Into, + remote_name: Option, + branch_name: Option, + cx: &mut Context, + ) -> u64 { + let id = self.next_operation_id; + self.next_operation_id += 1; + cx.emit(GitProjectEvent::OperationUpdated(GitOperationUpdate { + id, + kind, + state: GitOperationState::Running, + summary: summary.into(), + details: None, + remote_name, + branch_name, + worktree_path: None, + retryable: false, + })); + cx.notify(); + id + } + + /// How many operations' worktrees to remember. Only the retryable ones + /// register, and only the most recent failure is ever offered, so this is + /// far more history than anything reads. + const REMEMBERED_OPERATION_WORKTREES: usize = 32; + + /// Records where operation `id` is running, so its retry can go back there. + pub(crate) fn note_operation_worktree(&mut self, id: u64, worktree_path: &Path) { + self.operation_worktrees + .insert(id, worktree_path.to_path_buf()); + if self.operation_worktrees.len() > Self::REMEMBERED_OPERATION_WORKTREES { + let cutoff = id.saturating_sub(Self::REMEMBERED_OPERATION_WORKTREES as u64); + self.operation_worktrees + .retain(|recorded, _| *recorded > cutoff); + } + } + + fn operation_worktree(&self, id: u64) -> Option { + self.operation_worktrees.get(&id).cloned() + } + + pub(crate) fn complete_op( + &self, + id: u64, + kind: GitOperationKind, + summary: impl Into, + names: (Option, Option, Option), + cx: &mut Context, + ) { + cx.emit(GitProjectEvent::OperationUpdated(GitOperationUpdate { + id, + kind, + state: GitOperationState::Succeeded, + summary: summary.into(), + details: names.0, + remote_name: names.1, + branch_name: names.2, + worktree_path: self.operation_worktree(id), + retryable: false, + })); + } + + pub(crate) fn fail_op( + &self, + id: u64, + kind: GitOperationKind, + summary: impl Into, + error: impl Into, + names: (Option, Option, bool), + cx: &mut Context, + ) { + cx.emit(GitProjectEvent::OperationUpdated(GitOperationUpdate { + id, + kind, + state: GitOperationState::Failed, + summary: summary.into(), + details: Some(error.into()), + remote_name: names.0, + branch_name: names.1, + worktree_path: self.operation_worktree(id), + retryable: names.2, + })); + } + + /// Report an operation that failed before its task could start. The + /// worktree is recorded just as a started operation's is, so a retry offered + /// for a preflight failure goes back to the checkout the operation was aimed + /// at rather than whichever one the user is looking at when they click it. + pub(crate) fn fail_to_start_task( + &mut self, + kind: GitOperationKind, + summary: impl Into, + error: anyhow::Error, + retryable: bool, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + let summary = summary.into(); + let branch_name = self.head_branch_at(worktree_path); + let operation_id = + self.begin_operation(kind, summary.clone(), None, branch_name.clone(), cx); + self.note_operation_worktree(operation_id, worktree_path); + self.fail_op( + operation_id, + kind, + summary, + error.to_string(), + (None, branch_name, retryable), + cx, + ); + cx.spawn(async move |_this: WeakEntity, _cx: &mut AsyncApp| Err(error)) + } + + pub fn repo_path(&self) -> &Path { + &self.repo_path + } + + /// Raise [`GitProject::has_background_work`] until the returned guard drops. + pub(crate) fn track_background_work(&self) -> BackgroundWorkGuard { + self.background_work.fetch_add(1, Ordering::AcqRel); + BackgroundWorkGuard(self.background_work.clone()) + } + + /// Whether a background refresh is still running. + /// + /// Neither history paging nor the ahead/behind walk emits an + /// `OperationUpdated`, so a caller that only watches operations sees an idle + /// project while both are still loading. Anything that waits for the + /// repository to settle — a measurement, a snapshot — has to consult this + /// as well as the operation list. + pub fn has_background_work(&self) -> bool { + self.background_work.load(Ordering::Acquire) > 0 + } + + /// Resolve the current HEAD commit OID of the given worktree (or the main + /// repo when `worktree_path` is the repo path). A cheap synchronous ref read + /// used to capture the pre-commit HEAD so a commit can be undone against the + /// branch it was actually made on, not the main repo's HEAD. + pub fn head_oid_at(&self, worktree_path: &Path) -> Option { + let repo = git2::Repository::open(worktree_path).ok()?; + let head = repo.head().ok()?; + head.target() + } + + pub fn repo_name(&self) -> &str { + self.repo_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + } + + /// The checked-out branch, or `None` when HEAD is detached or unborn. + pub fn head_branch(&self) -> Option<&str> { + self.head_branch.as_deref() + } + + pub fn is_head_detached(&self) -> bool { + self.head_detached + } + + /// The local branch HEAD was last switched away from, the one + /// `git switch -` would return to, if it still exists. + pub fn previous_branch(&self) -> Option<&str> { + self.previous_branch.as_deref() + } + + /// The commit HEAD points at, as of the last refresh. + pub fn head_oid(&self) -> Option { + self.worktrees + .iter() + .find(|worktree| worktree.is_current) + .and_then(|worktree| worktree.head_oid) + } + + /// The commit HEAD points at, when it is among the loaded commits. + pub fn head_commit(&self) -> Option<&CommitInfo> { + let head = self.head_oid()?; + self.recent_commits.iter().find(|commit| commit.oid == head) + } + + /// Tell the watcher which linked worktree the UI is inspecting so it is + /// watched and fingerprinted regardless of the `watch_all_worktrees` + /// setting. Pass `None` when the user leaves worktree inspection. + pub fn set_inspected_worktree(&mut self, path: Option) { + self.inspected_worktree = path; + } + + pub(crate) fn inspected_worktree(&self) -> Option<&Path> { + self.inspected_worktree.as_deref() + } + + /// The worktree in the current snapshot whose working directory is + /// `worktree_path`, if any. + pub fn worktree_at(&self, worktree_path: &Path) -> Option<&WorktreeInfo> { + self.worktrees + .iter() + .find(|worktree| worktree.path == worktree_path) + } + + /// The branch checked out in `worktree_path`. Falls back to this project's + /// own HEAD branch when the path is not a worktree we know about, so + /// operation labels degrade to something sensible rather than blank. + pub fn head_branch_at(&self, worktree_path: &Path) -> Option { + match self.worktree_at(worktree_path) { + Some(worktree) => worktree.branch.clone(), + None => self.head_branch.clone(), + } + } + + pub fn repo_state(&self) -> RepoState { + self.repo_state + } + + /// The merge/rebase state of `worktree_path`. Each checkout keeps its own — + /// a conflicted pull inside a linked worktree writes `MERGE_HEAD` under + /// `.git/worktrees//`, which the main repository reports as clean — + /// so a banner or a Continue/Abort command asking about the project would + /// tell the user nothing is in progress while they are looking at conflicts. + pub fn repo_state_at(&self, worktree_path: &Path) -> RepoState { + match self.worktree_at(worktree_path) { + Some(worktree) => worktree.state, + None => self.repo_state, + } + } + + pub fn branches(&self) -> &[BranchInfo] { + &self.branches + } + + pub fn tags(&self) -> &[TagInfo] { + &self.tags + } + + pub fn remotes(&self) -> &[RemoteInfo] { + &self.remotes + } + + /// The remote a default fetch should use for `worktree_path`. + /// + /// Derived from that checkout's own HEAD and upstream. A linked worktree on + /// a branch tracking `fork/feature` should fetch `fork`, and asking the + /// main repository would answer `origin` — the remote of a branch the user + /// is not on. + pub fn preferred_remote_name_at(&self, worktree_path: &Path) -> Result { + let repo = Repository::open(worktree_path) + .with_context(|| format!("Failed to open repository at {}", worktree_path.display()))?; + default_remote_name(&repo) + } + + /// Resolve a tag name to the commit OID it points to. + /// Handles both lightweight and annotated tags by peeling to the commit. + pub fn resolve_tag_to_oid(&self, tag_name: &str) -> Result { + let repo = self.open_repo()?; + let obj = repo + .revparse_single(&format!("refs/tags/{}", tag_name)) + .with_context(|| format!("Failed to resolve tag '{}'", tag_name))?; + let commit = obj + .peel_to_commit() + .with_context(|| format!("Tag '{}' does not point to a commit", tag_name))?; + Ok(commit.id()) + } + + /// Resolve a branch name to the commit OID it points to. + /// Tries local branch first, then remote, then raw revparse. + pub fn resolve_branch_to_oid(&self, branch_name: &str) -> Result { + let repo = self.open_repo()?; + let refs_to_try = [ + format!("refs/heads/{}", branch_name), + format!("refs/remotes/{}", branch_name), + branch_name.to_string(), + ]; + for refspec in &refs_to_try { + if let Ok(obj) = repo.revparse_single(refspec) { + if let Ok(commit) = obj.peel_to_commit() { + return Ok(commit.id()); + } + } + } + anyhow::bail!("Failed to resolve branch '{}' to a commit", branch_name) + } + + pub fn stashes(&self) -> &[StashEntry] { + &self.stashes + } + + pub fn worktrees(&self) -> &[WorktreeInfo] { + &self.worktrees + } + + pub fn status(&self) -> &WorkingTreeStatus { + &self.status + } + + pub fn status_arc(&self) -> Arc { + Arc::clone(&self.status) + } + + pub fn recent_commits(&self) -> &[CommitInfo] { + &self.recent_commits + } + + pub fn recent_commits_arc(&self) -> Arc> { + Arc::clone(&self.recent_commits) + } + + pub fn has_changes(&self) -> bool { + !self.status.staged.is_empty() || !self.status.unstaged.is_empty() + } + + /// Returns the list of conflicted file paths from the unstaged changes. + pub fn conflicted_files(&self) -> Vec<&FileStatus> { + self.status + .unstaged + .iter() + .filter(|f| f.kind == FileChangeKind::Conflicted) + .collect() + } + + /// The conflicted files in `worktree_path`. Falls back to this project's + /// own status when that checkout has no cached status yet, which is the + /// same answer the caller would have got before asking. + pub fn conflicted_files_at(&self, worktree_path: &Path) -> Vec<&FileStatus> { + match self + .worktree_at(worktree_path) + .and_then(|worktree| worktree.status.as_ref()) + { + Some(status) => status + .unstaged + .iter() + .filter(|f| f.kind == FileChangeKind::Conflicted) + .collect(), + None => self.conflicted_files(), + } + } + + /// Whether `worktree_path` has any conflicted files. + pub fn has_conflicts_at(&self, worktree_path: &Path) -> bool { + !self.conflicted_files_at(worktree_path).is_empty() + } + + pub(crate) fn open_repo(&self) -> Result { + Repository::open(&self.repo_path) + .with_context(|| format!("Failed to open repository at {}", self.repo_path.display())) + } + + /// Apply pre-gathered refresh data to self. + /// Whether any local branch is missing graph state it could have. + /// + /// True right after a reference point moves: the carry-forward drops the + /// answers that were derived from it, leaving a hole that only a fresh walk + /// can fill. False in the steady state, where nothing moved and everything + /// was carried — which is what keeps the watcher from scheduling two + /// history walks on every file save. + /// + /// "Could have" is doing real work. A repository whose trunk is named + /// something other than `main` or `master` has no trunk for + /// `is_merged_into_main` to be about, and that answer is `None` for as long + /// as the repository exists. Reading that as work outstanding would put a + /// full history walk and a `git for-each-ref` behind every file save, on + /// exactly the repositories that already got the worst of this code. + pub fn needs_graph_state_refresh(&self) -> bool { + graph_state_is_incomplete(&self.branches, self.head_detached) + } + + pub(crate) fn apply_refresh_data(&mut self, data: RefreshData) { + self.head_branch = data.head_branch; + self.head_detached = data.head_detached; + self.previous_branch = data.previous_branch; + self.repo_state = data.repo_state; + self.branches = carry_forward_branch_graph_state(&self.branches, data.branches); + self.tags = data.tags; + self.remotes = data.remotes; + self.stashes = data.stashes; + self.worktrees = data.worktrees; + self.status = Arc::new(data.status); + self.recent_commits = Arc::new(data.recent_commits); + self.has_more_commits = data.has_more_commits; + self.default_branch = data.default_branch; + self.current_user_email = data.current_user_email; + // Reset offset — the full refresh replaces all commits. + self.commit_offset = self.recent_commits.len(); + // Advance the refresh generation so an in-flight watcher snapshot that + // started before this apply discards its now-stale lightweight result. + self.refresh_generation = self.refresh_generation.wrapping_add(1); + } + + /// The current refresh generation, bumped on every `apply_refresh_data`. + /// The watcher captures this before gathering and drops its result if the + /// generation advanced in the meantime (a newer full refresh won). + pub(crate) fn refresh_generation(&self) -> u64 { + self.refresh_generation + } + + /// Whether a refresh has ever been applied to this project. + /// + /// The honest test for "there is something to show". Counting commits + /// instead treats an empty repository as permanently unloaded, which is a + /// real state — a freshly initialised one — and leaves whatever is waiting + /// on it waiting until its timeout. + pub fn has_loaded(&self) -> bool { + self.refresh_generation > 0 + } + + /// Whether there are more commits beyond the currently loaded set. + pub fn has_more_commits(&self) -> bool { + self.has_more_commits + } + + /// The remote default branch (e.g. "main", "master") determined from + /// `refs/remotes/origin/HEAD` symbolic reference. + pub fn default_branch(&self) -> Option<&str> { + self.default_branch.as_deref() + } + + /// The current Git user email, used for "My Branches" / "My Commits" filtering. + pub fn current_user_email(&self) -> Option<&str> { + self.current_user_email.as_deref() + } + + /// Set the author filter for "My Commits" mode. When `Some(email)`, + /// only commits authored by this email are loaded. Clears existing commits + /// and resets the offset so the next load fetches from the beginning. + pub fn set_commit_author_filter(&mut self, email: Option) { + if self.commit_author_filter != email { + self.commit_author_filter = email; + self.commit_query_generation = self.commit_query_generation.wrapping_add(1); + self.load_more_in_flight = false; + // Reset commit list so next load starts fresh with the filter applied. + self.recent_commits = Arc::new(Vec::new()); + self.commit_offset = 0; + self.has_more_commits = true; + } + } + + /// How many commits are currently loaded. + pub fn loaded_commit_count(&self) -> usize { + self.recent_commits.len() + } + + /// Asynchronously load the next batch of commits and append them to the + /// existing list. Emits `GitProjectEvent::StatusChanged` when done so the + /// graph view re-renders. + pub fn load_more_commits(&mut self, cx: &mut Context) -> Task> { + if self.load_more_in_flight || !self.has_more_commits { + return cx.spawn(async move |_, _| Ok(())); + } + self.load_more_in_flight = true; + let query_generation = self.commit_query_generation; + let repo_path = self.repo_path.clone(); + let already_loaded = self.commit_offset; + // Stable pagination cursor: the OID of the last loaded commit. Re-anchoring + // on it (rather than a raw `--skip`) avoids dropping a commit if refs change + // between pages. + let after_oid = self.recent_commits.last().map(|c| c.oid); + // Collect the branch/tag ref-map data we need for labelling + let branch_tips: Vec<(git2::Oid, bool, String)> = self + .branches + .iter() + .filter_map(|b| b.tip_oid.map(|oid| (oid, b.is_remote, b.name.clone()))) + .collect(); + let tag_tips: Vec<(git2::Oid, String)> = + self.tags.iter().map(|t| (t.oid, t.name.clone())).collect(); + let commit_limit = self.commit_limit; + let author_filter = self.commit_author_filter.clone(); + + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result = cx + .background_executor() + .spawn(async move { + refresh::load_more_commits_from_repo( + &repo_path, + already_loaded, + after_oid, + commit_limit, + &branch_tips, + &tag_tips, + author_filter.as_deref(), + ) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + if this.commit_query_generation != query_generation { + return Ok(()); + } + this.load_more_in_flight = false; + let (new_commits, has_more) = result?; + // Append the new commits, deduplicated by OID. + let existing_oids: std::collections::HashSet = + this.recent_commits.iter().map(|c| c.oid).collect(); + // Append in place (no full-Vec clone) via Arc::make_mut. + let combined = Arc::make_mut(&mut this.recent_commits); + for commit in new_commits { + if !existing_oids.contains(&commit.oid) { + combined.push(commit); + } + } + this.commit_offset = this.recent_commits.len(); + this.has_more_commits = has_more; + cx.emit(GitProjectEvent::StatusChanged); + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Compute ahead/behind for all local branches with upstreams in a background + /// task, then update the branches list and emit `AheadBehindRefreshed` so the + /// UI refreshes. This is called after the initial refresh so that the + /// expensive graph walks don't block the first render. + /// + /// The walk itself is delegated to `git for-each-ref`, which reads the + /// commit-graph file; libgit2's `graph_ahead_behind` does not, and one call + /// per branch made this the second-largest cost in a refresh. + pub fn refresh_ahead_behind(&mut self, cx: &mut Context) { + self.refresh_ahead_behind_attempt(0, cx); + } + + /// The walk itself, carrying how many times it has already been restarted. + /// + /// Two full-history walks take long enough that an ordinary file save can + /// land a refresh underneath them, and the result then belongs to a + /// superseded snapshot. Dropping it silently — which is what this did — + /// leaves the flags permanently unset on a repository that is being written + /// to, because every attempt loses the same race. Restarting is bounded so + /// that sustained churn degrades to "no answer yet" rather than to a task + /// that respawns itself forever. + fn refresh_ahead_behind_attempt(&mut self, attempt: u8, cx: &mut Context) { + const MAX_ATTEMPTS: u8 = 3; + + let repo_path = self.repo_path.clone(); + let query_generation = self.commit_query_generation; + let refresh_generation = self.refresh_generation; + let branch_tips: Vec = self + .branches + .iter() + .map(|branch| { + ( + branch.name.clone(), + branch.is_remote, + branch.is_head, + branch.tip_oid, + ) + }) + .collect(); + + let work = self.track_background_work(); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let _work = work; + let (computed, merged) = cx + .background_executor() + .spawn(async move { + let repo = match git2::Repository::open(&repo_path) { + Ok(r) => r, + Err(_) => return (Vec::new(), Vec::new()), + }; + + let ahead_behind = ahead_behind_via_for_each_ref(&repo_path, &repo); + let merged = merged_flags(&repo, &branch_tips); + (ahead_behind, merged) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + // Nothing to apply: the repository would not open, or it + // has no branches. Retrying would not change that. + if computed.is_empty() && merged.is_empty() { + return; + } + + if this.commit_query_generation != query_generation + || this.refresh_generation != refresh_generation + { + let next = attempt + 1; + if next < MAX_ATTEMPTS && this.needs_graph_state_refresh() { + log::debug!( + "ahead/behind superseded by a newer refresh; retry {next} of {MAX_ATTEMPTS}" + ); + this.refresh_ahead_behind_attempt(next, cx); + } + return; + } + for (name, tip, into_main, into_head) in merged { + if let Some(branch) = this + .branches + .iter_mut() + .find(|b| b.name == name && b.tip_oid == tip) + { + branch.is_merged_into_main = into_main; + branch.is_merged_into_head = into_head; + } + } + // Update ahead/behind values in place + for (name, local_oid, _upstream_oid, ahead, behind) in computed { + if let Some(branch) = this + .branches + .iter_mut() + .find(|b| b.name == name && b.tip_oid == Some(local_oid)) + { + branch.ahead = Some(ahead); + branch.behind = Some(behind); + } + } + cx.emit(GitProjectEvent::AheadBehindRefreshed); + cx.notify(); + }) + }) + .ok(); + }) + .detach(); + } +} + +/// A branch as the merged-flag walk needs it: name, remote, checked-out, tip. +type BranchTip = (String, bool, bool, Option); + +/// One branch's answer: name, tip, merged-into-main, merged-into-HEAD. Each +/// flag is `None` when the question could not be asked at all. +type MergedFlags = (String, Option, Option, Option); + +/// Whether each local branch is merged into main, and into HEAD. +/// +/// Two history walks for the whole repository, rather than one per branch: the +/// question "is this tip an ancestor of that one" is a set-membership test once +/// you have walked the target's history, and there are only two targets. +/// +/// Returns `(name, tip, merged_into_main, merged_into_head)` for local branches +/// only; a remote branch has neither flag. Each flag is `None` when the question +/// could not be asked at all — no trunk to compare against, or an unborn HEAD — +/// so that "unknown" never reaches the UI dressed as "not merged". +fn merged_flags(repo: &Repository, branches: &[BranchTip]) -> Vec { + // Priority: local main > local master > remote origin/main > remote origin/master + let tip_named = |wanted: &str, remote: bool| { + branches + .iter() + .find(|(name, is_remote, _, _)| *is_remote == remote && name == wanted) + .and_then(|(_, _, _, tip)| *tip) + }; + let main_tip = tip_named("main", false) + .or_else(|| tip_named("master", false)) + .or_else(|| tip_named("origin/main", true)) + .or_else(|| tip_named("origin/master", true)); + let head_tip = repo.head().ok().and_then(|reference| reference.target()); + + let reachable_from_main = main_tip.map(|tip| refresh::reachable_set(repo, tip)); + + // When HEAD is the same commit as main the second walk would rebuild an + // identical set. Borrowing the first one instead of cloning it matters at + // scale: on a repository the size of linux.git each set is tens of + // megabytes, and the clone also pays for a full rehash. + let head_is_main = matches!((head_tip, main_tip), (Some(head), Some(main)) if head == main); + let walked_from_head = match head_tip { + Some(head) if !head_is_main => Some(refresh::reachable_set(repo, head)), + _ => None, + }; + let reachable_from_head = if head_is_main { + reachable_from_main.as_ref() + } else { + walked_from_head.as_ref() + }; + + // A tip that is not in the set is genuinely not merged; a set that does not + // exist means the question could not be asked. The two must not collapse + // together — a repository whose trunk is `develop` has no `main` to walk, + // and reporting every branch as unmerged there invites the user to delete + // branches that are fully merged. + let membership = |set: Option<&std::collections::HashSet>, + tip: Option| { + set.zip(tip) + .map(|(reachable, tip)| reachable.contains(&tip)) + }; + + branches + .iter() + .filter(|(_, is_remote, _, _)| !is_remote) + .map(|(name, _, _, tip)| { + // The checked-out branch needs no special case: `reachable_set` + // includes the commit it starts from, so a branch that *is* main + // finds its own tip in the set, and one that was merged into main + // finds it there too — which the previous tip-equality test got + // wrong for exactly that second case. + let into_main = membership(reachable_from_main.as_ref(), *tip); + let into_head = membership(reachable_from_head, *tip); + (name.clone(), *tip, into_main, into_head) + }) + .collect() +} + +/// Keeps graph-derived branch state that the incoming snapshot did not compute. +/// +/// A refresh replaces the branch list wholesale, but the fields that need a +/// history walk — merged flags, ahead/behind — are computed afterwards, off the +/// path a user is waiting on. Without this, every refresh would blank them and +/// the sidebar would flicker between "merged" and "unknown" on every watcher +/// tick until the follow-up landed. +/// +/// Each answer is carried only when *both* commits it was derived from are +/// unchanged. A branch's own tip is not enough: "merged into HEAD" is a fact +/// about the branch and HEAD together, so checking out a different branch +/// invalidates it even though nothing about the branch moved. The same holds +/// for ahead/behind against a moved upstream, which is what a push does. +fn carry_forward_branch_graph_state( + previous: &[BranchInfo], + mut incoming: Vec, +) -> Vec { + if previous.is_empty() { + return incoming; + } + + // An unresolvable reference point counts as changed, so the answer is + // dropped rather than carried on an assumption. + let trunk_unchanged = + trunk_tip(previous).is_some() && trunk_tip(previous) == trunk_tip(&incoming); + let head_unchanged = head_tip(previous).is_some() && head_tip(previous) == head_tip(&incoming); + + let known: std::collections::HashMap<(&str, Option), &BranchInfo> = previous + .iter() + .map(|branch| ((branch.name.as_str(), branch.tip_oid), branch)) + .collect(); + + for index in 0..incoming.len() { + let Some(before) = known + .get(&(incoming[index].name.as_str(), incoming[index].tip_oid)) + .copied() + else { + continue; + }; + + if trunk_unchanged && incoming[index].is_merged_into_main.is_none() { + incoming[index].is_merged_into_main = before.is_merged_into_main; + } + if head_unchanged && incoming[index].is_merged_into_head.is_none() { + incoming[index].is_merged_into_head = before.is_merged_into_head; + } + + if !incoming[index].has_ahead_behind() { + let upstream_moved = match incoming[index].upstream.as_deref() { + Some(upstream) => { + upstream_tip(previous, upstream) != upstream_tip(&incoming, upstream) + } + // No upstream to be ahead or behind of. + None => true, + }; + if !upstream_moved { + incoming[index].ahead = before.ahead; + incoming[index].behind = before.behind; + } + } + } + + incoming +} + +/// Tip of the branch the merged-into-main flag is measured against. +/// +/// Same priority as [`merged_flags`] uses, so the carry-forward invalidates on +/// exactly the commit the flag was derived from. +fn trunk_tip(branches: &[BranchInfo]) -> Option { + let named = |wanted: &str, remote: bool| { + branches + .iter() + .find(|branch| branch.is_remote == remote && branch.name == wanted) + .and_then(|branch| branch.tip_oid) + }; + named("main", false) + .or_else(|| named("master", false)) + .or_else(|| named("origin/main", true)) + .or_else(|| named("origin/master", true)) +} + +/// Tip of the checked-out branch, or `None` when HEAD is detached and no branch +/// in the list claims it. +fn head_tip(branches: &[BranchInfo]) -> Option { + branches + .iter() + .find(|branch| branch.is_head) + .and_then(|branch| branch.tip_oid) +} + +/// Whether any local branch is missing graph state that a walk could supply. +/// +/// The distinction that matters is between an answer nobody has computed yet +/// and one that does not exist. A repository whose trunk is neither `main` nor +/// `master` has nothing for `is_merged_into_main` to be about, and reading that +/// permanent `None` as outstanding work would put a full history walk and a +/// `git for-each-ref` behind every file save. +/// +/// `head_detached` is passed separately because a detached HEAD still resolves +/// to a commit — the walk answers merged-into-HEAD from the repository, not +/// from this list — while no branch here claims to be it. +fn graph_state_is_incomplete(branches: &[BranchInfo], head_detached: bool) -> bool { + let trunk = trunk_tip(branches); + let head = head_tip(branches).is_some() || head_detached; + branches.iter().any(|branch| { + if branch.is_remote { + return false; + } + let merged_into_main_pending = + trunk.is_some() && branch.is_merged_into_main.is_none() && branch.tip_oid.is_some(); + let merged_into_head_pending = + head && branch.is_merged_into_head.is_none() && branch.tip_oid.is_some(); + let ahead_behind_pending = branch + .upstream + .as_deref() + .is_some_and(|upstream| upstream_tip(branches, upstream).is_some()) + && !branch.has_ahead_behind(); + merged_into_main_pending || merged_into_head_pending || ahead_behind_pending + }) +} + +/// Tip of the remote-tracking branch `upstream`, by its full name. +fn upstream_tip(branches: &[BranchInfo], upstream: &str) -> Option { + branches + .iter() + .find(|branch| branch.is_remote && branch.name == upstream) + .and_then(|branch| branch.tip_oid) +} + +/// Ahead/behind counts for every local branch that has an upstream. +/// +/// One `git for-each-ref` rather than one `graph_ahead_behind` per branch. +/// libgit2's version walks both sides without consulting +/// `.git/objects/info/commit-graph`, so it costs a full object-parsing traversal +/// per branch; git's own does consult it. The OIDs still come from libgit2, +/// because the caller matches on them and parsing them back out of the +/// subprocess would only add a way to disagree. +fn ahead_behind_via_for_each_ref( + repo_path: &Path, + repo: &git2::Repository, +) -> Vec<(String, git2::Oid, git2::Oid, usize, usize)> { + let output = git_command() + .current_dir(repo_path) + .args([ + "for-each-ref", + // Not `%(refname:short)`: that shortens to a *non-ambiguous* + // form, so a branch `foo` sharing its name with a tag comes back + // as `heads/foo`. Nothing then finds it, and the branch that has + // two things named after it is exactly the one whose counts a + // user wants. The full name is unambiguous by construction. + "--format=%(refname)%09%(upstream:track)", + "refs/heads", + ]) + .output(); + + let Ok(output) = output else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut results = Vec::new(); + + for line in stdout.lines() { + let Some((refname, track)) = line.split_once('\t') else { + continue; + }; + let Some(name) = local_branch_name(refname) else { + continue; + }; + let Some((ahead, behind)) = parse_upstream_track(track) else { + continue; + }; + // A branch with no upstream, or a gone one, is not reported at all — + // matching what the per-branch version did by skipping it. + let Ok(branch) = repo.find_branch(name, git2::BranchType::Local) else { + continue; + }; + let (Some(local), Ok(upstream)) = (branch.get().target(), branch.upstream()) else { + continue; + }; + let Some(upstream_target) = upstream.get().target() else { + continue; + }; + results.push((name.to_string(), local, upstream_target, ahead, behind)); + } + + results +} + +/// The branch name in a full local refname, spelled as the rest of the +/// snapshot spells it. Anything outside `refs/heads/` is not our business. +fn local_branch_name(refname: &str) -> Option<&str> { + refname + .strip_prefix("refs/heads/") + .filter(|name| !name.is_empty()) +} + +/// Reads git's `%(upstream:track)` field into ahead/behind counts. +/// +/// The field is prose, not data: `[ahead 2, behind 1]`, `[ahead 3]`, +/// `[behind 4]`, `[gone]`, or empty when the branch is level with its upstream. +/// Returning `None` covers both "no upstream" and "upstream gone", which are the +/// cases that have no counts to report. +fn parse_upstream_track(track: &str) -> Option<(usize, usize)> { + let track = track.trim(); + if track.is_empty() { + // An upstream that exists and matches exactly prints nothing. Only a + // branch that has one is level with it, so this is 0/0 rather than + // "not applicable" — but a branch with no upstream also prints nothing, + // and the caller filters those out by looking the upstream up. + return Some((0, 0)); + } + let inner = track.strip_prefix('[')?.strip_suffix(']')?; + if inner == "gone" { + return None; + } + + let mut ahead = 0; + let mut behind = 0; + for part in inner.split(',') { + let part = part.trim(); + if let Some(count) = part.strip_prefix("ahead ") { + ahead = count.trim().parse().ok()?; + } else if let Some(count) = part.strip_prefix("behind ") { + behind = count.trim().parse().ok()?; + } + } + Some((ahead, behind)) +} + +#[cfg(test)] +mod carry_forward_tests { + use super::{carry_forward_branch_graph_state, graph_state_is_incomplete, BranchInfo}; + + /// [`graph_state_is_incomplete`] for a repository whose HEAD is on a branch, + /// which is every case but the one test that says otherwise. + fn needs_refresh(branches: &[BranchInfo]) -> bool { + graph_state_is_incomplete(branches, false) + } + + fn oid(byte: u8) -> git2::Oid { + git2::Oid::from_bytes(&[byte; 20]).unwrap() + } + + fn branch(name: &str, tip: u8) -> BranchInfo { + BranchInfo { + name: name.to_string(), + is_head: false, + is_remote: false, + upstream: None, + ahead: None, + behind: None, + tip_oid: Some(oid(tip)), + author_email: None, + last_commit_time: None, + is_merged_into_main: None, + is_merged_into_head: None, + } + } + + /// `main`, checked out, so both reference points resolve. + fn trunk(tip: u8) -> BranchInfo { + let mut main = branch("main", tip); + main.is_head = true; + main + } + + fn remote(name: &str, tip: u8) -> BranchInfo { + let mut branch = branch(name, tip); + branch.is_remote = true; + branch + } + + /// A `feature` branch tracking `origin/feature`, with every answer known. + fn tracked_feature(tip: u8) -> BranchInfo { + let mut feature = branch("feature", tip); + feature.upstream = Some("origin/feature".to_string()); + feature.is_merged_into_main = Some(true); + feature.is_merged_into_head = Some(true); + feature.ahead = Some(3); + feature.behind = Some(2); + feature + } + + /// The same branch after the carry-forward, by name. + fn feature_of(branches: &[BranchInfo]) -> &BranchInfo { + branches + .iter() + .find(|branch| branch.name == "feature") + .expect("the feature branch survives the carry-forward") + } + + #[test] + fn a_repository_with_no_trunk_does_not_ask_for_the_walk_forever() { + // `develop`, checked out, with no `main` or `master` anywhere. The + // merged-into-main answer is unavailable, not outstanding, and reading + // it as outstanding would put a full history walk behind every file + // save for the life of the repository. + let mut develop = branch("develop", 1); + develop.is_head = true; + develop.is_merged_into_head = Some(true); + assert!(!needs_refresh(&[develop])); + } + + #[test] + fn a_branch_tracking_an_upstream_nothing_lists_does_not_ask_either() { + // The upstream ref is gone — deleted on the remote, or never fetched. + // Nothing a walk can do will produce counts against it. + let mut orphan = branch("feature", 2); + orphan.upstream = Some("origin/feature".to_string()); + orphan.is_merged_into_head = Some(false); + assert!(!needs_refresh(&[orphan])); + } + + #[test] + fn a_detached_head_still_asks_for_the_walk() { + // `git checkout ` in a terminal leaves no branch marked + // `is_head`, but HEAD still resolves and the walk can still answer. + // Treating the missing branch as a missing reference point would strand + // every merged-into-current badge as unknown. + let feature = branch("feature", 2); + assert!(!needs_refresh(std::slice::from_ref(&feature))); + assert!(graph_state_is_incomplete(&[feature], true)); + } + + #[test] + fn a_missing_answer_that_a_walk_could_supply_still_asks() { + // `main` is present, so merged-into-main is a question with an answer, + // and nothing has supplied it. + let feature = branch("feature", 2); + assert!(needs_refresh(&[trunk(1), feature])); + } + + #[test] + fn a_fully_answered_repository_asks_for_nothing() { + let branches = vec![ + { + let mut main = trunk(1); + main.is_merged_into_main = Some(true); + main.is_merged_into_head = Some(true); + main + }, + tracked_feature(2), + remote("origin/feature", 2), + ]; + assert!(!needs_refresh(&branches)); + } + + /// An incoming `feature` that tracks its upstream and knows nothing else. + fn incoming_feature(tip: u8) -> BranchInfo { + let mut feature = branch("feature", tip); + feature.upstream = Some("origin/feature".to_string()); + feature + } + + #[test] + fn answers_the_snapshot_did_not_compute_keep_their_previous_values() { + let before = vec![trunk(1), remote("origin/feature", 5), tracked_feature(2)]; + let incoming = vec![trunk(1), remote("origin/feature", 5), incoming_feature(2)]; + + let after = carry_forward_branch_graph_state(&before, incoming); + let feature = feature_of(&after); + + assert_eq!(feature.is_merged_into_main, Some(true)); + assert_eq!(feature.is_merged_into_head, Some(true)); + assert_eq!((feature.ahead, feature.behind), (Some(3), Some(2))); + } + + #[test] + fn a_moved_tip_discards_what_was_known_about_the_old_one() { + // The answers described the previous commit. Showing them against a new + // one would be worse than showing nothing. + let before = vec![trunk(1), remote("origin/feature", 5), tracked_feature(2)]; + let incoming = vec![trunk(1), remote("origin/feature", 5), incoming_feature(9)]; + + let after = carry_forward_branch_graph_state(&before, incoming); + let feature = feature_of(&after); + + assert_eq!(feature.is_merged_into_main, None); + assert_eq!(feature.ahead, None); + } + + #[test] + fn checking_out_another_branch_invalidates_merged_into_head_but_not_into_main() { + // The branch did not move; the thing it was compared against did. This + // is the case a tip-only key cannot see, and it is the common one — it + // is what `git checkout` in a terminal looks like from here. + let before = vec![trunk(1), tracked_feature(2)]; + + let mut moved_head = incoming_feature(2); + moved_head.is_head = true; + let incoming = vec![branch("main", 1), moved_head]; + + let after = carry_forward_branch_graph_state(&before, incoming); + let feature = feature_of(&after); + + assert_eq!( + feature.is_merged_into_head, None, + "HEAD moved, so the answer measured against the old HEAD is gone" + ); + assert_eq!( + feature.is_merged_into_main, + Some(true), + "main did not move, so that answer still holds" + ); + } + + #[test] + fn a_moved_trunk_invalidates_merged_into_main() { + let before = vec![trunk(1), tracked_feature(2)]; + let incoming = vec![trunk(7), incoming_feature(2)]; + + let after = carry_forward_branch_graph_state(&before, incoming); + assert_eq!(feature_of(&after).is_merged_into_main, None); + } + + #[test] + fn pushing_a_branch_does_not_leave_it_reporting_the_old_ahead_count() { + // A push moves the upstream and leaves the local tip alone, so a key + // built only from the local tip matches and carries "ahead 3" onto a + // branch that is now level. + let before = vec![trunk(1), remote("origin/feature", 5), tracked_feature(2)]; + let incoming = vec![trunk(1), remote("origin/feature", 2), incoming_feature(2)]; + + let after = carry_forward_branch_graph_state(&before, incoming); + let feature = feature_of(&after); + + assert_eq!((feature.ahead, feature.behind), (None, None)); + assert_eq!( + feature.is_merged_into_main, + Some(true), + "the upstream is irrelevant to the merged flags" + ); + } + + #[test] + fn a_branch_level_with_its_upstream_is_not_mistaken_for_an_uncomputed_one() { + // `Some(0)` is an answer. The sentinel this replaced could not tell it + // from "no walk has run", and overwrote it with whatever came before. + let before = vec![trunk(1), remote("origin/feature", 2), tracked_feature(2)]; + let mut level = incoming_feature(2); + level.ahead = Some(0); + level.behind = Some(0); + let incoming = vec![trunk(1), remote("origin/feature", 2), level]; + + let after = carry_forward_branch_graph_state(&before, incoming); + let feature = feature_of(&after); + + assert_eq!((feature.ahead, feature.behind), (Some(0), Some(0))); + } + + #[test] + fn a_freshly_computed_flag_wins_over_the_old_one() { + let mut stale = tracked_feature(2); + stale.is_merged_into_main = Some(false); + let before = vec![trunk(1), stale]; + + let mut fresh = incoming_feature(2); + fresh.is_merged_into_main = Some(true); + let incoming = vec![trunk(1), fresh]; + + let after = carry_forward_branch_graph_state(&before, incoming); + assert_eq!(feature_of(&after).is_merged_into_main, Some(true)); + } + + #[test] + fn a_branch_that_is_new_is_left_alone() { + let before = vec![trunk(1), branch("old", 3)]; + let after = carry_forward_branch_graph_state(&before, vec![trunk(1), branch("new", 9)]); + + assert_eq!(after.len(), 2); + let fresh = after + .iter() + .find(|branch| branch.name == "new") + .expect("the new branch is present"); + assert_eq!(fresh.is_merged_into_main, None); + } + + #[test] + fn nothing_is_carried_when_the_reference_points_cannot_be_resolved() { + // No trunk in the list and no branch claiming HEAD: both answers were + // measured against something this snapshot cannot identify, so neither + // is safe to keep. + let before = vec![tracked_feature(2)]; + let after = carry_forward_branch_graph_state(&before, vec![incoming_feature(2)]); + + assert_eq!(after[0].is_merged_into_main, None); + assert_eq!(after[0].is_merged_into_head, None); + } +} + +#[cfg(test)] +mod ahead_behind_tests { + use super::{local_branch_name, parse_upstream_track}; + + #[test] + fn a_full_refname_gives_the_name_the_snapshot_uses() { + assert_eq!(local_branch_name("refs/heads/main"), Some("main")); + assert_eq!( + local_branch_name("refs/heads/feature/login"), + Some("feature/login") + ); + } + + #[test] + fn a_branch_sharing_a_tag_s_name_still_resolves() { + // This is the case `%(refname:short)` used to lose: with both + // `refs/heads/release` and `refs/tags/release` present, git shortens + // the branch to `heads/release`, which matches no branch at all and + // left the counts unknown for the one branch anybody would be watching. + assert_eq!(local_branch_name("refs/heads/release"), Some("release")); + } + + #[test] + fn anything_that_is_not_a_local_branch_is_declined() { + assert_eq!(local_branch_name("refs/tags/v1.0.0"), None); + assert_eq!(local_branch_name("refs/remotes/origin/main"), None); + assert_eq!(local_branch_name("refs/heads/"), None); + assert_eq!(local_branch_name("main"), None); + } + + #[test] + fn a_level_branch_prints_nothing_and_is_zero_zero() { + assert_eq!(parse_upstream_track(""), Some((0, 0))); + assert_eq!(parse_upstream_track(" "), Some((0, 0))); + } + + #[test] + fn both_directions_are_read() { + assert_eq!(parse_upstream_track("[ahead 2, behind 1]"), Some((2, 1))); + } + + #[test] + fn one_direction_leaves_the_other_at_zero() { + assert_eq!(parse_upstream_track("[ahead 3]"), Some((3, 0))); + assert_eq!(parse_upstream_track("[behind 4]"), Some((0, 4))); + } + + #[test] + fn a_gone_upstream_has_no_counts() { + assert_eq!(parse_upstream_track("[gone]"), None); + } + + #[test] + fn unparseable_input_is_rejected_rather_than_guessed() { + assert_eq!(parse_upstream_track("[ahead many]"), None); + assert_eq!(parse_upstream_track("ahead 2"), None); + } +} + +#[cfg(test)] +mod tests { + use super::{normalize_repo_path, GitProject}; + use rgitui_test_support::TempRepo; + use std::path::PathBuf; + + fn has_adjacent_legacy_change_events(source: &str) -> bool { + let is_legacy = |line: &str| { + let line = line.trim(); + line == "cx.emit(GitProjectEvent::StatusChanged);" + || line == "cx.emit(GitProjectEvent::HeadChanged);" + || line == "cx.emit(GitProjectEvent::RefsChanged);" + }; + source + .lines() + .zip(source.lines().skip(1)) + .any(|(left, right)| is_legacy(left) && is_legacy(right)) + } + + #[test] + fn background_work_is_reported_until_every_guard_drops() { + let project = GitProject::empty_at(PathBuf::from(".")); + assert!(!project.has_background_work()); + + let first = project.track_background_work(); + let second = project.track_background_work(); + assert!(project.has_background_work()); + + drop(first); + assert!( + project.has_background_work(), + "one refresh finishing must not make a project with another in flight look idle" + ); + + drop(second); + assert!(!project.has_background_work()); + } + + #[test] + fn a_dropped_refresh_still_lowers_the_background_count() { + // A task cancelled by a newer refresh never reaches its own completion + // path, so the count has to come down from `Drop` or anything waiting + // for the project to settle waits forever. + let project = GitProject::empty_at(PathBuf::from(".")); + { + let _abandoned = project.track_background_work(); + assert!(project.has_background_work()); + } + assert!(!project.has_background_work()); + } + + #[test] + fn aggregate_operations_do_not_emit_adjacent_legacy_change_events() { + for source in [ + include_str!("local_ops.rs"), + include_str!("network.rs"), + include_str!("rebase.rs"), + ] { + assert!(!has_adjacent_legacy_change_events(source)); + } + } + + /// Build a repo with one commit containing `tracked.txt`. + fn repo_with_one_commit() -> TempRepo { + let fixture = TempRepo::init(); + fixture.commit_file("tracked.txt", "original\n", "initial"); + fixture + } + + #[test] + fn untracked_files_do_not_make_the_worktree_dirty() { + let fixture = repo_with_one_commit(); + let repo = fixture.repo(); + assert!(!super::repo_has_worktree_changes(repo).unwrap()); + + // A scratch file with no .gitignore entry must not block checkout. + fixture.write_file("notes.txt", "scratch"); + fixture.write_file("build/out.o", "junk"); + + assert!( + !super::repo_has_worktree_changes(repo).unwrap(), + "untracked files must not count as worktree changes" + ); + assert!(super::ensure_clean_worktree(repo, "Checkout").is_ok()); + } + + #[test] + fn modified_tracked_files_make_the_worktree_dirty() { + let fixture = repo_with_one_commit(); + fixture.write_file("tracked.txt", "edited\n"); + + assert!(super::repo_has_worktree_changes(fixture.repo()).unwrap()); + let err = super::ensure_clean_worktree(fixture.repo(), "Checkout").unwrap_err(); + assert!(err.to_string().contains("Checkout")); + } + + #[test] + fn staged_changes_make_the_worktree_dirty() { + let fixture = repo_with_one_commit(); + fixture.write_file("staged.txt", "new\n"); + fixture.stage("staged.txt"); + + assert!( + super::repo_has_worktree_changes(fixture.repo()).unwrap(), + "a staged addition is a tracked change" + ); + } + + #[test] + fn normalize_non_unc_path_unchanged() { + let path = PathBuf::from("/home/user/repo"); + assert_eq!(normalize_repo_path(path.clone()), path); + } + + #[test] + fn normalize_relative_path_unchanged() { + let path = PathBuf::from("./my-repo"); + assert_eq!(normalize_repo_path(path.clone()), path); + } + + #[test] + fn changing_commit_author_filter_invalidates_async_commit_queries() { + let mut project = GitProject::empty_at(PathBuf::from("repo")); + let initial = project.commit_query_generation; + project.load_more_in_flight = true; + + project.set_commit_author_filter(Some("person+tag@example.com".to_string())); + + assert_ne!(project.commit_query_generation, initial); + assert!(!project.load_more_in_flight); + assert!(project.recent_commits.is_empty()); + assert_eq!(project.commit_offset, 0); + assert!(project.has_more_commits); + + let unchanged = project.commit_query_generation; + project.set_commit_author_filter(Some("person+tag@example.com".to_string())); + assert_eq!(project.commit_query_generation, unchanged); + } + + #[cfg(target_os = "windows")] + #[test] + fn normalize_wsl_localhost_unc_path() { + let input = PathBuf::from(r"\\wsl.localhost\archlinux\home\user\repo"); + let expected = PathBuf::from("//wsl.localhost/archlinux/home/user/repo"); + assert_eq!(normalize_repo_path(input), expected); + } + + #[cfg(target_os = "windows")] + #[test] + fn normalize_wsl_dollar_unc_path() { + let input = PathBuf::from(r"\\wsl$\Ubuntu\home\user\project"); + let expected = PathBuf::from("//wsl$/Ubuntu/home/user/project"); + assert_eq!(normalize_repo_path(input), expected); + } + + #[cfg(target_os = "windows")] + #[test] + fn normalize_windows_drive_path_unchanged() { + // Regular Windows drive paths must not be modified. + let path = PathBuf::from(r"C:\Users\user\repo"); + assert_eq!(normalize_repo_path(path.clone()), path); + } + + #[cfg(target_os = "windows")] + #[test] + fn normalize_network_share_unc_path() { + let input = PathBuf::from(r"\\server\share\project"); + let expected = PathBuf::from("//server/share/project"); + assert_eq!(normalize_repo_path(input), expected); + } + + #[cfg(target_os = "windows")] + #[test] + fn normalize_extended_length_prefix_unchanged() { + // \\?\ is the Windows extended-length prefix; std::fs::canonicalize + // emits it and libgit2 does not accept the slash-converted form. + let path = PathBuf::from(r"\\?\C:\Users\user\repo"); + assert_eq!(normalize_repo_path(path.clone()), path); + } + + #[cfg(target_os = "windows")] + #[test] + fn normalize_device_namespace_prefix_unchanged() { + let path = PathBuf::from(r"\\.\C:\Users\user\repo"); + assert_eq!(normalize_repo_path(path.clone()), path); + } +} diff --git a/third_party/rgitui/crates/rgitui_git/src/project/network.rs b/third_party/rgitui/crates/rgitui_git/src/project/network.rs new file mode 100644 index 000000000..92554ce76 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_git/src/project/network.rs @@ -0,0 +1,510 @@ +use anyhow::{Context as _, Result}; +use git2::Repository; +use gpui::{AsyncApp, Context, Task, WeakEntity}; +use std::path::Path; + +use crate::types::*; + +use super::argsafe::{validate_branch_name, validate_remote_name}; +use super::auth::run_git_network_command; +use super::refresh::gather_refresh_data; +use super::{ + ensure_clean_worktree, head_branch_name, pull_target, push_target, GitProject, GitProjectEvent, + RefreshData, +}; + +/// Open the repository handle for a specific working tree. Network operations +/// resolve their branch and upstream from *this* checkout, which is what makes +/// pushing from an inspected worktree push that worktree's branch rather than +/// the branch the main checkout happens to be on. +fn open_worktree_repo(worktree_path: &Path) -> Result { + Repository::open(worktree_path) + .with_context(|| format!("Failed to open worktree at {}", worktree_path.display())) +} + +impl GitProject { + pub fn fetch_default_at( + &mut self, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!("fetch_default: worktree={}", worktree_path.display()); + let remote_name = match self.preferred_remote_name_at(worktree_path) { + Ok(remote_name) => remote_name, + Err(error) => { + return self.fail_to_start_task( + GitOperationKind::Fetch, + "Fetch could not start", + error, + true, + worktree_path, + cx, + ) + } + }; + self.fetch_at(&remote_name, worktree_path, cx) + } + + pub fn pull_default_at( + &mut self, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!("pull_default: worktree={}", worktree_path.display()); + let (remote_name, branch_name) = + match open_worktree_repo(worktree_path).and_then(|repo| pull_target(&repo, None)) { + Ok(target) => target, + Err(error) => { + return self.fail_to_start_task( + GitOperationKind::Pull, + "Pull could not start", + error, + true, + worktree_path, + cx, + ) + } + }; + self.pull_from_at(&remote_name, &branch_name, worktree_path, cx) + } + + pub fn push_default_at( + &mut self, + force: bool, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!( + "push_default: force={} worktree={}", + force, + worktree_path.display() + ); + let (remote_name, branch_name, set_upstream) = + match open_worktree_repo(worktree_path).and_then(|repo| push_target(&repo, None)) { + Ok(target) => target, + Err(error) => { + return self.fail_to_start_task( + GitOperationKind::Push, + "Push could not start", + error, + true, + worktree_path, + cx, + ) + } + }; + self.push_to_at( + &remote_name, + &branch_name, + force, + set_upstream, + worktree_path, + cx, + ) + } + + /// Fetch from a remote, running git in the given worktree. + pub fn fetch_at( + &mut self, + remote_name: &str, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!( + "fetch: remote={} worktree={}", + remote_name, + worktree_path.display() + ); + let remote_name = remote_name.to_string(); + let task_remote_name = remote_name.clone(); + let task_worktree_path = worktree_path.to_path_buf(); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let operation_id = self.begin_operation( + GitOperationKind::Fetch, + format!("Fetching from '{}'...", remote_name), + Some(remote_name.clone()), + self.head_branch_at(worktree_path), + cx, + ); + self.note_operation_worktree(operation_id, worktree_path); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result<(Option, RefreshData)> = cx + .background_executor() + .spawn(async move { + validate_remote_name(&task_remote_name)?; + let details = run_git_network_command( + &task_worktree_path, + &["fetch", "--prune", &task_remote_name], + )?; + let data = gather_refresh_data(&repo_path, commit_limit)?; + anyhow::Ok((details, data)) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok((details, data)) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Fetch, + format!("Fetched from '{}'", remote_name), + ( + details.or(Some("Remote refs refreshed.".into())), + Some(remote_name.clone()), + this.head_branch.clone(), + ), + cx, + ); + cx.emit(GitProjectEvent::RefsChanged); + } + Err(e) => { + log::error!("Fetch failed: {}", e); + this.fail_op( + operation_id, + GitOperationKind::Fetch, + format!("Fetch from '{}' failed", remote_name), + e.to_string(), + (Some(remote_name.clone()), this.head_branch.clone(), true), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Pull into the given worktree, merging into the branch *that* worktree + /// has checked out. + pub fn pull_at( + &mut self, + remote_name: &str, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!( + "pull: remote={} worktree={}", + remote_name, + worktree_path.display() + ); + let branch_name = match open_worktree_repo(worktree_path) + .and_then(|repo| pull_target(&repo, Some(remote_name)).map(|(_, branch)| branch)) + { + Ok(branch_name) => branch_name, + Err(error) => { + return self.fail_to_start_task( + GitOperationKind::Pull, + "Pull could not start", + error, + true, + worktree_path, + cx, + ) + } + }; + self.pull_from_at(remote_name, &branch_name, worktree_path, cx) + } + + pub fn pull_from_at( + &mut self, + remote_name: &str, + branch_name: &str, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!( + "pull_from: remote={} branch={} worktree={}", + remote_name, + branch_name, + worktree_path.display() + ); + let remote_name = remote_name.to_string(); + let branch_name = branch_name.to_string(); + let task_remote_name = remote_name.clone(); + let task_branch_name = branch_name.clone(); + let task_worktree_path = worktree_path.to_path_buf(); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let operation_id = self.begin_operation( + GitOperationKind::Pull, + format!("Pulling '{}' from '{}'...", branch_name, remote_name), + Some(remote_name.clone()), + Some(branch_name.clone()), + cx, + ); + self.note_operation_worktree(operation_id, worktree_path); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result<(String, Option, RefreshData)> = cx + .background_executor() + .spawn(async move { + let (msg, details) = { + let repo = Repository::open(&task_worktree_path)?; + ensure_clean_worktree(&repo, "Pull")?; + drop(repo); + + validate_remote_name(&task_remote_name)?; + validate_branch_name(&task_branch_name)?; + match run_git_network_command( + &task_worktree_path, + &["pull", &task_remote_name, &task_branch_name], + ) { + Ok(details) => { + let msg = if details + .as_deref() + .map(|text| text.contains("Already up to date.")) + .unwrap_or(false) + { + format!( + "'{}' is already up to date with '{}'", + task_branch_name, task_remote_name + ) + } else { + format!( + "Pulled '{}' from '{}'", + task_branch_name, task_remote_name + ) + }; + (msg, details) + } + Err(e) => { + let error_msg = e.to_string(); + if error_msg.contains("CONFLICT") + || error_msg.contains("Automatic merge failed") + { + let repo = Repository::open(&task_worktree_path)?; + let conflict_count = + repo.index()?.conflicts()?.count(); + let msg = format!( + "CONFLICT:{} conflict(s) during pull from '{}/{}'. Resolve and continue.", + conflict_count, task_remote_name, task_branch_name + ); + (msg, Some(error_msg)) + } else { + return Err(e); + } + } + } + }; // repo dropped here + + let data = gather_refresh_data(&repo_path, commit_limit)?; + Ok((msg, details, data)) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok((msg, details, data)) => { + let is_conflict = msg.starts_with("CONFLICT:"); + this.apply_refresh_data(data); + if is_conflict { + let user_msg = msg.trim_start_matches("CONFLICT:").to_string(); + this.fail_op( + operation_id, + GitOperationKind::Pull, + format!("Pull from '{}' has conflicts", remote_name), + user_msg, + (Some(remote_name.clone()), Some(branch_name.clone()), false), + cx, + ); + } else { + this.complete_op( + operation_id, + GitOperationKind::Pull, + msg, + (details.or(Some( + "Repository state refreshed after pull.".into(), + )), Some(remote_name.clone()), Some(branch_name.clone())), + cx, + ); + } + cx.emit(GitProjectEvent::RepositoryChanged); + } + Err(e) => { + log::error!("Pull failed: {}", e); + this.fail_op( + operation_id, + GitOperationKind::Pull, + format!("Pull from '{}' failed", remote_name), + e.to_string(), + (Some(remote_name.clone()), Some(branch_name.clone()), true), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Push the branch checked out in the given worktree. + pub fn push_at( + &mut self, + remote_name: &str, + force: bool, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!( + "push: remote={} force={} worktree={}", + remote_name, + force, + worktree_path.display() + ); + let (branch_name, set_upstream) = match open_worktree_repo(worktree_path).and_then(|repo| { + push_target(&repo, Some(remote_name)).map(|(_, branch, set)| (branch, set)) + }) { + Ok(target) => target, + Err(error) => { + return self.fail_to_start_task( + GitOperationKind::Push, + "Push could not start", + error, + true, + worktree_path, + cx, + ) + } + }; + self.push_to_at( + remote_name, + &branch_name, + force, + set_upstream, + worktree_path, + cx, + ) + } + + pub fn push_to_at( + &mut self, + remote_name: &str, + remote_branch_name: &str, + force: bool, + set_upstream: bool, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + log::info!( + "push_to: remote={} branch={} force={} worktree={}", + remote_name, + remote_branch_name, + force, + worktree_path.display() + ); + let remote_name = remote_name.to_string(); + let remote_branch_name = remote_branch_name.to_string(); + let task_remote_name = remote_name.clone(); + let task_remote_branch_name = remote_branch_name.clone(); + let task_worktree_path = worktree_path.to_path_buf(); + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let operation_id = self.begin_operation( + GitOperationKind::Push, + if force { + format!( + "Force pushing to '{}/{}'...", + remote_name, remote_branch_name + ) + } else { + format!("Pushing to '{}/{}'...", remote_name, remote_branch_name) + }, + Some(remote_name.clone()), + Some(remote_branch_name.clone()), + cx, + ); + self.note_operation_worktree(operation_id, worktree_path); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result<(String, Option, RefreshData)> = cx + .background_executor() + .spawn(async move { + let (msg, details) = { + let repo = Repository::open(&task_worktree_path)?; + let branch_name = head_branch_name(&repo)?; + drop(repo); + + validate_remote_name(&task_remote_name)?; + validate_branch_name(&task_remote_branch_name)?; + + let mut args = vec!["push"]; + if force { + args.push("--force"); + } + if set_upstream { + args.push("--set-upstream"); + } + args.push(task_remote_name.as_str()); + let refspec = format!("HEAD:{}", task_remote_branch_name); + args.push(refspec.as_str()); + let details = run_git_network_command(&task_worktree_path, &args)?; + let msg = if force { + format!( + "Force pushed '{}' to '{}/{}'", + branch_name, task_remote_name, task_remote_branch_name + ) + } else if set_upstream { + format!( + "Pushed '{}' to '{}/{}' and set upstream", + branch_name, task_remote_name, task_remote_branch_name + ) + } else { + format!( + "Pushed '{}' to '{}/{}'", + branch_name, task_remote_name, task_remote_branch_name + ) + }; + (msg, details) + }; + let data = gather_refresh_data(&repo_path, commit_limit)?; + Ok((msg, details, data)) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok((msg, details, data)) => { + this.apply_refresh_data(data); + this.complete_op( + operation_id, + GitOperationKind::Push, + msg, + ( + details.or(Some("Remote refs refreshed after push.".into())), + Some(remote_name.clone()), + Some(remote_branch_name.clone()), + ), + cx, + ); + cx.emit(GitProjectEvent::RefsChanged); + } + Err(e) => { + log::error!("Push failed: {}", e); + this.fail_op( + operation_id, + GitOperationKind::Push, + format!("Push to '{}' failed", remote_name), + e.to_string(), + ( + Some(remote_name.clone()), + Some(remote_branch_name.clone()), + true, + ), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } +} diff --git a/third_party/rgitui/crates/rgitui_git/src/project/rebase.rs b/third_party/rgitui/crates/rgitui_git/src/project/rebase.rs new file mode 100644 index 000000000..c85bd6c47 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_git/src/project/rebase.rs @@ -0,0 +1,257 @@ +use anyhow::{Context as _, Result}; +use git2::Repository; +use gpui::{AsyncApp, Context, Task, WeakEntity}; + +use crate::types::*; + +use super::argsafe::sh_quote; +use super::ensure_clean_worktree; +use super::refresh::gather_refresh_data; +use super::{GitProject, GitProjectEvent, RefreshData}; + +impl GitProject { + /// Perform an interactive rebase using a prepared plan. + /// + /// Writes the desired todo list to a temp file and invokes `git rebase -i` + /// with `GIT_SEQUENCE_EDITOR` set to a command that replaces the editor file + /// with the prepared plan. For reword actions, the new message is written to + /// its own file and applied with `exec git commit --amend --file`, keeping + /// the message off the shell-interpreted `exec` line. + pub fn rebase_interactive( + &mut self, + entries: Vec, + cx: &mut Context, + ) -> Task> { + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let entry_count = entries.len(); + let operation_id = self.begin_operation( + GitOperationKind::Rebase, + format!("Rebasing {} commits...", entry_count), + None, + branch_name.clone(), + cx, + ); + + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result<(String, RefreshData)> = cx + .background_executor() + .spawn(async move { + if entries.is_empty() { + anyhow::bail!("No entries provided for interactive rebase"); + } + + let base_oid = { + let repo = Repository::open(&repo_path)?; + ensure_clean_worktree(&repo, "Interactive rebase")?; + + // The plan must cover exactly HEAD's contiguous first-parent + // range `base..HEAD`. Derive that range from HEAD itself + // (NOT from the date-ordered `--all` commit list the plan was + // built from) and verify the plan's commit set matches it, so + // a stale or cross-branch plan can't drop, duplicate, or + // replay commits from an unrelated branch and corrupt history. + // Reordering within the range is allowed (only the set and the + // base are validated). + let head_commit = repo.head()?.peel_to_commit()?; + let mut range: Vec = Vec::with_capacity(entries.len()); + let mut walk = head_commit; + for _ in 0..entries.len() { + range.push(walk.id()); + walk = walk + .parent(0) + .context("rebase range extends past the root commit")?; + } + // `walk` is now the first parent of the oldest in-range commit. + let base = walk.id(); + + let plan_set: std::collections::HashSet = entries + .iter() + .map(|e| git2::Oid::from_str(&e.oid)) + .collect::>()?; + let range_set: std::collections::HashSet = + range.iter().copied().collect(); + if plan_set != range_set { + anyhow::bail!( + "Interactive rebase plan does not match the current branch's \ + history (the selected commits are not exactly the last {} \ + first-parent commits of HEAD). Refresh and try again.", + entries.len() + ); + } + + base.to_string() + }; + + // Holds the todo list and any reword message files. Dropped + // once `git rebase` has exited. + let scratch = tempfile::Builder::new() + .prefix("rgitui-rebase-") + .tempdir() + .context("Failed to create a temporary directory for the rebase plan")?; + + let mut todo_lines = Vec::new(); + for (index, entry) in entries.iter().rev().enumerate() { + let short_oid = if entry.oid.len() >= 7 { + &entry.oid[..7] + } else { + &entry.oid + }; + + match &entry.action { + RebaseEntryAction::Pick => { + todo_lines.push(format!("pick {} {}", short_oid, entry.message)); + } + RebaseEntryAction::Reword(new_msg) => { + todo_lines.push(format!("pick {} {}", short_oid, entry.message)); + // Git runs `exec` lines through the shell, so the + // message must never appear on the command line. + // Write it to a file and let git read it verbatim. + let msg_path = scratch.path().join(format!("reword-{index}.msg")); + std::fs::write(&msg_path, new_msg.as_bytes()).with_context( + || format!("Failed to write reword message for {short_oid}"), + )?; + todo_lines.push(format!( + "exec git commit --amend --file {}", + sh_quote(&msg_path.to_string_lossy()) + )); + } + RebaseEntryAction::Squash => { + todo_lines.push(format!("squash {} {}", short_oid, entry.message)); + } + RebaseEntryAction::Fixup => { + todo_lines.push(format!("fixup {} {}", short_oid, entry.message)); + } + RebaseEntryAction::Drop => { + todo_lines.push(format!("drop {} {}", short_oid, entry.message)); + } + } + } + + let todo_content = todo_lines.join("\n") + "\n"; + + let todo_file = scratch.path().join("git-rebase-todo"); + std::fs::write(&todo_file, &todo_content) + .context("Failed to write the rebase plan")?; + + let sequence_editor = if cfg!(windows) { + format!( + "cmd /c copy /y \"{}\" ", + todo_file.to_string_lossy().replace('/', "\\") + ) + } else { + format!("cp {} ", sh_quote(&todo_file.to_string_lossy())) + }; + + let mut cmd = super::git_command(); + cmd.current_dir(&repo_path) + .env("GIT_SEQUENCE_EDITOR", &sequence_editor) + .env("GIT_EDITOR", "true") + .args(["rebase", "-i", &base_oid]); + let output = cmd + .output() + .with_context(|| "Failed to execute git rebase -i")?; + + // A rebase that stopped on a conflict is still in progress, + // and its todo list still holds `exec ... --file ` + // lines pointing into the scratch directory. Deleting it now + // would make `git rebase --continue` fail to read the reword + // message. Keep the directory in that case and let the OS + // temp sweep reclaim it; otherwise git is done with it. + let rebase_still_in_progress = !output.status.success() + && Repository::open(&repo_path) + .map(|repo| { + matches!( + repo.state(), + git2::RepositoryState::Rebase + | git2::RepositoryState::RebaseInteractive + | git2::RepositoryState::RebaseMerge + ) + }) + .unwrap_or(false); + if rebase_still_in_progress { + let _ = scratch.keep(); + } else { + drop(scratch); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if output.status.success() { + let data = gather_refresh_data(&repo_path, commit_limit)?; + Ok(( + format!("Rebased {} commits successfully", entry_count), + data, + )) + } else if stderr.contains("CONFLICT") || stderr.contains("could not apply") { + let data = gather_refresh_data(&repo_path, commit_limit)?; + let detail: String = stderr.lines().take(3).collect::>().join(" "); + Ok(( + format!( + "CONFLICT:Rebase paused due to conflicts. \ + Resolve conflicts and use Continue or Abort. {}", + detail + ), + data, + )) + } else { + anyhow::bail!( + "git rebase -i failed:\nstdout: {}\nstderr: {}", + stdout.trim(), + stderr.trim() + ); + } + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok((msg, data)) => { + let is_conflict = msg.starts_with("CONFLICT:"); + this.apply_refresh_data(data); + if is_conflict { + let user_msg = msg.trim_start_matches("CONFLICT:").to_string(); + this.fail_op( + operation_id, + GitOperationKind::Rebase, + "Rebase paused due to conflicts", + user_msg, + (None, branch_name.clone(), false), + cx, + ); + } else { + this.complete_op( + operation_id, + GitOperationKind::Rebase, + msg, + ( + Some("Repository state refreshed after rebase.".into()), + None, + branch_name.clone(), + ), + cx, + ); + } + cx.emit(GitProjectEvent::RepositoryChanged); + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Rebase, + "Interactive rebase failed", + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } +} diff --git a/third_party/rgitui/crates/rgitui_git/src/project/reflog.rs b/third_party/rgitui/crates/rgitui_git/src/project/reflog.rs new file mode 100644 index 000000000..324f8f298 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_git/src/project/reflog.rs @@ -0,0 +1,196 @@ +use anyhow::Result; +use chrono::{TimeZone, Utc}; +use git2::Repository; +use std::path::Path; + +/// A single reflog entry. +#[derive(Debug, Clone)] +pub struct ReflogEntryInfo { + /// The new OID after the operation. + pub new_oid: git2::Oid, + /// The old OID before the operation (may be zero for initial entries). + pub old_oid: git2::Oid, + /// Short form of new OID. + pub new_short_id: String, + /// Short form of old OID (empty string if zero). + pub old_short_id: String, + /// Operation message (e.g., "commit: Add feature"). + pub message: Option, + /// Who performed the operation. + pub committer: String, + /// When the operation occurred. + pub time: chrono::DateTime, +} + +/// Maximum number of reflog entries to return. +const MAX_REFLOG_ENTRIES: usize = 200; + +/// Get reflog entries for a reference (typically "HEAD"). +pub fn compute_reflog(repo_path: &Path, ref_name: &str) -> Result> { + let repo = Repository::open(repo_path)?; + let reflog = repo.reflog(ref_name)?; + + let mut entries = Vec::new(); + for entry in reflog.iter() { + if entries.len() >= MAX_REFLOG_ENTRIES { + break; + } + + let new_oid = entry.id_new(); + let old_oid = entry.id_old(); + + // Check for zero OID (initial entry or special cases) + let zero_oid = git2::Oid::zero(); + let old_short_id = if old_oid == zero_oid { + String::new() + } else { + format!("{:.7}", old_oid) + }; + + let committer_sig = entry.committer(); + let time = Utc + .timestamp_opt(committer_sig.when().seconds(), 0) + .single() + .unwrap_or_else(Utc::now); + + entries.push(ReflogEntryInfo { + new_oid, + old_oid, + new_short_id: format!("{:.7}", new_oid), + old_short_id, + message: entry.message().map(str::to_string), + committer: committer_sig.name().unwrap_or("unknown").to_string(), + time, + }); + } + + // Reflog entries are returned newest-first, which is what we want + Ok(entries) +} + +use gpui::{AsyncApp, Context, Task, WeakEntity}; + +use super::GitProject; + +impl GitProject { + /// Get reflog entries for a reference. + pub fn reflog(&self, ref_name: &str) -> Result> { + compute_reflog(self.repo_path(), ref_name) + } + + /// Get reflog entries asynchronously. + pub fn reflog_async( + &self, + ref_name: String, + cx: &mut Context, + ) -> Task>> { + let repo_path = self.repo_path().to_path_buf(); + cx.spawn(async move |_this: WeakEntity, cx: &mut AsyncApp| { + cx.background_executor() + .spawn(async move { compute_reflog(&repo_path, &ref_name) }) + .await + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rgitui_test_support::TempRepo; + use std::path::Path; + + #[test] + fn reflog_returns_entries_for_head() { + let repo = TempRepo::with_commits(3); + let entries = compute_reflog(repo.path(), "HEAD").unwrap(); + // Each commit produces a reflog entry; 3 commits → at least 3 entries + assert!( + entries.len() >= 3, + "expected ≥3 entries, got {}", + entries.len() + ); + } + + #[test] + fn reflog_entries_have_short_ids() { + let repo = TempRepo::with_commits(2); + let entries = compute_reflog(repo.path(), "HEAD").unwrap(); + assert!(!entries.is_empty()); + let first = &entries[0]; + // new_short_id should be exactly 7 hex chars + assert_eq!(first.new_short_id.len(), 7, "short id should be 7 chars"); + } + + #[test] + fn reflog_initial_entry_has_empty_old_short_id() { + let repo = TempRepo::with_commits(1); + let entries = compute_reflog(repo.path(), "HEAD").unwrap(); + // The initial commit entry should have no old OID + let last = entries.last().unwrap(); + assert_eq!( + last.old_short_id, "", + "initial reflog entry should have empty old_short_id" + ); + } + + #[test] + fn reflog_entries_newest_first() { + let repo = TempRepo::with_commits(3); + let entries = compute_reflog(repo.path(), "HEAD").unwrap(); + // The newest entry should point to the same OID as HEAD + assert_eq!( + entries[0].new_oid, + repo.head_oid(), + "first entry should be newest (HEAD)" + ); + } + + #[test] + fn reflog_entry_committer_name() { + let repo = TempRepo::with_commits(1); + let entries = compute_reflog(repo.path(), "HEAD").unwrap(); + assert!(!entries.is_empty()); + assert_eq!(entries[0].committer, TempRepo::AUTHOR_NAME); + } + + #[test] + fn reflog_entry_has_message() { + let repo = TempRepo::with_commits(2); + let entries = compute_reflog(repo.path(), "HEAD").unwrap(); + // Latest entry should have a message + let first = &entries[0]; + assert!( + first.message.is_some(), + "reflog entry should have a message" + ); + let msg = first.message.as_deref().unwrap_or(""); + assert!( + msg.contains("commit"), + "message should mention commit, got: {:?}", + msg + ); + } + + #[test] + fn reflog_caps_at_max_entries() { + // Verify the function doesn't panic and returns ≤ MAX_REFLOG_ENTRIES + let repo = TempRepo::with_commits(5); + let entries = compute_reflog(repo.path(), "HEAD").unwrap(); + assert!(entries.len() <= 200); + } + + #[test] + fn reflog_unknown_ref_returns_empty_or_err() { + let repo = TempRepo::with_commits(1); + // git2 may return Ok([]) or Err for unknown refs; both are acceptable + if let Ok(entries) = compute_reflog(repo.path(), "refs/heads/nonexistent-branch-zz99") { + assert!(entries.is_empty(), "unknown ref should yield no entries"); + } + } + + #[test] + fn reflog_err_on_bad_repo_path() { + let result = compute_reflog(Path::new("/no/such/repo"), "HEAD"); + assert!(result.is_err()); + } +} diff --git a/third_party/rgitui/crates/rgitui_git/src/project/refresh.rs b/third_party/rgitui/crates/rgitui_git/src/project/refresh.rs new file mode 100644 index 000000000..ce9f43a9a --- /dev/null +++ b/third_party/rgitui/crates/rgitui_git/src/project/refresh.rs @@ -0,0 +1,2117 @@ +use anyhow::{Context as _, Result}; +use chrono::{TimeZone, Utc}; +use git2::{Repository, StatusOptions}; +use std::collections::{hash_map::DefaultHasher, HashMap, HashSet}; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use crate::types::*; + +use super::diff::batch_diff_stats; +use super::git_command; +use super::GitProject; +use super::GitProjectEvent; +use super::RefreshData; + +pub(super) const MAX_COMMIT_LIMIT: usize = 100_000; + +pub(super) fn normalize_commit_limit(limit: usize) -> usize { + limit.clamp(1, MAX_COMMIT_LIMIT) +} + +/// Per-worktree status cache: maps the worktree path to a (fingerprint, status) pair. +/// The fingerprint is derived from `repo.statuses()` output so it reflects both index +/// and working-tree changes, not just `.git` sentinel file mtimes. +pub(super) type WorktreeStatusCache = HashMap; + +/// Remove valid Co-Authored-By trailer lines from a commit message. +/// Only strips lines that have a parseable `Name ` format. +pub(super) fn clean_co_author_lines(message: &str) -> String { + let prefix = "co-authored-by:"; + let cleaned_lines: Vec<&str> = message + .lines() + .filter(|line| { + let lower = line.trim().to_ascii_lowercase(); + if !lower.starts_with(prefix) { + return true; + } + let rest = &line.trim()[prefix.len()..].trim(); + let has_email = rest.contains('<') && rest.contains('>'); + if !has_email { + return true; + } + if let Some(start) = rest.find('<') { + if let Some(end) = rest.find('>') { + let name = rest[..start].trim(); + let email = rest[start + 1..end].trim(); + return name.is_empty() || email.is_empty(); + } + } + true + }) + .collect(); + let cleaned = cleaned_lines.join("\n"); + cleaned.trim_end().to_string() +} + +/// Extract Co-Authored-By signatures from a commit message. +pub fn extract_co_authors(message: &str) -> Vec { + let prefix = "co-authored-by:"; + let mut co_authors = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for line in message.lines() { + let trimmed = line.trim(); + let lower = trimmed.to_ascii_lowercase(); + if lower.starts_with(prefix) { + let rest = &trimmed[prefix.len()..].trim(); + if let Some(email_start) = rest.find('<') { + if let Some(email_end) = rest.find('>') { + let name = rest[..email_start].trim().to_string(); + let email = rest[email_start + 1..email_end].trim().to_string(); + let identity = (name.to_lowercase(), email.to_lowercase()); + if !name.is_empty() && !email.is_empty() && seen.insert(identity) { + co_authors.push(Signature { name, email }); + } + } + } + } + } + co_authors +} + +/// Combined: clean message and extract co-authors in one pass. +#[cfg(test)] +fn parse_co_authors(message: &str) -> (String, Vec) { + let co_authors = extract_co_authors(message); + let cleaned = clean_co_author_lines(message); + (cleaned, co_authors) +} + +/// Record separator that won't appear in commit messages. +const LOG_RECORD_SEP: &str = "\x1e"; +/// Field separator within a single commit-log record. +const LOG_FIELD_SEP: &str = "\x1d"; + +/// The `--format` argument value for the commit-log subprocess, shared by the +/// full-refresh loader and the incremental load-more loader so the field layout +/// stays in lockstep. Fields, in order: oid, short_id, author_name, +/// author_email, committer_name, committer_email, timestamp, parent_oids, +/// summary, body. +fn commit_log_format() -> String { + format!( + "{rs}%H{gs}%h{gs}%an{gs}%ae{gs}%cn{gs}%ce{gs}%ct{gs}%P{gs}%s{gs}%b{gs}", + rs = LOG_RECORD_SEP, + gs = LOG_FIELD_SEP, + ) +} + +/// Parse the stdout of a `git log --format=commit_log_format()` invocation into +/// `CommitInfo`s, attaching ref labels from `ref_map`. +/// +/// Every record in `stdout` is parsed; pagination (the `-(limit + 1)` extra +/// record that signals "there are more", and the stable-cursor windowing used +/// by the incremental loader) is owned by the callers, so a single parser +/// serves both the full-refresh and load-more paths. +fn parse_git_log_records( + stdout: &str, + ref_map: &mut HashMap>, +) -> Vec { + let mut commits = Vec::new(); + + for record in stdout.split(LOG_RECORD_SEP) { + let record = record.trim(); + if record.is_empty() { + continue; + } + + let fields: Vec<&str> = record.splitn(11, LOG_FIELD_SEP).collect(); + if fields.len() < 10 { + continue; + } + + let oid = match git2::Oid::from_str(fields[0]) { + Ok(o) => o, + Err(_) => continue, + }; + let short_id = fields[1].to_string(); + let author_name = fields[2].to_string(); + let author_email = fields[3].to_string(); + let committer_name = fields[4].to_string(); + let committer_email = fields[5].to_string(); + let timestamp: i64 = fields[6].parse().unwrap_or(0); + let parent_oids: Vec = fields[7] + .split_whitespace() + .filter_map(|s| git2::Oid::from_str(s).ok()) + .collect(); + let summary = fields[8].to_string(); + let body = fields[9].trim(); + let message = if body.is_empty() { + summary.clone() + } else { + format!("{}\n\n{}", summary, clean_co_author_lines(body)) + }; + + let time = Utc.timestamp_opt(timestamp, 0).single(); + let refs = ref_map.remove(&oid).unwrap_or_default(); + + commits.push(CommitInfo { + oid, + short_id, + summary, + message, + author: Signature { + name: author_name, + email: author_email, + }, + committer: Signature { + name: committer_name, + email: committer_email, + }, + co_authors: Vec::new(), + time: time.unwrap_or_else(Utc::now), + parent_oids, + refs, + is_signed: false, + }); + } + + commits +} + +/// Canonical form used to compare two working-tree paths for identity. Git +/// reports the same checkout through several spellings (trailing separator, +/// `/` vs `\` on Windows, differing case), so raw `PathBuf` equality would let +/// the same worktree appear twice. Falls back to the path as given when it +/// cannot be resolved — e.g. a registry entry whose directory was deleted. +fn worktree_identity(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +/// Read the checked-out branch and HEAD commit of `repo`. +/// +/// git2's `shorthand()` returns a short OID rather than `None` on a detached +/// HEAD, so the detached case is reported separately and `branch` left empty — +/// otherwise every caller renders `a1b2c3d` as if it were a branch name. +fn head_summary(repo: &Repository) -> (Option, bool, Option) { + let detached = repo.head_detached().unwrap_or(false); + let head = repo.head().ok(); + let branch = if detached { + None + } else { + head.as_ref().and_then(|h| h.shorthand().map(String::from)) + }; + let head_oid = head.and_then(|h| h.target()); + (branch, detached, head_oid) +} + +/// The branch a HEAD reflog message says HEAD was switched away from. +/// +/// Every checkout records `checkout: moving from to ` — git's own +/// `checkout` and `switch`, and libgit2's `set_head` and `set_head_detached` +/// alike. `` is a branch name, or a commit id when HEAD was already +/// detached. Ref names cannot contain spaces, so the first ` to ` ends it. +fn checkout_source(message: &str) -> Option<&str> { + let (from, _to) = message + .strip_prefix("checkout: moving from ")? + .split_once(" to ")?; + (!from.is_empty()).then_some(from) +} + +/// The local branch HEAD was most recently switched away from, other than +/// `current`, provided it still exists. +/// +/// Commits HEAD was detached at are passed over rather than ending the search, +/// so after stepping from commit to commit the branch the user started on is +/// still what comes back. +pub(crate) fn previous_branch(repo: &Repository, current: Option<&str>) -> Option { + let reflog = repo.reflog("HEAD").ok()?; + reflog + .iter() + .filter_map(|entry| entry.message().and_then(checkout_source).map(str::to_owned)) + .find(|from| { + Some(from.as_str()) != current + && repo.find_branch(from, git2::BranchType::Local).is_ok() + }) +} + +/// Build a [`WorktreeInfo`] for the checkout at `path` by opening it directly. +fn worktree_info_at( + name: String, + path: PathBuf, + is_locked: bool, + is_current: bool, +) -> WorktreeInfo { + let opened = Repository::open(&path); + let (branch, head_detached, head_oid) = opened + .as_ref() + .map(head_summary) + .unwrap_or((None, false, None)); + let state = opened + .as_ref() + .map(|repo| RepoState::from_git2(repo.state())) + .unwrap_or(RepoState::Clean); + WorktreeInfo { + name, + path, + is_locked, + is_current, + branch, + head_detached, + head_oid, + status: None, + state, + } +} + +/// Gather information about all worktrees attached to this repository. +/// +/// Three sources are merged, deduplicated by canonical path: +/// 1. `repo.workdir()` — the checkout rgitui was opened on (`is_current`). +/// 2. The main checkout, derived from the common git dir. It is absent from +/// `repo.worktrees()` (which lists only *linked* worktrees), so without this +/// step it has no row at all when rgitui was opened on a linked worktree. +/// 3. `repo.worktrees()` — the linked-worktree registry, which still contains +/// the current checkout when that checkout is itself a linked worktree. +fn gather_worktrees(repo: &Repository) -> Vec { + let mut worktrees = Vec::new(); + let mut seen: HashSet = HashSet::new(); + + // The checkout this Repository handle points at. + let workdir = repo.workdir().unwrap_or_else(|| repo.path()).to_path_buf(); + let current_name = workdir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("main") + .to_string(); + let (branch, head_detached, head_oid) = head_summary(repo); + seen.insert(worktree_identity(&workdir)); + worktrees.push(WorktreeInfo { + name: current_name, + path: workdir, + is_locked: false, + is_current: true, + branch, + head_detached, + head_oid, + status: None, + state: RepoState::from_git2(repo.state()), + }); + + // The main checkout, when this handle was opened on a linked worktree. A + // linked worktree's git dir is `/worktrees/`, so the common + // dir's parent is where the main working directory should be. That is only + // a guess — a bare repository has no checkout there, and an unrelated + // repository could sit at that path — so it counts only if opening it + // yields a working directory sharing our common git dir. + let common_dir = repo.commondir().to_path_buf(); + if common_dir != repo.path() { + if let Some(main_workdir) = common_dir + .parent() + .and_then(|candidate| Repository::open(candidate).ok()) + .filter(|main| main.commondir() == common_dir) + .and_then(|main| main.workdir().map(Path::to_path_buf)) + { + if seen.insert(worktree_identity(&main_workdir)) { + if let Some(name) = main_workdir.file_name().and_then(|n| n.to_str()) { + let name = name.to_string(); + worktrees.push(worktree_info_at(name, main_workdir, false, false)); + } + } + } + } + + // The linked-worktree registry, shared by every checkout of this repository. + if let Ok(names) = repo.worktrees() { + for name in names.iter().flatten() { + if name.is_empty() { + continue; + } + if let Ok(wt) = repo.find_worktree(name) { + let path = wt.path().to_path_buf(); + if !seen.insert(worktree_identity(&path)) { + // Already listed — this is the checkout rgitui was opened on. + continue; + } + let is_locked = match wt.is_locked() { + Ok(git2::WorktreeLockStatus::Locked(_)) => true, + Ok(git2::WorktreeLockStatus::Unlocked) | Err(_) => false, + }; + worktrees.push(worktree_info_at(name.to_string(), path, is_locked, false)); + } + } + } + + // Sort: current worktree first, then alphabetically by name + worktrees.sort_by(|a, b| { + if a.is_current != b.is_current { + return b.is_current.cmp(&a.is_current); + } + a.name.cmp(&b.name) + }); + + worktrees +} + +/// Files at or below this size are content-hashed in full when fingerprinting +/// the working tree; larger ones are sampled (see [`mix_file_contents`]). +const FINGERPRINT_FULL_READ_LIMIT: u64 = 64 * 1024; + +/// Bytes taken from each end of a file that exceeds the full-read limit. +const FINGERPRINT_SAMPLE_LEN: u64 = 32 * 1024; + +/// Mix a workdir file's contents into `hasher`, reading a bounded number of +/// bytes. +/// +/// Size and mtime alone are not enough: a coarse-timestamp filesystem (FAT32, +/// some network/WSL/SMB mounts) can hold both steady across an edit. But +/// reading whole files here is unaffordable — this runs for every modified file +/// on every refresh, including the watcher's cached path, so a single large +/// file in the working tree would be re-read every few hundred milliseconds. +/// +/// Small files are hashed whole. Larger ones are sampled at both ends, which +/// still catches ordinary edits while keeping the work per file constant. +fn mix_file_contents(hasher: &mut DefaultHasher, path: &Path, len: u64) { + use std::io::{Read as _, Seek as _, SeekFrom}; + + if len <= FINGERPRINT_FULL_READ_LIMIT { + if let Ok(contents) = std::fs::read(path) { + contents.hash(hasher); + } + return; + } + + let Ok(mut file) = std::fs::File::open(path) else { + return; + }; + let sample = FINGERPRINT_SAMPLE_LEN as usize; + let mut buf = vec![0u8; sample]; + + if file.read_exact(&mut buf).is_ok() { + buf.hash(hasher); + } + if file.seek(SeekFrom::End(-(sample as i64))).is_ok() && file.read_exact(&mut buf).is_ok() { + buf.hash(hasher); + } +} + +/// Build a cheap fingerprint from `repo.statuses()` output. +/// +/// Captures: file paths, status flags, staged blob OIDs (index state), and +/// mtime+size plus bounded content sampling for workdir-modified tracked files +/// (content changes that don't move through the index). This is fast — libgit2 +/// uses its own stat cache for `statuses()`, and the additional `metadata()` +/// calls are only for files already flagged as workdir-modified. +fn status_fingerprint(repo_path: &Path, statuses: &git2::Statuses<'_>) -> u64 { + let mut hasher = DefaultHasher::new(); + for entry in statuses.iter() { + let path = entry.path().unwrap_or(""); + path.hash(&mut hasher); + entry.status().bits().hash(&mut hasher); + if let Some(delta) = entry.head_to_index() { + delta.new_file().id().as_bytes().hash(&mut hasher); + } + if entry.status().intersects( + git2::Status::WT_MODIFIED | git2::Status::WT_RENAMED | git2::Status::WT_TYPECHANGE, + ) { + let full_path = repo_path.join(path); + if let Ok(meta) = std::fs::metadata(&full_path) { + meta.len().hash(&mut hasher); + if let Ok(mtime) = meta.modified() { + mtime.hash(&mut hasher); + } + mix_file_contents(&mut hasher, &full_path, meta.len()); + } + } + } + hasher.finish() +} + +/// Gather all refresh data from a repository at the given path. +/// This is a standalone function (no `&self`) so it can run on a background thread. +fn compute_working_tree_status( + repo_path: &Path, + cache: Option<&Mutex>, +) -> Result { + let repo = Repository::open(repo_path)?; + let mut wt_status = WorkingTreeStatus::default(); + + let mut opts = StatusOptions::new(); + opts.include_untracked(true) + .recurse_untracked_dirs(true) + .include_unmodified(false); + + let statuses = repo.statuses(Some(&mut opts))?; + + // Compute fingerprint once; used for both the cache read and the cache write. + let fingerprint = cache.map(|_| status_fingerprint(repo_path, &statuses)); + + if let (Some(fp), Some(c)) = (fingerprint, cache) { + let guard = c.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((cached_fp, cached_status)) = guard.get(repo_path) { + if *cached_fp == fp { + log::debug!("worktree status cache hit: {}", repo_path.display()); + return Ok(cached_status.clone()); + } + } + } + + // Scoped OS threads rather than the executor: this whole function already + // runs on a background thread and borrows `repo_path`, so a scope keeps the + // borrow and costs two thread spawns, where routing through the executor + // would make every gather function async up to its call sites. + let (staged_stats, unstaged_stats) = std::thread::scope(|s| { + let staged_handle = s.spawn(|| { + let repo = Repository::open(repo_path).ok(); + repo.as_ref() + .map(|r| batch_diff_stats(r, true)) + .unwrap_or_default() + }); + let unstaged_handle = s.spawn(|| { + let repo = Repository::open(repo_path).ok(); + repo.as_ref() + .map(|r| batch_diff_stats(r, false)) + .unwrap_or_default() + }); + ( + staged_handle.join().unwrap_or_default(), + unstaged_handle.join().unwrap_or_default(), + ) + }); + + for entry in statuses.iter() { + let path = PathBuf::from(entry.path().unwrap_or("")); + let st = entry.status(); + + if st.intersects( + git2::Status::INDEX_NEW + | git2::Status::INDEX_MODIFIED + | git2::Status::INDEX_DELETED + | git2::Status::INDEX_RENAMED + | git2::Status::INDEX_TYPECHANGE, + ) { + let kind = if st.contains(git2::Status::INDEX_NEW) { + FileChangeKind::Added + } else if st.contains(git2::Status::INDEX_MODIFIED) { + FileChangeKind::Modified + } else if st.contains(git2::Status::INDEX_DELETED) { + FileChangeKind::Deleted + } else if st.contains(git2::Status::INDEX_RENAMED) { + FileChangeKind::Renamed + } else { + FileChangeKind::TypeChange + }; + let &(additions, deletions) = staged_stats.get(&path).unwrap_or(&(0, 0)); + wt_status.staged.push(FileStatus { + path: path.clone(), + kind, + old_path: None, + additions, + deletions, + }); + } + + if st.intersects( + git2::Status::WT_NEW + | git2::Status::WT_MODIFIED + | git2::Status::WT_DELETED + | git2::Status::WT_RENAMED + | git2::Status::WT_TYPECHANGE, + ) { + let kind = if st.contains(git2::Status::WT_NEW) { + FileChangeKind::Untracked + } else if st.contains(git2::Status::WT_MODIFIED) { + FileChangeKind::Modified + } else if st.contains(git2::Status::WT_DELETED) { + FileChangeKind::Deleted + } else if st.contains(git2::Status::WT_RENAMED) { + FileChangeKind::Renamed + } else { + FileChangeKind::TypeChange + }; + let &(additions, deletions) = unstaged_stats.get(&path).unwrap_or(&(0, 0)); + wt_status.unstaged.push(FileStatus { + path: path.clone(), + kind, + old_path: None, + additions, + deletions, + }); + } + + if st.contains(git2::Status::CONFLICTED) { + wt_status.unstaged.push(FileStatus { + path, + kind: FileChangeKind::Conflicted, + old_path: None, + additions: 0, + deletions: 0, + }); + } + } + + if let (Some(fp), Some(c)) = (fingerprint, cache) { + c.lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(repo_path.to_path_buf(), (fp, wt_status.clone())); + } + + Ok(wt_status) +} + +pub fn gather_refresh_data(repo_path: &Path, commit_limit: usize) -> Result { + log::debug!("gather_refresh_data: repo={}", repo_path.display()); + gather_refresh_data_internal(repo_path, true, commit_limit, None, None) +} + +/// Gather refresh data without computing ahead/behind for every branch. +/// +/// Use this for filesystem watcher events where only file status needs updating. +/// Ahead/behind values will be (0, 0) — they'll be recomputed on the next +/// full refresh from a git operation (fetch/push/pull) or explicit user refresh. +pub fn gather_refresh_data_lightweight( + repo_path: &Path, + commit_limit: usize, +) -> Result { + log::debug!( + "gather_refresh_data_lightweight: repo={}", + repo_path.display() + ); + gather_refresh_data_internal(repo_path, false, commit_limit, None, None) +} + +/// Like `gather_refresh_data_lightweight` but uses the per-worktree status cache to skip +/// `batch_diff_stats` for worktrees whose content has not changed since the last refresh. +/// +/// `author_filter`, when `Some`, restricts the loaded commit list to commits by +/// that author so a watcher-driven refresh keeps the "My Commits" view intact +/// instead of replacing it with the full unfiltered log. +pub(super) fn gather_refresh_data_lightweight_cached( + repo_path: &Path, + commit_limit: usize, + cache: &Mutex, + author_filter: Option<&str>, +) -> Result { + log::debug!( + "gather_refresh_data_lightweight_cached: repo={}", + repo_path.display() + ); + gather_refresh_data_internal(repo_path, false, commit_limit, Some(cache), author_filter) +} + +/// Every commit reachable from `tip`, including `tip` itself. +/// +/// A branch is merged into `tip` exactly when its own tip appears in this set, +/// which is the same answer `merge_base(branch_tip, tip) == branch_tip` gives +/// and costs one walk for all branches instead of one walk each. +/// +/// A walk that fails partway returns what it had rather than nothing: the +/// flags this feeds are a visual hint on a sidebar row, and under-reporting one +/// branch as unmerged is a far better failure than refusing to open the +/// repository. +pub(super) fn reachable_set(repo: &Repository, tip: git2::Oid) -> HashSet { + let mut reachable = HashSet::new(); + let Ok(mut walk) = repo.revwalk() else { + return reachable; + }; + if walk.push(tip).is_err() { + return reachable; + } + for oid in walk { + match oid { + Ok(oid) => { + reachable.insert(oid); + } + Err(_) => break, + } + } + reachable +} + +fn gather_refresh_data_internal( + repo_path: &Path, + compute_ahead_behind: bool, + commit_limit: usize, + worktree_cache: Option<&Mutex>, + author_filter: Option<&str>, +) -> Result { + let commit_limit = normalize_commit_limit(commit_limit); + let refresh_timer = std::time::Instant::now(); + let repo = Repository::open(repo_path) + .with_context(|| format!("Failed to open repository at {}", repo_path.display()))?; + + // Head + let (head_branch, head_detached, _) = head_summary(&repo); + let previous_branch = previous_branch(&repo, head_branch.as_deref()); + let repo_state = RepoState::from_git2(repo.state()); + + // Current user email (for "My Branches" / "My Commits" filtering) + let current_user_email = repo + .config() + .ok() + .and_then(|cfg| cfg.get_string("user.email").ok()); + + // Branches + // Branches — two-pass approach: + // Pass 1: collect basic info + last_commit_time + // Then find main branch tip OID + // Pass 2: compute is_merged_into_main using git ancestry check + let mut branches: Vec = Vec::new(); + { + let branch_iter = repo.branches(None)?; + for branch_result in branch_iter { + let (branch, branch_type) = branch_result?; + let name = branch.name()?.unwrap_or("").to_string(); + if name.is_empty() { + continue; + } + + let is_head = branch.is_head(); + let is_remote = branch_type == git2::BranchType::Remote; + let tip_oid = branch.get().target(); + + let upstream = branch + .upstream() + .ok() + .and_then(|u| u.name().ok().flatten().map(String::from)); + + // `None` rather than `(0, 0)` when the walk did not run: the + // deferred pass fills these in, and a snapshot that reported "level + // with upstream" here would be indistinguishable from one that had + // simply not looked yet. + let (ahead, behind) = if compute_ahead_behind { + match (tip_oid, branch.upstream()) { + (Some(local_oid), Ok(upstream_ref)) => upstream_ref + .get() + .target() + .and_then(|remote_oid| repo.graph_ahead_behind(local_oid, remote_oid).ok()) + .map_or((None, None), |(ahead, behind)| (Some(ahead), Some(behind))), + _ => (None, None), + } + } else { + (None, None) + }; + + let last_commit_time = + tip_oid.and_then(|oid| repo.find_commit(oid).ok().map(|c| c.time().seconds())); + + let author_email = tip_oid.and_then(|oid| { + repo.find_commit(oid) + .ok() + .and_then(|c| c.author().email().map(String::from)) + }); + + branches.push(BranchInfo { + name, + is_head, + is_remote, + upstream, + ahead, + behind, + tip_oid, + author_email, + last_commit_time, + is_merged_into_main: None, + is_merged_into_head: None, + }); + } + + branches.sort_by(|a, b| { + b.is_head + .cmp(&a.is_head) + .then(a.is_remote.cmp(&b.is_remote)) + .then(a.name.cmp(&b.name)) + }); + + // `is_merged_into_main` and `is_merged_into_head` are deliberately left + // unset. Both need a walk over the repository's history and decide + // nothing more than whether a sidebar row is dimmed, so they are filled + // in afterwards by `refresh_branch_graph_state`, once the commits the + // user is actually waiting for are on screen. Computing them here cost + // 139ms of the ~290ms it took to produce the whole snapshot. + // + // `None` means "not computed", which `apply_refresh_data` reads as + // "keep what was already known" — so a refresh never blanks a flag it + // simply did not recalculate. + } + + // Tags + let mut tags = Vec::new(); + if let Err(e) = repo.tag_foreach(|oid, name_bytes| { + if let Ok(name) = std::str::from_utf8(name_bytes) { + let name = name.strip_prefix("refs/tags/").unwrap_or(name).to_string(); + let peeled_oid = repo + .find_object(oid, None) + .and_then(|object| object.peel_to_commit()) + .map(|commit| commit.id()) + .unwrap_or(oid); + tags.push(TagInfo { + name, + oid: peeled_oid, + message: None, + }); + } + true + }) { + log::warn!("Failed to enumerate repository tags: {}", e); + } + tags.sort_by(|a, b| a.name.cmp(&b.name)); + + // Remotes + let mut remotes = Vec::new(); + { + let remote_names = repo.remotes()?; + for name in remote_names.iter().flatten() { + if let Ok(remote) = repo.find_remote(name) { + remotes.push(RemoteInfo { + name: name.to_string(), + url: remote.url().map(String::from), + push_url: remote.pushurl().map(String::from), + }); + } + } + } + + // Run status, stashes, worktrees in parallel; scoped threads for the same + // reason as in `compute_working_tree_status`. Status and stashes open their + // own repos, while worktrees and the revwalk share `&repo`. + let (status, stashes, worktrees) = std::thread::scope(|s| { + let status_handle = s.spawn(|| compute_working_tree_status(repo_path, worktree_cache)); + + let stash_handle = s.spawn(|| { + let mut stashes = Vec::new(); + if let Ok(mut repo_mut) = Repository::open(repo_path) { + let _ = repo_mut.stash_foreach(|stash_index, message, oid| { + stashes.push(StashEntry { + index: stash_index, + message: message.to_string(), + oid: *oid, + }); + true + }); + } + stashes + }); + + let mut worktrees = gather_worktrees(&repo); + let mut worktree_status_handles = Vec::new(); + for (idx, worktree) in worktrees.iter().enumerate() { + if worktree.is_current { + continue; + } + let worktree_path = worktree.path.clone(); + worktree_status_handles.push(( + idx, + s.spawn(move || compute_working_tree_status(&worktree_path, worktree_cache)), + )); + } + + let status = status_handle.join().unwrap().unwrap_or_default(); + if let Some(current_worktree) = worktrees.iter_mut().find(|wt| wt.is_current) { + current_worktree.status = Some(status.clone()); + } + + // A failed or panicked status thread leaves `None` — "not known yet" — + // rather than an empty status. An empty status is indistinguishable from + // a clean worktree, and callers act on that: it would silently drop the + // user out of worktree inspection because the checkout looked clean. + for (idx, handle) in worktree_status_handles { + worktrees[idx].status = match handle.join() { + Ok(Ok(status)) => Some(status), + Ok(Err(e)) => { + log::warn!( + "Failed to compute status for worktree {}: {}", + worktrees[idx].path.display(), + e + ); + None + } + Err(_) => { + log::warn!( + "Status thread panicked for worktree {}", + worktrees[idx].path.display() + ); + None + } + }; + } + + let stashes = stash_handle.join().unwrap_or_default(); + (status, stashes, worktrees) + }); + + // Recent commits — use git log subprocess for commit-graph acceleration. + // libgit2's revwalk doesn't use .git/objects/info/commit-graph, making it + // orders of magnitude slower on large repos like the Linux kernel. + let (recent_commits, has_more_commits) = { + let t_log = std::time::Instant::now(); + let limit = commit_limit; + + let mut ref_map = std::collections::HashMap::>::new(); + if let Ok(head) = repo.head() { + if let Some(oid) = head.target() { + ref_map.entry(oid).or_default().push(RefLabel::Head); + } + } + for branch in &branches { + if let Some(oid) = branch.tip_oid { + let label = if branch.is_remote { + RefLabel::RemoteBranch(branch.name.clone()) + } else { + RefLabel::LocalBranch(branch.name.clone()) + }; + ref_map.entry(oid).or_default().push(label); + } + } + for tag in &tags { + ref_map + .entry(tag.oid) + .or_default() + .push(RefLabel::Tag(tag.name.clone())); + } + + // `--topo-order` makes the subprocess emit commits in topological order + // (descendants before ancestors), the invariant the graph layout relies + // on. Without it git defaults to commit-date order, which is non-monotonic + // with topology for rebased/amended/cherry-picked or clock-skewed history + // and produces dangling lanes and misdrawn merges. + let mut log_cmd = git_command(); + log_cmd.current_dir(repo_path).args([ + "log", + "--branches", + "--remotes", + "--tags", + "--topo-order", + &format!("--format={}", commit_log_format()), + ]); + // Preserve an active "My Commits" filter across watcher/operation + // refreshes; without it the filtered list would be replaced by the full + // unfiltered log. + if let Some(author) = author_filter { + log_cmd.arg("--fixed-strings"); + log_cmd.arg(format!("--author={}", author)); + } + if head_detached { + log_cmd.arg("HEAD"); + } + log_cmd.arg(format!("-{}", limit.saturating_add(1))); + let output = log_cmd.output().with_context(|| "Failed to run git log")?; + + if !output.status.success() { + anyhow::bail!( + "git log failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut commits = parse_git_log_records(&stdout, &mut ref_map); + // The subprocess requested one extra commit (`-{limit + 1}`); its + // presence signals more history is available beyond the loaded window. + let has_more = commits.len() > limit; + commits.truncate(limit); + + log::debug!( + "git log completed in {:?}: {} commits", + t_log.elapsed(), + commits.len() + ); + (commits, has_more) + }; + + log::info!( + "gather_refresh_data_internal complete in {:?}: {} commits, {} branches, staged={} unstaged={}", + refresh_timer.elapsed(), + recent_commits.len(), + branches.len(), + status.staged.len(), + status.unstaged.len() + ); + Ok(RefreshData { + head_branch, + head_detached, + previous_branch, + repo_state, + branches, + tags, + remotes, + stashes, + status, + recent_commits, + has_more_commits, + worktrees, + default_branch: repo + .find_reference("refs/remotes/origin/HEAD") + .ok() + .and_then(|reference| reference.symbolic_target().map(str::to_owned)) + .and_then(|target| { + target + .strip_prefix("refs/remotes/origin/") + .map(str::to_owned) + }), + current_user_email, + }) +} + +/// Enrich a commit with is_signed and co_authors (deferred from the revwalk). +pub fn enrich_commit_info(repo_path: &Path, oid: git2::Oid) -> Result<(bool, Vec)> { + let repo = Repository::open(repo_path)?; + let commit = repo.find_commit(oid)?; + let is_signed = commit.header_field_bytes("gpgsig").is_ok(); + let raw_message = commit.message().unwrap_or(""); + let co_authors = extract_co_authors(raw_message); + Ok((is_signed, co_authors)) +} + +use gpui::{AsyncApp, Context, Task, WeakEntity}; +use std::sync::Arc; + +const FIRST_BATCH_SIZE: usize = 100; + +impl GitProject { + /// Refresh all state asynchronously on a background thread. + /// Uses two-phase loading: the first batch of commits loads quickly so the + /// UI appears populated during the splash animation, then the remainder + /// loads in the background. + pub fn refresh(&mut self, cx: &mut Context) -> Task> { + self.commit_query_generation = self.commit_query_generation.wrapping_add(1); + self.load_more_in_flight = false; + let query_generation = self.commit_query_generation; + let repo_path = self.repo_path.clone(); + let commit_limit = self.commit_limit; + let first_batch = FIRST_BATCH_SIZE.min(commit_limit); + let t = std::time::Instant::now(); + let cache = self.worktree_status_cache.clone(); + // Honour an active "My Commits" filter so the initial load matches the + // toggle instead of showing every author. + let author_filter = self.commit_author_filter.clone(); + + let work = self.track_background_work(); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + // Held for both phases, so a caller waiting for the project to + // settle waits for the remaining history too, not just the first + // batch that made the window look populated. + let _work = work; + // Phase 1: lightweight refresh (skip ahead/behind) with a small commit batch + let repo_path_p1 = repo_path.clone(); + let cache_path_p1 = repo_path.clone(); + let author_filter_p1 = author_filter.clone(); + let data = cx + .background_executor() + .spawn(async move { + let cache_fingerprint = if author_filter_p1.is_none() { + super::history_cache::ref_fingerprint(&cache_path_p1).ok() + } else { + None + }; + let data = gather_refresh_data_lightweight_cached( + &repo_path_p1, + first_batch, + &cache, + author_filter_p1.as_deref(), + )?; + if let Some(cache_fingerprint) = cache_fingerprint { + if let Err(error) = super::history_cache::store( + &cache_path_p1, + cache_fingerprint, + &data.recent_commits, + data.has_more_commits, + data.default_branch.as_deref(), + ) { + log::debug!("history cache write skipped: {}", error); + } + } + Ok::<_, anyhow::Error>(data) + }) + .await?; + + let needs_more = data.has_more_commits && first_batch < commit_limit; + let branch_tips: Vec<(git2::Oid, bool, String)> = data + .branches + .iter() + .filter_map(|b| b.tip_oid.map(|oid| (oid, b.is_remote, b.name.clone()))) + .collect(); + let tag_tips: Vec<(git2::Oid, String)> = + data.tags.iter().map(|t| (t.oid, t.name.clone())).collect(); + + cx.update(|cx| { + this.update(cx, |this, cx| { + if this.commit_query_generation != query_generation { + return Ok::<(), anyhow::Error>(()); + } + let reached_ui = t.elapsed(); + this.apply_refresh_data(data); + log::info!( + "refresh phase 1 applied in {:?} ({:?} waiting for the UI thread): {} commits", + t.elapsed(), + reached_ui, + this.recent_commits.len() + ); + cx.emit(GitProjectEvent::StatusChanged); + // Fire ahead/behind computation in the background (deferred from lightweight refresh) + this.refresh_ahead_behind(cx); + cx.notify(); + Ok::<(), anyhow::Error>(()) + }) + })??; + + // Phase 2: load remaining commits + if needs_more { + let remaining = commit_limit - first_batch; + let repo_path_p2 = repo_path.clone(); + let author_filter_p2 = author_filter.clone(); + // Page from the stable cursor (the oldest commit loaded in phase 1) + // rather than a numeric skip, so a ref change between the two + // subprocesses can't shift the boundary and skip a commit. + let (already_loaded, cursor) = cx.update(|cx| { + this.update(cx, |this, _| { + ( + this.recent_commits.len(), + this.recent_commits.last().map(|c| c.oid), + ) + }) + })?; + let (more_commits, has_more) = cx + .background_executor() + .spawn(async move { + load_more_commits_from_repo( + &repo_path_p2, + already_loaded, + cursor, + remaining, + &branch_tips, + &tag_tips, + author_filter_p2.as_deref(), + ) + }) + .await?; + + cx.update(|cx| { + this.update(cx, |this, cx| { + if this.commit_query_generation != query_generation { + return Ok(()); + } + let existing_oids: std::collections::HashSet = + this.recent_commits.iter().map(|c| c.oid).collect(); + // The background task handed us a fresh Arc, so it is + // uniquely held here; `make_mut` appends in place rather + // than deep-cloning the entire loaded list each page. + let combined = Arc::make_mut(&mut this.recent_commits); + for commit in more_commits { + if !existing_oids.contains(&commit.oid) { + combined.push(commit); + } + } + this.commit_offset = this.recent_commits.len(); + this.has_more_commits = has_more; + log::info!( + "refresh phase 2 applied in {:?}: {} commits total", + t.elapsed(), + this.recent_commits.len() + ); + cx.emit(GitProjectEvent::StatusChanged); + cx.notify(); + Ok(()) + }) + })? + } else { + Ok(()) + } + }) + } +} + +/// Load the next batch of commits after the already-loaded window, without +/// re-fetching branches/status/etc. Returns `(new_commits, has_more)`. +/// +/// Pagination is driven by a stable cursor (`after_oid`, the OID of the last +/// commit already shown) rather than a numeric `--skip`. A single +/// `git log --all --topo-order` window of `already_loaded + limit + 1` commits +/// is fetched, then everything up to and including the cursor is dropped and the +/// next `limit` commits are returned. Anchoring on the cursor OID means a +/// concurrent ref change between the initial load and this call cannot shift the +/// page boundary and skip (or duplicate) a commit the way `--skip` would; the +/// `-n` bound keeps `.output()` from buffering an unbounded log. +pub(super) fn load_more_commits_from_repo( + repo_path: &Path, + already_loaded: usize, + after_oid: Option, + limit: usize, + branch_tips: &[(git2::Oid, bool, String)], + tag_tips: &[(git2::Oid, String)], + author_filter: Option<&str>, +) -> Result<(Vec, bool)> { + // Build ref-label map from the caller-supplied tips. + let mut ref_map = std::collections::HashMap::>::new(); + let mut detached_head = None; + if let Ok(repo) = Repository::open(repo_path) { + if let Ok(head) = repo.head() { + if let Some(oid) = head.target() { + ref_map.entry(oid).or_default().push(RefLabel::Head); + if repo.head_detached().unwrap_or(false) { + detached_head = Some(oid); + } + } + } + } + for (oid, is_remote, name) in branch_tips { + let label = if *is_remote { + RefLabel::RemoteBranch(name.clone()) + } else { + RefLabel::LocalBranch(name.clone()) + }; + ref_map.entry(*oid).or_default().push(label); + } + for (oid, name) in tag_tips { + ref_map + .entry(*oid) + .or_default() + .push(RefLabel::Tag(name.clone())); + } + + let format = commit_log_format(); + // Fetch the already-loaded prefix plus this page plus one probe commit. The + // window is re-anchored on `after_oid` below, so the prefix only needs to be + // large enough to reach the cursor — it does not have to land exactly on it. + let window = already_loaded.saturating_add(limit).saturating_add(1); + + let mut cmd = git_command(); + cmd.current_dir(repo_path).args([ + "log", + "--branches", + "--remotes", + "--tags", + "--topo-order", + &format!("--format={}", format), + ]); + if let Some(author) = author_filter { + cmd.arg("--fixed-strings"); + cmd.arg(format!("--author={}", author)); + } + if let Some(head_oid) = detached_head { + cmd.arg(head_oid.to_string()); + } + cmd.arg(format!("-{}", window)); + let output = cmd.output().with_context(|| "Failed to run git log")?; + + if !output.status.success() { + anyhow::bail!( + "git log failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed = parse_git_log_records(&stdout, &mut ref_map); + + // Re-anchor on the stable cursor: drop everything up to and including the + // last already-loaded commit. If the cursor is no longer present (its ref + // was rewritten or deleted since the prior load), fall back to the numeric + // count so paging still makes forward progress. + let start = match after_oid { + Some(cursor) => match parsed.iter().position(|c| c.oid == cursor) { + Some(idx) => idx + 1, + None => already_loaded.min(parsed.len()), + }, + None => 0, + }; + + let mut remaining = parsed; + remaining.drain(..start); + let has_more = remaining.len() > limit; + remaining.truncate(limit); + + Ok((remaining, has_more)) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── parse_co_authors ─────────────────────────────────────────── + + #[test] + fn parse_single_co_author() { + let message = "Fix a bug\n\nCo-Authored-By: Alice Smith "; + let (cleaned, authors) = parse_co_authors(message); + assert_eq!(authors.len(), 1); + assert_eq!(authors[0].name, "Alice Smith"); + assert_eq!(authors[0].email, "alice@example.com"); + assert_eq!(cleaned, "Fix a bug"); + } + + #[test] + fn parse_multiple_co_authors() { + let message = "Refactor module\n\n\ + Co-Authored-By: Alice \n\ + Co-Authored-By: Bob Jones "; + let (cleaned, authors) = parse_co_authors(message); + assert_eq!(authors.len(), 2); + assert_eq!(authors[0].name, "Alice"); + assert_eq!(authors[1].name, "Bob Jones"); + assert_eq!(cleaned, "Refactor module"); + } + + #[test] + fn parse_duplicate_co_authors_once() { + let message = "Subject\n\nCo-Authored-By: Claude Fable \nco-authored-by: claude fable "; + let (_, authors) = parse_co_authors(message); + + assert_eq!(authors.len(), 1); + assert_eq!(authors[0].name, "Claude Fable"); + assert_eq!(authors[0].email, "noreply@anthropic.com"); + } + + #[test] + fn parse_co_author_case_insensitive() { + let message = "Fix\n\nco-authored-by: Alice "; + let (_, authors) = parse_co_authors(message); + assert_eq!(authors.len(), 1); + assert_eq!(authors[0].name, "Alice"); + } + + #[test] + fn parse_no_co_authors() { + let message = "Just a normal commit message\n\nWith a body."; + let (cleaned, authors) = parse_co_authors(message); + assert!(authors.is_empty()); + assert_eq!(cleaned, "Just a normal commit message\n\nWith a body."); + } + + #[test] + fn parse_co_author_missing_email() { + let message = "Fix\n\nCo-Authored-By: Alice"; + let (cleaned, authors) = parse_co_authors(message); + assert!(authors.is_empty()); + assert!(cleaned.contains("Co-Authored-By: Alice")); + } + + #[test] + fn parse_co_author_empty_name() { + let message = "Fix\n\nCo-Authored-By: "; + let (_, authors) = parse_co_authors(message); + assert!(authors.is_empty()); + } + + #[test] + fn parse_co_author_empty_email() { + let message = "Fix\n\nCo-Authored-By: Alice <>"; + let (_, authors) = parse_co_authors(message); + assert!(authors.is_empty()); + } + + #[test] + fn parse_co_author_empty_message() { + let (cleaned, authors) = parse_co_authors(""); + assert!(authors.is_empty()); + assert_eq!(cleaned, ""); + } + + #[test] + fn parse_co_author_preserves_body_lines() { + let message = "Title\n\nBody line 1\nBody line 2\n\nCo-Authored-By: A "; + let (cleaned, authors) = parse_co_authors(message); + assert_eq!(authors.len(), 1); + assert!(cleaned.contains("Body line 1")); + assert!(cleaned.contains("Body line 2")); + } +} + +// ── load_more_commits_from_repo ────────────────────────────────── + +#[cfg(test)] +mod reachable_tests { + use super::*; + use rgitui_test_support::TempRepo; + + /// The predicate `reachable_set` replaced, kept as the oracle it has to + /// agree with. A branch counted as merged when `merge_base(tip, target)` + /// was the tip itself. + fn merged_by_merge_base(repo: &Repository, tip: git2::Oid, target: git2::Oid) -> bool { + repo.merge_base(tip, target) + .map(|base| base == tip) + .unwrap_or(false) + } + + #[test] + fn a_tip_is_reachable_from_itself() { + // The old predicate answered true here — merge_base(x, x) == x — and a + // walk that excluded its own starting commit would report the checked + // out branch as unmerged from itself. + let repo = TempRepo::with_commits(3); + let head = repo.head_oid(); + assert!(reachable_set(repo.repo(), head).contains(&head)); + } + + #[test] + fn an_ancestor_is_reachable_and_a_descendant_is_not() { + let repo = TempRepo::with_commits(1); + let first = repo.head_oid(); + repo.commit_file("second.txt", "second", "second"); + let second = repo.head_oid(); + + let from_second = reachable_set(repo.repo(), second); + assert!(from_second.contains(&first), "ancestor must be reachable"); + + let from_first = reachable_set(repo.repo(), first); + assert!( + !from_first.contains(&second), + "a later commit is not reachable from an earlier one" + ); + } + + #[test] + fn set_membership_agrees_with_merge_base_across_a_divergent_history() { + // Two branches off a shared base, one of them merged back. This is the + // shape the flags exist to describe, and the case where a wrong answer + // would mislabel a sidebar row. + let repo = TempRepo::with_commits(2); + let base = repo.head_oid(); + repo.branch("merged-branch"); + repo.commit_file("on-main.txt", "main", "main work"); + let main_tip = repo.head_oid(); + + let merged_tip = repo + .repo() + .find_branch("merged-branch", git2::BranchType::Local) + .expect("branch should exist") + .get() + .target() + .expect("branch should have a tip"); + + let reachable = reachable_set(repo.repo(), main_tip); + for tip in [base, main_tip, merged_tip] { + assert_eq!( + reachable.contains(&tip), + merged_by_merge_base(repo.repo(), tip, main_tip), + "set membership disagreed with merge_base for {tip}" + ); + } + } + + #[test] + fn a_walk_from_every_commit_agrees_with_merge_base() { + // Exhaustive over a small history: whatever the old predicate said for + // each (tip, target) pair, the set has to say the same. + let repo = TempRepo::with_commits(6); + let mut walk = repo.repo().revwalk().unwrap(); + walk.push_head().unwrap(); + let commits: Vec = walk.filter_map(Result::ok).collect(); + + for &target in &commits { + let reachable = reachable_set(repo.repo(), target); + for &tip in &commits { + assert_eq!( + reachable.contains(&tip), + merged_by_merge_base(repo.repo(), tip, target), + "disagreement for tip {tip} against target {target}" + ); + } + } + } +} + +#[cfg(test)] +mod load_more_tests { + use super::*; + use rgitui_test_support::TempRepo; + + #[test] + fn load_more_returns_next_page() { + let repo = TempRepo::with_commits(5); + let path = repo.path(); + let branch_tips = vec![(repo.head_oid(), false, "main".to_string())]; + // Page 1: first 3 commits. + let (page1, more1) = + load_more_commits_from_repo(path, 0, None, 3, &branch_tips, &[], None).unwrap(); + assert_eq!(page1.len(), 3); + assert!(more1); + // Page 2: anchored on the oldest loaded commit, take up to 2. + let cursor = page1.last().map(|c| c.oid); + let (page2, more2) = + load_more_commits_from_repo(path, 3, cursor, 2, &branch_tips, &[], None).unwrap(); + // 5 commits total, 3 already loaded → 2 remaining, no more after that. + assert_eq!(page2.len(), 2); + assert!(!more2); + // The page boundary is contiguous with no overlap or gap. + let oids: Vec<_> = page1.iter().chain(page2.iter()).map(|c| c.oid).collect(); + let unique: std::collections::HashSet<_> = oids.iter().collect(); + assert_eq!(oids.len(), unique.len(), "no commit appears twice"); + assert_eq!(oids.len(), 5, "every commit is loaded exactly once"); + } + + #[test] + fn load_more_detects_has_more() { + let repo = TempRepo::with_commits(5); + let path = repo.path(); + let branch_tips = vec![(repo.head_oid(), false, "main".to_string())]; + // First page, limit 3 → should have more. + let (commits, has_more) = + load_more_commits_from_repo(path, 0, None, 3, &branch_tips, &[], None).unwrap(); + assert_eq!(commits.len(), 3); + assert!(has_more); + } + + #[test] + fn load_more_empty_past_end() { + let repo = TempRepo::with_commits(3); + let path = repo.path(); + let branch_tips = vec![(repo.head_oid(), false, "main".to_string())]; + // Cursor at the oldest commit: nothing remains beyond it. + let (all, _) = + load_more_commits_from_repo(path, 0, None, 3, &branch_tips, &[], None).unwrap(); + let cursor = all.last().map(|c| c.oid); + let (commits, has_more) = + load_more_commits_from_repo(path, 3, cursor, 5, &branch_tips, &[], None).unwrap(); + assert!(commits.is_empty()); + assert!(!has_more); + } + + #[test] + fn load_more_survives_ref_added_between_pages() { + // Regression for BUG-40: a commit created between page loads must not + // shift the boundary and skip a commit. The stable cursor re-anchors on + // the OID, so even though `already_loaded` is now stale the page is + // still contiguous. + let repo = TempRepo::with_commits(5); + let path = repo.path(); + let tip = repo.head_oid(); + let mut branch_tips = vec![(tip, false, "main".to_string())]; + + let (page1, _) = + load_more_commits_from_repo(path, 0, None, 3, &branch_tips, &[], None).unwrap(); + let cursor = page1.last().map(|c| c.oid); + + // A new commit lands on main between the two page loads. + branch_tips[0] = (repo.commit("commit 5"), false, "main".to_string()); + + let (page2, _) = + load_more_commits_from_repo(path, 3, cursor, 5, &branch_tips, &[], None).unwrap(); + + let loaded: std::collections::HashSet<_> = + page1.iter().chain(page2.iter()).map(|c| c.oid).collect(); + // The original 5 commits plus the cursor's successors are all present; + // crucially the commit immediately after the cursor is not skipped. + assert!(loaded.contains(&tip), "boundary commit must not be skipped"); + assert_eq!( + page1.len() + page2.len(), + loaded.len(), + "no commit is loaded twice" + ); + } + + #[test] + fn refresh_peels_annotated_tags_and_reads_remote_head() { + let repo = TempRepo::with_commits(1); + let tip = repo.head_oid(); + let git = repo.repo(); + let target = git.find_object(tip, None).unwrap(); + git.tag("v1", &target, &repo.signature(), "release", false) + .unwrap(); + git.reference("refs/remotes/origin/main", tip, true, "test") + .unwrap(); + git.reference_symbolic( + "refs/remotes/origin/HEAD", + "refs/remotes/origin/main", + true, + "test", + ) + .unwrap(); + drop(target); + + let data = gather_refresh_data_internal(repo.path(), false, 10, None, None).unwrap(); + assert_eq!(data.default_branch.as_deref(), Some("main")); + assert!(data + .tags + .iter() + .any(|tag| tag.name == "v1" && tag.oid == tip)); + let commit = data + .recent_commits + .iter() + .find(|commit| commit.oid == tip) + .unwrap(); + assert!(commit + .refs + .iter() + .any(|label| matches!(label, RefLabel::Tag(name) if name == "v1"))); + } + + #[test] + fn refresh_excludes_commits_reachable_only_from_stash_ref() { + let repo = TempRepo::with_commits(1); + let git = repo.repo(); + let tree_oid = git.index().unwrap().write_tree().unwrap(); + let stash_only = repo.commit_tree(None, "stash only", tree_oid, &[]); + git.reference("refs/stash", stash_only, true, "test") + .unwrap(); + + let data = gather_refresh_data_internal(repo.path(), false, 10, None, None).unwrap(); + assert!(!data + .recent_commits + .iter() + .any(|commit| commit.oid == stash_only)); + } + + #[test] + fn author_filter_treats_regex_characters_literally() { + let repo = TempRepo::with_commits(1); + let special = repo.commit_file_as( + "Special", + "person+tag@example.com", + "special.txt", + "special\n", + "special", + ); + + let data = gather_refresh_data_internal( + repo.path(), + false, + 10, + None, + Some("person+tag@example.com"), + ) + .unwrap(); + assert_eq!(data.recent_commits.len(), 1); + assert_eq!(data.recent_commits[0].oid, special); + } + + #[test] + fn commit_limit_is_clamped_to_safe_bounds() { + assert_eq!(normalize_commit_limit(0), 1); + assert_eq!(normalize_commit_limit(42), 42); + assert_eq!(normalize_commit_limit(usize::MAX), MAX_COMMIT_LIMIT); + } +} + +// ── is_merged_into_main via graph_descendant_of ────────────────── +// +// graph_descendant_of(a, b) in libgit2 means: +// "is b reachable from a by following parent pointers?" +// i.e. "is a an ancestor of b?" +// +// To check "branch is merged into main": +// We need: "is branch_tip an ancestor of main_tip?" +// I.e., can we reach main_tip by following parent pointers from branch_tip? +// Answer: graph_descendant_of(branch_tip, main_tip) + +#[cfg(test)] +mod is_merged_tests { + use super::*; + use rgitui_test_support::TempRepo; + + fn empty_tree_oid(repo: &Repository) -> git2::Oid { + repo.index().unwrap().write_tree().unwrap() + } + + /// Commit an empty tree onto an arbitrary ref, which is how these tests + /// draw a topology without caring about file contents. + fn commit( + fixture: &TempRepo, + refname: &str, + message: &str, + parent: Option, + ) -> git2::Oid { + let repo = fixture.repo(); + let tree_oid = empty_tree_oid(repo); + fixture.commit_tree( + Some(refname), + message, + tree_oid, + &parent.into_iter().collect::>(), + ) + } + + fn merge(fixture: &TempRepo, into_ref: &str, from_ref: &str, message: &str) -> git2::Oid { + let repo = fixture.repo(); + let tree_oid = empty_tree_oid(repo); + let main = repo + .revparse_single(into_ref) + .unwrap() + .peel_to_commit() + .unwrap() + .id(); + let branch = repo + .revparse_single(from_ref) + .unwrap() + .peel_to_commit() + .unwrap() + .id(); + fixture.commit_tree(Some(into_ref), message, tree_oid, &[main, branch]) + } + + /// Repo structure: + /// A (main, branch) + /// After: A --- M (main, merged) + /// \ + /// B (branch) + /// merge_base(branch_tip=B, main_tip=M) should equal B (B is ancestor of M). + #[test] + fn branch_merged_into_main_returns_true() { + let fixture = TempRepo::init(); + let repo = fixture.repo(); + + let a = commit(&fixture, "refs/heads/main", "A", None); + let b = commit(&fixture, "refs/heads/branch", "B", Some(a)); + let m = merge(&fixture, "refs/heads/main", "refs/heads/branch", "Merge"); + + // Verify B is a parent of M + let m_commit = repo.find_commit(m).unwrap(); + let m_parents: Vec<_> = m_commit.parent_ids().collect(); + assert!(m_parents.contains(&b), "B should be a parent of M"); + + // is_merged = merge_base(branch_tip, main_tip) == branch_tip + let mb = repo.merge_base(b, m).unwrap(); + assert_eq!(mb, b, "merge_base(branch=B, main=M) should equal B"); + } + + /// Repo structure: + /// A --- C (main, diverged) + /// \ + /// B (branch, never merged) + /// merge_base(branch_tip=B, main_tip=C) should NOT equal B. + #[test] + fn branch_not_merged_returns_false() { + let fixture = TempRepo::init(); + let repo = fixture.repo(); + + let a = commit(&fixture, "refs/heads/main", "A", None); + let b = commit(&fixture, "refs/heads/branch", "B", Some(a)); + let c = commit(&fixture, "refs/heads/main", "C", Some(a)); + + let mb = repo.merge_base(b, c).unwrap(); + assert_ne!( + mb, b, + "merge_base(branch=B, main=C) should NOT equal B for diverged branches" + ); + } + + /// Repo structure (fast-forward): + /// A (both main and branch) + /// Then branch advances: + /// A --- B (branch) + /// (main still at A) + /// After fast-forward of main to branch tip B: + /// A --- B (main, branch — same commit) + /// merge_base(branch_tip=B, main_tip=B) == B (same commit). + #[test] + fn fast_forward_merged_returns_true() { + let fixture = TempRepo::init(); + let repo = fixture.repo(); + + let a = commit(&fixture, "refs/heads/main", "A", None); + // Create branch at A (same as main) + repo.branch("branch", &repo.find_commit(a).unwrap(), false) + .unwrap(); + // Advance branch to B + let b = commit(&fixture, "refs/heads/branch", "B", Some(a)); + + // Fast-forward: move main ref to branch tip + let mut main_ref = repo.find_reference("refs/heads/main").unwrap(); + main_ref + .set_target(b, "fast-forward main to branch") + .unwrap(); + + let main_tip = repo + .revparse_single("refs/heads/main") + .unwrap() + .peel_to_commit() + .unwrap() + .id(); + // Both point to B now + assert_eq!(main_tip, b, "main should now point to B"); + + // is_merged = merge_base(branch_tip, main_tip) == branch_tip + // When both point to the same commit, merge_base equals that commit + let mb = repo.merge_base(b, main_tip).unwrap(); + assert_eq!(mb, b, "merge_base of same commit should equal that commit"); + } + + /// Repo structure: + /// A --- B --- C (main advanced after branch split) + /// \ + /// D (branch, still at D) + /// This is the key bug scenario: main advanced past the branch commit, + /// but the branch was never merged into main. + /// merge_base(branch_tip=D, main_tip=C) should NOT equal D. + #[test] + fn main_advanced_after_branch_returns_false() { + let fixture = TempRepo::init(); + let repo = fixture.repo(); + + let a = commit(&fixture, "refs/heads/main", "A", None); + let d = commit(&fixture, "refs/heads/branch", "D", Some(a)); + let _b = commit(&fixture, "refs/heads/main", "B", Some(a)); + let c = commit( + &fixture, + "refs/heads/main", + "C", + Some( + repo.revparse_single("refs/heads/main") + .unwrap() + .peel_to_commit() + .unwrap() + .id(), + ), + ); + + // branch still at D, main at C. D is NOT an ancestor of C. + let mb = repo.merge_base(d, c).unwrap(); + assert_ne!( + mb, d, + "merge_base(branch=D, main=C) should NOT equal D when main advanced independently" + ); + } + + #[test] + fn merged_status_is_computed_against_current_branch() { + let fixture = TempRepo::init(); + let repo = fixture.repo(); + + let a = commit(&fixture, "refs/heads/main", "A", None); + let feature_tip = commit(&fixture, "refs/heads/feature", "B", Some(a)); + let diverged_tip = commit(&fixture, "refs/heads/diverged", "D", Some(a)); + repo.set_head("refs/heads/feature").unwrap(); + + let branches = vec![ + ("main".to_string(), false, false, Some(a)), + ("feature".to_string(), false, true, Some(feature_tip)), + ("diverged".to_string(), false, false, Some(diverged_tip)), + ]; + let flags = super::super::merged_flags(repo, &branches); + let flag = |wanted: &str| { + flags + .iter() + .find(|(name, _, _, _)| name == wanted) + .map(|(_, _, _, into_head)| *into_head) + .unwrap() + }; + + assert_eq!( + flag("main"), + Some(true), + "main is an ancestor of the checked-out feature" + ); + assert_eq!( + flag("diverged"), + Some(false), + "a branch that diverged before HEAD is not merged into it" + ); + assert_eq!( + flag("feature"), + Some(true), + "the checked-out branch is trivially merged into itself" + ); + } + + #[test] + fn the_gather_leaves_merged_flags_for_the_deferred_pass() { + // The flags are what made opening a repository slow, so the snapshot + // must not compute them. `None` is the signal that they are unknown + // rather than false, which is what stops `apply_refresh_data` blanking + // a previously computed value. + let fixture = TempRepo::init(); + let a = commit(&fixture, "refs/heads/main", "A", None); + let _feature = commit(&fixture, "refs/heads/feature", "B", Some(a)); + + let data = gather_refresh_data_internal(fixture.path(), false, 100, None, None).unwrap(); + assert!( + data.branches + .iter() + .all(|branch| branch.is_merged_into_main.is_none() + && branch.is_merged_into_head.is_none()), + "the snapshot must not walk history for these" + ); + } + + #[test] + fn a_repository_with_no_trunk_reports_unknown_rather_than_unmerged() { + // The trunk here is `develop`, which is not one of the names + // `merged_flags` looks for. There is no answer to "is this merged into + // main", and saying `false` would have the branch health panel offer + // every branch up for deletion as unmerged. + let fixture = TempRepo::init(); + let repo = fixture.repo(); + let a = commit(&fixture, "refs/heads/develop", "A", None); + let feature_tip = commit(&fixture, "refs/heads/feature", "B", Some(a)); + repo.set_head("refs/heads/develop").unwrap(); + + let branches = vec![ + ("develop".to_string(), false, true, Some(a)), + ("feature".to_string(), false, false, Some(feature_tip)), + ]; + let flags = super::super::merged_flags(repo, &branches); + + for (name, _, into_main, _) in &flags { + assert_eq!( + *into_main, None, + "{name} has no trunk to be measured against, so the flag is unknown" + ); + } + } + + #[test] + fn an_unreachable_branch_is_reported_as_unmerged_not_unknown() { + // The counterpart to the test above: here the walk *did* run and the + // tip genuinely is not in it. That is an answer, and it must not be + // flattened into the same `None` as "could not ask". + let fixture = TempRepo::init(); + let repo = fixture.repo(); + let a = commit(&fixture, "refs/heads/main", "A", None); + let orphan = commit(&fixture, "refs/heads/orphan", "unrelated", None); + repo.set_head("refs/heads/main").unwrap(); + + let branches = vec![ + ("main".to_string(), false, true, Some(a)), + ("orphan".to_string(), false, false, Some(orphan)), + ]; + let flags = super::super::merged_flags(repo, &branches); + let into_main = |wanted: &str| { + flags + .iter() + .find(|(name, _, _, _)| name == wanted) + .map(|(_, _, into_main, _)| *into_main) + .unwrap() + }; + + assert_eq!(into_main("main"), Some(true)); + assert_eq!( + into_main("orphan"), + Some(false), + "a root commit with no path to main is answerably not merged" + ); + } +} + +// ── gather_worktrees ───────────────────────────────────────────── + +#[cfg(test)] +mod worktree_tests { + use super::*; + use rgitui_test_support::TempRepo; + + /// Add a linked worktree checked out on a new branch, returning its path. + fn add_worktree(fixture: &TempRepo, name: &str) -> PathBuf { + let repo = fixture.repo(); + let head = repo.head().unwrap().peel_to_commit().unwrap(); + repo.branch(name, &head, false).unwrap(); + let path = fixture.path().join(name); + let reference = repo + .find_reference(&format!("refs/heads/{}", name)) + .unwrap(); + let mut opts = git2::WorktreeAddOptions::new(); + opts.reference(Some(&reference)); + repo.worktree(name, &path, Some(&opts)).unwrap(); + path + } + + fn find<'a>(worktrees: &'a [WorktreeInfo], name: &str) -> &'a WorktreeInfo { + worktrees + .iter() + .find(|worktree| worktree.name == name) + .unwrap_or_else(|| panic!("no worktree named {name}")) + } + + #[test] + fn main_repo_lists_itself_and_its_linked_worktrees() { + let fixture = TempRepo::with_commits(1); + add_worktree(&fixture, "feature"); + + let worktrees = gather_worktrees(fixture.repo()); + + assert_eq!(worktrees.len(), 2, "{worktrees:?}"); + assert!(worktrees[0].is_current, "the current worktree sorts first"); + assert_eq!( + find(&worktrees, "feature").branch.as_deref(), + Some("feature") + ); + assert_eq!( + worktrees.iter().filter(|w| w.is_current).count(), + 1, + "exactly one worktree is current" + ); + } + + /// Opening rgitui on a linked worktree used to list that worktree twice — + /// once as `repo.workdir()` and again from the registry — while the main + /// checkout had no row at all. + #[test] + fn opening_a_linked_worktree_lists_each_checkout_once() { + let fixture = TempRepo::with_commits(1); + let worktree_path = add_worktree(&fixture, "feature"); + let linked = Repository::open(&worktree_path).unwrap(); + + let worktrees = gather_worktrees(&linked); + + assert_eq!(worktrees.len(), 2, "{worktrees:?}"); + let identities: HashSet = worktrees + .iter() + .map(|worktree| worktree_identity(&worktree.path)) + .collect(); + assert_eq!(identities.len(), 2, "no checkout is listed twice"); + assert!( + identities.contains(&worktree_identity(fixture.path())), + "the main checkout is present" + ); + assert!(find(&worktrees, "feature").is_current); + } + + /// `shorthand()` reports a short OID on a detached HEAD, which must not + /// reach the UI as if it were a branch name. + #[test] + fn detached_head_reports_no_branch() { + let fixture = TempRepo::with_commits(2); + fixture + .repo() + .set_head_detached(fixture.head_oid()) + .unwrap(); + + let worktrees = gather_worktrees(fixture.repo()); + + let current = worktrees.iter().find(|w| w.is_current).unwrap(); + assert!(current.head_detached); + assert_eq!(current.branch, None); + assert_eq!(current.head_oid, Some(fixture.head_oid())); + } +} + +// ── previous_branch ────────────────────────────────────────────── + +#[cfg(test)] +mod previous_branch_tests { + use super::*; + use rgitui_test_support::TempRepo; + + fn switch_to(fixture: &TempRepo, branch: &str) { + fixture + .repo() + .set_head(&format!("refs/heads/{branch}")) + .unwrap(); + } + + #[test] + fn checkout_source_reads_the_branch_or_commit_moved_away_from() { + assert_eq!( + checkout_source("checkout: moving from main to feature"), + Some("main") + ); + assert_eq!( + checkout_source("checkout: moving from 1a2b3c4d to main"), + Some("1a2b3c4d") + ); + assert_eq!( + checkout_source("checkout: moving from feature/to-do to main"), + Some("feature/to-do") + ); + assert_eq!(checkout_source("commit: add a file"), None); + assert_eq!(checkout_source("checkout: moving from to main"), None); + } + + #[test] + fn detaching_remembers_the_branch_it_left() { + let fixture = TempRepo::with_commits(2); + fixture + .repo() + .set_head_detached(fixture.head_oid()) + .unwrap(); + + assert_eq!( + previous_branch(fixture.repo(), None).as_deref(), + Some(TempRepo::DEFAULT_BRANCH) + ); + } + + #[test] + fn stepping_between_detached_commits_still_finds_the_branch() { + let fixture = TempRepo::with_commits(3); + let repo = fixture.repo(); + let head = fixture.head_oid(); + let parent = repo.find_commit(head).unwrap().parent_id(0).unwrap(); + repo.set_head_detached(head).unwrap(); + repo.set_head_detached(parent).unwrap(); + + assert_eq!( + previous_branch(repo, None).as_deref(), + Some(TempRepo::DEFAULT_BRANCH) + ); + } + + #[test] + fn on_a_branch_it_is_the_one_switched_from() { + let fixture = TempRepo::with_commits(1); + fixture.branch("feature"); + switch_to(&fixture, "feature"); + + assert_eq!( + previous_branch(fixture.repo(), Some("feature")).as_deref(), + Some(TempRepo::DEFAULT_BRANCH) + ); + } + + #[test] + fn the_current_branch_is_never_the_previous_one() { + let fixture = TempRepo::with_commits(1); + fixture.branch("feature"); + switch_to(&fixture, "feature"); + switch_to(&fixture, TempRepo::DEFAULT_BRANCH); + switch_to(&fixture, "feature"); + + assert_eq!( + previous_branch(fixture.repo(), Some("feature")).as_deref(), + Some(TempRepo::DEFAULT_BRANCH) + ); + } + + #[test] + fn a_deleted_branch_is_passed_over() { + let fixture = TempRepo::with_commits(1); + fixture.branch("feature"); + fixture.branch("topic"); + switch_to(&fixture, "topic"); + switch_to(&fixture, "feature"); + fixture + .repo() + .set_head_detached(fixture.head_oid()) + .unwrap(); + fixture + .repo() + .find_branch("feature", git2::BranchType::Local) + .unwrap() + .delete() + .unwrap(); + + assert_eq!( + previous_branch(fixture.repo(), None).as_deref(), + Some("topic") + ); + } + + #[test] + fn a_repository_never_switched_has_no_previous_branch() { + let fixture = TempRepo::with_commits(2); + assert_eq!( + previous_branch(fixture.repo(), Some(TempRepo::DEFAULT_BRANCH)), + None + ); + } + + /// `head_branch` used to be `shorthand()`, which is the literal `HEAD` on a + /// detached HEAD — so the title bar named a branch called HEAD and Rename + /// Branch offered to rename it. + #[test] + fn a_detached_refresh_reports_no_branch_and_where_to_return() { + let fixture = TempRepo::with_commits(2); + fixture + .repo() + .set_head_detached(fixture.head_oid()) + .unwrap(); + + let data = gather_refresh_data(fixture.path(), 50).unwrap(); + + assert!(data.head_detached); + assert_eq!(data.head_branch, None); + assert_eq!( + data.previous_branch.as_deref(), + Some(TempRepo::DEFAULT_BRANCH) + ); + } +} diff --git a/third_party/rgitui/crates/rgitui_git/src/project/search.rs b/third_party/rgitui/crates/rgitui_git/src/project/search.rs new file mode 100644 index 000000000..d839dccde --- /dev/null +++ b/third_party/rgitui/crates/rgitui_git/src/project/search.rs @@ -0,0 +1,443 @@ +//! Git grep — global content search. + +use anyhow::{Context as _, Result}; +use gpui::{AsyncApp, Context, Task, WeakEntity}; +use std::path::{Path, PathBuf}; + +use super::GitProject; +use crate::types::SearchResult; + +impl GitProject { + /// Run `git grep` synchronously. + pub fn git_grep(&self, pattern: &str) -> Result> { + git_grep(self.repo_path(), pattern) + } + + /// Run `git grep` asynchronously. + pub fn git_grep_async( + &self, + pattern: String, + cx: &mut Context, + ) -> Task>> { + let repo_path = self.repo_path().to_path_buf(); + cx.spawn(async move |_this: WeakEntity, cx: &mut AsyncApp| { + cx.background_executor() + .spawn(async move { git_grep(&repo_path, &pattern) }) + .await + }) + } +} + +/// Run `git grep` in the given repository. +pub fn git_grep(repo_path: &Path, pattern: &str) -> Result> { + let output = super::git_command() + .current_dir(repo_path) + // `-e` keeps a pattern starting with `-` from being parsed as an option + // (searching for "-v" or "-f/etc/passwd" would otherwise change what the + // command does); the trailing `--` closes the empty pathspec list. + .args(["grep", "-n", "-e", pattern, "--"]) + .output() + .context("Failed to execute git grep")?; + + if !output.status.success() { + // Exit code 1 means "no matches" — not an error, return empty. + if output.status.code() == Some(1) { + return Ok(Vec::new()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git grep failed: {}", stderr.trim()); + } + + let raw = String::from_utf8_lossy(&output.stdout); + let results = parse_grep_output(&raw); + Ok(results) +} + +/// Parse git grep output in the format "path:lineno:content". +/// +/// Path may contain colons (e.g. Windows paths like "C:/foo:bar/file.rs"), and +/// content may also contain colons (e.g. URLs). We scan colons from right to left: +/// - If the segment after a colon starts with a digit, it's the content start. +/// Skip it and keep looking. +/// - If the segment after a colon starts with a NON-digit character, that's the +/// content start. Then find the previous purely-digit segment before it to +/// get the line number. +/// +/// This correctly handles: +/// - Standard: "src/main.rs:10:fn main() {" → path="src/main.rs", lineno=10, content="fn main() {" +/// - URLs: "file.rs:7:visit https://example.com" → path="file.rs", lineno=7 +/// - Colons in path: "C:/foo:bar/file.rs:12:let x" → path="C:/foo:bar/file.rs", lineno=12 +fn parse_grep_output(raw: &str) -> Vec { + let mut results = Vec::new(); + for line in raw.lines() { + let bytes = line.as_bytes(); + + // Find all colon byte positions. + let colon_positions: Vec = (0..bytes.len()).filter(|&i| bytes[i] == b':').collect(); + + if colon_positions.len() < 2 { + // Malformed: need at least 2 colons (path:lineno:content). + continue; + } + + // Find the content start (first colon from the right where content doesn't + // start with a purely-digit segment — this rules out URL ports, etc. in content). + let content_colon_idx = 'search: { + for &colon_pos in colon_positions.iter().rev() { + let after = &line[colon_pos + 1..]; + let after_first = after.chars().next(); + + if after.is_empty() { + // Empty after colon — this could be the lineno/content separator. + // But we still want content to be non-digit. Break only if non-digit. + break 'search colon_pos; + } + + let after_starts_with_digit = + after_first.map(|c| c.is_ascii_digit()).unwrap_or(false); + + // Also detect URL patterns like "https:/" or "server.com:8080/". + // If content starts with '/' and the segment before this colon is purely + // digits, the '/' is a URL path separator — skip and keep looking left. + let after_starts_with_slash = after_first == Some('/'); + // URL pattern detection: content starts with '/'. + // Skip this colon (keep looking left) only if it's a URL component: + // - "https:/" pattern: prev char is '/' and char-2-back is letter (scheme) + // - "8080:/" pattern: prev char is digit (port number) + // In all other cases (including '/'-prefixed content that's not a URL), + // treat '/' as the content start and break. + let is_url_pattern = after_starts_with_slash + && colon_pos >= 2 + && (bytes.get(colon_pos - 1) == Some(&b'/') + || bytes + .get(colon_pos - 1) + .map(u8::is_ascii_digit) + .unwrap_or(false) + || bytes + .get(colon_pos - 2) + .map(u8::is_ascii_alphabetic) + .unwrap_or(false)); + + if is_url_pattern { + // This colon is a URL component (e.g. "https:/" or ":8080/"). + // The real content colon is to the left. + continue; + } + + if !after_starts_with_digit { + // Non-digit after: check if the segment between the previous colon + // and this colon is purely digits. + if let Some(idx) = colon_positions.iter().position(|&p| p == colon_pos) { + if idx > 0 { + let prev_colon = colon_positions[idx - 1]; + let between = &line[prev_colon + 1..colon_pos]; + if between.chars().all(|c| c.is_ascii_digit()) { + // between is purely digits → this IS the lineno/content separator. + break 'search colon_pos; + } + // between has non-digits: not the lineno/content separator. + // Keep looking for a better colon to the left. + continue; + } + // idx == 0: first colon → this is the path/lineno boundary. + break 'search colon_pos; + } + // colon_positions position lookup failed — shouldn't happen. + continue; + } + + // Content starts with a digit → might be part of lineno, keep looking left. + if let Some(idx) = colon_positions.iter().position(|&p| p == colon_pos) { + if idx > 0 { + let prev_colon = colon_positions[idx - 1]; + let between = &line[prev_colon + 1..colon_pos]; + if between.chars().all(|c| c.is_ascii_digit()) { + // Previous segment is purely digits → this is part of the path + // or lineno, not the content. Skip and keep looking left. + continue; + } + } + } + // Content starts with digit but previous segment not purely digits → + // this IS the lineno/content boundary. + break 'search colon_pos; + } + unreachable!("colon_positions.len() >= 2: for loop always breaks via 'break search'") + }; + + // Fallback for malformed input: if rank==0 (no valid separator found), + // fall back to the old simple split: first colon=path, second colon=lineno/content. + if colon_positions.iter().position(|&p| p == content_colon_idx) == Some(0) { + // No valid content separator found. Fall back to simple split. + let Some((file_part, rest)) = line.split_once(':') else { + continue; + }; + let path = PathBuf::from(file_part); + let Some((line_str, content)) = rest.split_once(':') else { + // Malformed in fallback too — skip. + continue; + }; + let line_number = line_str.parse::().unwrap_or(1); + results.push(SearchResult { + path, + line_number, + content: content.to_string(), + }); + continue; + } + + // content_start is the character after content_colon_idx. + let content_start = content_colon_idx + 1; + + let content_colon_rank = colon_positions + .iter() + .position(|&p| p == content_colon_idx) + .expect("content_colon_idx is from colon_positions"); + + if content_colon_rank == 0 { + // No colon to the left of content colon — malformed, skip. + continue; + } + + // Lineno is always the segment directly before the content colon. + // In git grep output "path:lineno:content", this is always the lineno. + // We don't need to verify it's digits — git always emits digits here. + let lineno_seg_start = colon_positions[content_colon_rank - 1] + 1; + let lineno_seg_end = content_colon_idx; + + // Path ends at the colon before the lineno digits. + let path_end = colon_positions[content_colon_rank - 1]; + + let lineno_str = &line[lineno_seg_start..lineno_seg_end]; + let path = &line[..path_end]; + let content = &line[content_start..]; + let line_number = lineno_str.parse::().unwrap_or(1); + + results.push(SearchResult { + path: PathBuf::from(path), + line_number, + content: content.to_string(), + }); + } + results +} + +#[cfg(test)] +mod tests { + use super::*; + use rgitui_test_support::TempRepo; + + // Unit tests for parse_grep_output — no git repo required. + #[test] + fn parse_grep_output_normal() { + let raw = "src/main.rs:10:fn main() {"; + let results = parse_grep_output(raw); + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, std::path::PathBuf::from("src/main.rs")); + assert_eq!(results[0].line_number, 10); + assert_eq!(results[0].content, "fn main() {"); + } + + #[test] + fn parse_grep_output_path_with_single_colon() { + // Filename contains a colon (valid on most filesystems). + let raw = "src/foo:bar.rs:5:let x = 1;"; + let results = parse_grep_output(raw); + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, std::path::PathBuf::from("src/foo:bar.rs")); + assert_eq!(results[0].line_number, 5); + assert_eq!(results[0].content, "let x = 1;"); + } + + #[test] + fn parse_grep_output_path_with_multiple_colons() { + // Multiple path segments containing colons. + let raw = "a:b:c:42:hello world"; + let results = parse_grep_output(raw); + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, std::path::PathBuf::from("a:b:c")); + assert_eq!(results[0].line_number, 42); + assert_eq!(results[0].content, "hello world"); + } + + #[test] + fn parse_grep_output_content_with_colon() { + // Content contains URLs with colons (e.g. https://). + let raw = "file.rs:7:visit https://example.com for more"; + let results = parse_grep_output(raw); + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, std::path::PathBuf::from("file.rs")); + assert_eq!(results[0].line_number, 7); + assert_eq!(results[0].content, "visit https://example.com for more"); + } + + #[test] + fn parse_grep_output_multiple_lines() { + let raw = "a.rs:1:first\nb.rs:2:second\nc.rs:3:third"; + let results = parse_grep_output(raw); + assert_eq!(results.len(), 3); + assert_eq!(results[0].path, std::path::PathBuf::from("a.rs")); + assert_eq!(results[0].line_number, 1); + assert_eq!(results[0].content, "first"); + assert_eq!(results[1].path, std::path::PathBuf::from("b.rs")); + assert_eq!(results[1].line_number, 2); + assert_eq!(results[1].content, "second"); + assert_eq!(results[2].path, std::path::PathBuf::from("c.rs")); + assert_eq!(results[2].line_number, 3); + assert_eq!(results[2].content, "third"); + } + + #[test] + fn parse_grep_output_empty_content() { + let raw = "main.rs:5:"; + let results = parse_grep_output(raw); + assert_eq!(results.len(), 1); + assert_eq!(results[0].line_number, 5); + assert_eq!(results[0].content, ""); + } + + #[test] + fn parse_grep_output_non_numeric_lineno_falls_back_to_one() { + // "abc" is not a valid line number → fallback to 1. + let raw = "main.rs:abc:content"; + let results = parse_grep_output(raw); + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, std::path::PathBuf::from("main.rs")); + assert_eq!(results[0].line_number, 1); + assert_eq!(results[0].content, "content"); + } + + #[test] + fn parse_grep_output_no_colon_skipped() { + // Line with fewer than 2 colons — malformed, skip. + let raw = "main.rs no colon here"; + let results = parse_grep_output(raw); + assert!(results.is_empty()); + } + + #[test] + fn parse_grep_output_only_path_and_lineno() { + // Path:lineno with empty content. + let raw = "main.rs:5:"; + let results = parse_grep_output(raw); + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, std::path::PathBuf::from("main.rs")); + assert_eq!(results[0].line_number, 5); + assert_eq!(results[0].content, ""); + } + + #[test] + fn parse_grep_output_windows_style_path() { + // Windows path with drive letter and colon in a path segment. + let raw = "C:/Users/foo:bar/src/main.rs:12:let x = 1;"; + let results = parse_grep_output(raw); + assert_eq!(results.len(), 1); + assert_eq!( + results[0].path, + std::path::PathBuf::from("C:/Users/foo:bar/src/main.rs") + ); + assert_eq!(results[0].line_number, 12); + assert_eq!(results[0].content, "let x = 1;"); + } + + #[test] + fn parse_grep_output_content_with_multiple_colons() { + // Multiple colons in content (struct literals, JSON, etc.). + let raw = "config.rs:3:let cfg = Config { port: 8080, host: \"localhost\" };"; + let results = parse_grep_output(raw); + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, std::path::PathBuf::from("config.rs")); + assert_eq!(results[0].line_number, 3); + assert_eq!( + results[0].content, + "let cfg = Config { port: 8080, host: \"localhost\" };" + ); + } + + #[test] + fn parse_grep_output_malformed_fallback_simple_split() { + // When every segment before the last looks non-numeric (no purely-digit + // segment found), the content colon lands at position 0 in the main + // loop. The fallback does a simple split: first ':' = path, second ':' + // = lineno/content. For "foo:bar:baz": fallback splits "foo"/"bar:baz", + // then "bar"/"baz" → path="foo", line=1 (non-numeric), content="baz". + let raw = "foo:bar:baz"; + let results = parse_grep_output(raw); + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, std::path::PathBuf::from("foo")); + assert_eq!(results[0].line_number, 1); // "bar" is not numeric → falls back to 1 + assert_eq!(results[0].content, "baz"); // second split gives "bar"/"baz" + } + + #[test] + fn parse_grep_output_ftp_url_in_content() { + // FTP URLs also have colons in the scheme. + let raw = "readme.txt:1:Download from ftp://server.com/file"; + let results = parse_grep_output(raw); + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, std::path::PathBuf::from("readme.txt")); + assert_eq!(results[0].line_number, 1); + assert_eq!(results[0].content, "Download from ftp://server.com/file"); + } + + fn make_test_repo() -> TempRepo { + let repo = TempRepo::init(); + repo.write_file("hello.rs", "fn main() {\n println!(\"hello\");\n}\n"); + repo.write_file("other.txt", "nothing here\n"); + repo.stage("hello.rs"); + repo.stage("other.txt"); + repo.commit("Initial commit\n"); + repo + } + + #[test] + fn git_grep_finds_match() { + let repo = make_test_repo(); + let results = git_grep(repo.path(), "println").unwrap(); + assert_eq!(results.len(), 1, "expected 1 match, got {:?}", results); + assert_eq!( + results[0].line_number, 2, + "expected line 2, got {}", + results[0].line_number + ); + assert!( + results[0].content.contains("println"), + "expected 'println' in content, got: {}", + results[0].content + ); + } + + #[test] + fn git_grep_multiple_matches() { + let repo = make_test_repo(); + let results = git_grep(repo.path(), "fn").unwrap(); + assert_eq!(results.len(), 1, "expected 1 match, got {:?}", results); + } + + #[test] + fn git_grep_no_matches() { + let repo = make_test_repo(); + let results = git_grep(repo.path(), "xyzzy").unwrap(); + assert_eq!(results.len(), 0); + } + + /// A pattern beginning with `-` must be searched for, not parsed as an + /// option. Without `-e` these turned into git flags and failed the search. + #[test] + fn git_grep_treats_leading_dash_as_a_pattern() { + let repo = TempRepo::init(); + repo.commit_file("flags.txt", "run with -v for verbose\n", "initial\n"); + + let results = git_grep(repo.path(), "-v").unwrap(); + assert_eq!(results.len(), 1, "expected 1 match, got {:?}", results); + assert!(results[0].content.contains("-v")); + + // An option-shaped pattern with no match is simply "no results", not an + // error from git rejecting an unknown flag. + assert_eq!( + git_grep(repo.path(), "--upload-pack=echo").unwrap().len(), + 0 + ); + assert_eq!(git_grep(repo.path(), "-f/etc/passwd").unwrap().len(), 0); + } +} diff --git a/third_party/rgitui/crates/rgitui_git/src/project/submodule.rs b/third_party/rgitui/crates/rgitui_git/src/project/submodule.rs new file mode 100644 index 000000000..7bef67e19 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_git/src/project/submodule.rs @@ -0,0 +1,295 @@ +use anyhow::{Context as _, Result}; +use git2::{Repository, SubmoduleUpdateOptions}; +use std::path::PathBuf; + +/// Information about a submodule in the repository. +#[derive(Debug, Clone)] +pub struct SubmoduleInfo { + /// Name of the submodule (from .gitmodules) + pub name: String, + /// Path relative to repository root + pub path: PathBuf, + /// URL of the submodule + pub url: String, + /// Branch configured for submodule (if any) + pub branch: Option, + /// OID in HEAD tree (what the superproject expects) + pub head_oid: Option, + /// OID in index (staged version) + pub index_oid: Option, + /// OID in working directory (currently checked out) + pub workdir_oid: Option, + /// Whether the submodule is initialized + pub is_initialized: bool, +} + +impl SubmoduleInfo { + /// Returns a human-readable status string for this submodule. + pub fn status(&self) -> &'static str { + if !self.is_initialized { + return "not initialized"; + } + match (&self.head_oid, &self.workdir_oid) { + (Some(head), Some(workdir)) if head == workdir => "up to date", + (Some(_), Some(_)) => "modified", + (Some(_), None) => "not checked out", + (None, _) => "no commit", + } + } +} + +/// Compute the status of all submodules in the repository. +pub fn compute_submodules(repo: &Repository) -> Result> { + let submodules = repo + .submodules() + .context("Failed to enumerate submodules")?; + + let mut result = Vec::new(); + for submodule in submodules { + let name = submodule.name().unwrap_or("unknown").to_string(); + let path = submodule.path().to_path_buf(); + let url = submodule.url().unwrap_or("").to_string(); + let branch = submodule.branch().map(str::to_string); + + let head_oid = submodule.head_id().map(|oid| oid.to_string()); + let index_oid = submodule.index_id().map(|oid| oid.to_string()); + let workdir_oid = submodule.workdir_id().map(|oid| oid.to_string()); + + // A submodule is initialized if its repository exists + let is_initialized = submodule.open().is_ok(); + + result.push(SubmoduleInfo { + name, + path, + url, + branch, + head_oid, + index_oid, + workdir_oid, + is_initialized, + }); + } + + Ok(result) +} + +/// Initialize a submodule by name. +/// This copies submodule info from .gitmodules to .git/config. +pub fn submodule_init(repo: &Repository, name: &str) -> Result<()> { + let submodules = repo + .submodules() + .context("Failed to enumerate submodules")?; + + for mut submodule in submodules { + if submodule.name() == Some(name) { + submodule + .init(false) + .context("Failed to initialize submodule")?; + return Ok(()); + } + } + + anyhow::bail!("Submodule '{}' not found", name) +} + +/// Update a submodule by name. +/// This fetches and checks out the submodule to the expected commit. +pub fn submodule_update(repo: &Repository, name: &str, init: bool) -> Result<()> { + let submodules = repo + .submodules() + .context("Failed to enumerate submodules")?; + + for mut submodule in submodules { + if submodule.name() == Some(name) { + let mut opts = SubmoduleUpdateOptions::new(); + submodule + .update(init, Some(&mut opts)) + .context("Failed to update submodule")?; + return Ok(()); + } + } + + anyhow::bail!("Submodule '{}' not found", name) +} + +/// Initialize all submodules. +pub fn submodule_init_all(repo: &Repository) -> Result> { + let submodules = repo + .submodules() + .context("Failed to enumerate submodules")?; + + let mut initialized = Vec::new(); + for mut submodule in submodules { + let name = submodule.name().unwrap_or("unknown").to_string(); + submodule + .init(false) + .context(format!("Failed to initialize submodule '{}'", name))?; + initialized.push(name); + } + + Ok(initialized) +} + +/// Update all submodules. +pub fn submodule_update_all(repo: &Repository, init: bool) -> Result> { + let submodules = repo + .submodules() + .context("Failed to enumerate submodules")?; + + let mut updated = Vec::new(); + for mut submodule in submodules { + let name = submodule.name().unwrap_or("unknown").to_string(); + let mut opts = SubmoduleUpdateOptions::new(); + submodule + .update(init, Some(&mut opts)) + .context(format!("Failed to update submodule '{}'", name))?; + updated.push(name); + } + + Ok(updated) +} + +use gpui::{AsyncApp, Context, Task, WeakEntity}; + +use super::GitProject; + +impl GitProject { + /// Get submodule status synchronously. + pub fn submodules(&self) -> Result> { + let repo = self.open_repo()?; + compute_submodules(&repo) + } + + /// Get submodule status asynchronously. + pub fn submodules_async(&self, cx: &mut Context) -> Task>> { + let repo_path = self.repo_path().to_path_buf(); + cx.spawn(async move |_this: WeakEntity, cx: &mut AsyncApp| { + cx.background_executor() + .spawn(async move { + let repo = Repository::open(&repo_path).context("Failed to open repository")?; + compute_submodules(&repo) + }) + .await + }) + } + + /// Initialize a submodule by name (async). + pub fn submodule_init_async(&self, name: String, cx: &mut Context) -> Task> { + let repo_path = self.repo_path().to_path_buf(); + cx.spawn(async move |_this: WeakEntity, cx: &mut AsyncApp| { + cx.background_executor() + .spawn(async move { + let repo = Repository::open(&repo_path).context("Failed to open repository")?; + submodule_init(&repo, &name) + }) + .await + }) + } + + /// Update a submodule by name (async). + pub fn submodule_update_async( + &self, + name: String, + init: bool, + cx: &mut Context, + ) -> Task> { + let repo_path = self.repo_path().to_path_buf(); + cx.spawn(async move |_this: WeakEntity, cx: &mut AsyncApp| { + cx.background_executor() + .spawn(async move { + let repo = Repository::open(&repo_path).context("Failed to open repository")?; + submodule_update(&repo, &name, init) + }) + .await + }) + } + + /// Initialize all submodules (async). + pub fn submodule_init_all_async(&self, cx: &mut Context) -> Task>> { + let repo_path = self.repo_path().to_path_buf(); + cx.spawn(async move |_this: WeakEntity, cx: &mut AsyncApp| { + cx.background_executor() + .spawn(async move { + let repo = Repository::open(&repo_path).context("Failed to open repository")?; + submodule_init_all(&repo) + }) + .await + }) + } + + /// Update all submodules (async). + pub fn submodule_update_all_async( + &self, + init: bool, + cx: &mut Context, + ) -> Task>> { + let repo_path = self.repo_path().to_path_buf(); + cx.spawn(async move |_this: WeakEntity, cx: &mut AsyncApp| { + cx.background_executor() + .spawn(async move { + let repo = Repository::open(&repo_path).context("Failed to open repository")?; + submodule_update_all(&repo, init) + }) + .await + }) + } +} + +#[cfg(test)] +mod tests { + use super::SubmoduleInfo; + + fn make_submodule( + is_initialized: bool, + head_oid: Option<&str>, + workdir_oid: Option<&str>, + ) -> SubmoduleInfo { + SubmoduleInfo { + name: "test-sub".to_string(), + path: std::path::PathBuf::from("libs/test-sub"), + url: "https://example.com/repo".to_string(), + branch: None, + head_oid: head_oid.map(String::from), + index_oid: None, + workdir_oid: workdir_oid.map(String::from), + is_initialized, + } + } + + #[test] + fn status_not_initialized() { + let sub = make_submodule(false, Some("abc123"), Some("abc123")); + assert_eq!(sub.status(), "not initialized"); + } + + #[test] + fn status_up_to_date() { + let sub = make_submodule(true, Some("abc123"), Some("abc123")); + assert_eq!(sub.status(), "up to date"); + } + + #[test] + fn status_modified_head_differs_from_workdir() { + let sub = make_submodule(true, Some("abc123"), Some("def456")); + assert_eq!(sub.status(), "modified"); + } + + #[test] + fn status_not_checked_out_has_head_no_workdir() { + let sub = make_submodule(true, Some("abc123"), None); + assert_eq!(sub.status(), "not checked out"); + } + + #[test] + fn status_no_commit() { + let sub = make_submodule(true, None, None); + assert_eq!(sub.status(), "no commit"); + } + + #[test] + fn status_no_commit_with_workdir() { + // Even with a workdir OID, if there's no head OID, it's "no commit" + let sub = make_submodule(true, None, Some("def456")); + assert_eq!(sub.status(), "no commit"); + } +} diff --git a/third_party/rgitui/crates/rgitui_git/src/project/watcher.rs b/third_party/rgitui/crates/rgitui_git/src/project/watcher.rs new file mode 100644 index 000000000..93a0cd8f5 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_git/src/project/watcher.rs @@ -0,0 +1,877 @@ +use gpui::{AsyncApp, Context}; +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; +use std::collections::hash_map::DefaultHasher; +use std::collections::HashSet; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use super::refresh::{gather_refresh_data_lightweight_cached, WorktreeStatusCache}; +use super::{GitProject, GitProjectEvent}; + +/// How long the watcher will go without a fingerprint scan when nothing under a +/// git directory appears to have changed. +/// +/// The scan is normally driven by the filesystem watcher, which is immediate. +/// This is the safety net for the cases where notify cannot be trusted — +/// network shares, some container mounts — where the cost of being slow to +/// notice is a stale sidebar and the cost of polling hard is a permanent +/// fraction of a core per open tab. +const FINGERPRINT_FALLBACK: Duration = Duration::from_secs(5); + +/// Fold a file's length and mtime into `hasher`. Returns whether the file's +/// metadata was readable (i.e. the file exists). +fn mix_file_meta(hasher: &mut DefaultHasher, path: &Path) -> bool { + match path.metadata() { + Ok(meta) => { + meta.len().hash(hasher); + if let Ok(mtime) = meta.modified() { + mtime.hash(hasher); + } + true + } + Err(_) => false, + } +} + +/// Compute a fingerprint over all critical `.git` sentinel files. +/// +/// This covers: +/// - `.git/HEAD` — branch switches, detached HEAD +/// - `.git/index` — staging area changes (`git add`, `git reset`) +/// - `.git/packed-refs` — packed ref updates +/// - `.git/MERGE_HEAD`, `.git/REBASE_HEAD` — in-progress operations +/// - Current branch ref file (parsed from HEAD) — commits on current branch +/// - `.git/refs/heads/` — local branch changes from external operations +/// - `.git/refs/remotes/` — push/fetch ref updates +/// - `.git/refs/tags/` — tag creation/deletion +/// +/// The small sentinel files (HEAD, the current branch ref, packed-refs) are +/// hashed by *content* in addition to size and mtime, so two distinct state +/// transitions whose newest mtime collapses to the same coarse timestamp +/// (FAT32 ~2s, some network/WSL/SMB mounts) are still distinguished. Ref files +/// under `refs/` are covered by metadata plus their directory mtimes instead — +/// see [`scan_refs`]. +fn git_state_fingerprint(git_dir: &Path) -> Option { + let mut hasher = DefaultHasher::new(); + let mut any = false; + + // Sentinel files hashed by content so same-mtime content changes are seen. + for name in ["HEAD", "index", "packed-refs", "MERGE_HEAD", "REBASE_HEAD"] { + let path = git_dir.join(name); + any |= mix_file_meta(&mut hasher, &path); + // `index` can be large; hash bytes only for the tiny sentinels. + if name != "index" { + if let Ok(content) = std::fs::read(&path) { + content.hash(&mut hasher); + } + } + } + + // Current branch ref file (e.g. refs/heads/main from "ref: refs/heads/main\n") + if let Ok(head_content) = std::fs::read_to_string(git_dir.join("HEAD")) { + if let Some(ref_rel) = head_content.strip_prefix("ref: ") { + let ref_path = git_dir.join(ref_rel.trim()); + any |= mix_file_meta(&mut hasher, &ref_path); + if let Ok(content) = std::fs::read(&ref_path) { + content.hash(&mut hasher); + } + } + } + + // Scan refs directories (local branches, remotes, tags). Each file's hash is + // XOR-accumulated so the directory order `read_dir` happens to return doesn't + // perturb the result; the combined accumulator is then mixed in once. + let mut refs_acc: u64 = 0; + for subdir in &["refs/heads", "refs/remotes", "refs/tags"] { + scan_refs(&git_dir.join(subdir), &mut refs_acc); + } + refs_acc.hash(&mut hasher); + + if any { + Some(hasher.finish()) + } else { + None + } +} + +/// Recursively accumulate an order-independent fingerprint over every ref file +/// under `dir` (its path, size and mtime) plus the mtime of each directory +/// visited. `read_dir` yields entries in a filesystem-dependent order, so each +/// entry's hash is XOR-folded into `acc` — that keeps the result stable across +/// scans of an unchanged directory while still changing when any ref file is +/// added, removed or modified. +/// +/// Ref files are deliberately *not* read. This runs every poll tick for the main +/// repo and each watched worktree, so on a repo with thousands of refs opening +/// every one of them was a sustained I/O load that scaled with refs × tabs. +/// Directory mtimes stand in for content: git updates a ref by writing a lock +/// file and renaming it into place, which touches the containing directory even +/// when the ref file's own size (a fixed-width OID) and mtime do not move. The +/// ref that matters most for HEAD state — the checked-out branch — is still +/// content-hashed separately by [`git_state_fingerprint`], as are `HEAD` and +/// `packed-refs`. +fn scan_refs(dir: &Path, acc: &mut u64) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + + if let Ok(meta) = std::fs::metadata(dir) { + if let Ok(mtime) = meta.modified() { + let mut dir_hasher = DefaultHasher::new(); + dir.hash(&mut dir_hasher); + mtime.hash(&mut dir_hasher); + *acc ^= dir_hasher.finish(); + } + } + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + scan_refs(&path, acc); + } else if let Ok(meta) = entry.metadata() { + let mut file_hasher = DefaultHasher::new(); + path.hash(&mut file_hasher); + meta.len().hash(&mut file_hasher); + if let Ok(mtime) = meta.modified() { + mtime.hash(&mut file_hasher); + } + *acc ^= file_hasher.finish(); + } + } +} + +/// Resolve the per-worktree git dir for a linked worktree under the main repo. +/// A worktree's `.git` is a file containing `gitdir: ` pointing into +/// `

/.git/worktrees//`. We read that redirect here so fingerprinting +/// still works when the caller only has the worktree's working-tree path. +fn worktree_git_dir(worktree_path: &Path) -> Option { + let dot_git = worktree_path.join(".git"); + let content = std::fs::read_to_string(&dot_git).ok()?; + for line in content.lines() { + if let Some(rest) = line.strip_prefix("gitdir:") { + let p = PathBuf::from(rest.trim()); + return Some(if p.is_absolute() { + p + } else { + worktree_path.join(p) + }); + } + } + None +} + +/// Compute a combined fingerprint across the main repo's `.git` and every +/// linked worktree's per-worktree git dir. The per-dir fingerprints are XOR-ed +/// so the result is independent of the (unordered) worktree-set iteration order +/// while still changing whenever any watched git dir changes. +fn combined_fingerprint( + primary_git_dir: &Path, + common_git_dir: &Path, + extra_worktree_paths: &HashSet, +) -> Option { + let mut combined = git_state_fingerprint(primary_git_dir); + if common_git_dir != primary_git_dir { + if let Some(fp) = git_state_fingerprint(common_git_dir) { + combined = Some(combined.map_or(fp, |current| current ^ fp)); + } + } + for wt in extra_worktree_paths { + if let Some(git_dir) = worktree_git_dir(wt) { + if let Some(fp) = git_state_fingerprint(&git_dir) { + combined = Some(combined.map_or(fp, |c| c ^ fp)); + } + } + } + combined +} + +fn repository_git_dirs(repo_path: &Path) -> Option<(PathBuf, PathBuf)> { + let repo = git2::Repository::open(repo_path).ok()?; + Some((repo.path().to_path_buf(), repo.commondir().to_path_buf())) +} + +/// Gitignore filtering for the notify callback, one handle per watched checkout. +/// +/// `Repository::status_should_ignore` resolves the path it is given against the +/// workdir of the repository it is asked. Asking the main repository about a +/// path inside a *linked worktree* therefore evaluates a nonsense relative path +/// and answers "not ignored" no matter what that worktree's `.gitignore` says — +/// so a `cargo build` in a watched worktree woke a refresh for every file the +/// compiler wrote. Routing each event path to the checkout that owns it, and +/// asking about the path *relative to that root*, makes the answer correct. +struct IgnoreRepos { + repos: Vec<(PathBuf, git2::Repository)>, +} + +impl IgnoreRepos { + fn new() -> Self { + Self { repos: Vec::new() } + } + + /// Open a handle for `root` if one is not held already. + fn add(&mut self, root: &Path) { + if self.repos.iter().any(|(path, _)| path == root) { + return; + } + match git2::Repository::open(root) { + Ok(repo) => self.repos.push((root.to_path_buf(), repo)), + Err(e) => log::debug!("No gitignore filtering for {}: {}", root.display(), e), + } + } + + /// Drop handles for roots that are no longer watched. + fn retain_roots(&mut self, keep: &HashSet) { + self.repos.retain(|(path, _)| keep.contains(path)); + } + + /// Whether every path in `paths` is ignored by the checkout that owns it. + /// A path under no watched root counts as not ignored, so unknown changes + /// still wake a refresh rather than being silently dropped. + fn all_ignored(&self, paths: &[PathBuf]) -> bool { + !paths.is_empty() && paths.iter().all(|path| self.is_ignored(path)) + } + + fn is_ignored(&self, path: &Path) -> bool { + let Some(owner) = owning_root(self.repos.iter().map(|(root, _)| root.as_path()), path) + else { + return false; + }; + self.repos + .iter() + .find(|(root, _)| root == owner) + .is_some_and(|(root, repo)| match path.strip_prefix(root) { + Ok(relative) if relative.as_os_str().is_empty() => false, + // A workdir-relative path sidesteps libgit2's absolute-path + // relativization entirely, which is where the cross-worktree + // answer went wrong. + Ok(relative) => repo.status_should_ignore(relative).unwrap_or(false), + Err(_) => false, + }) + } +} + +/// The checkout that owns `path`: the deepest watched root containing it. +/// +/// A linked worktree may live inside the main checkout, and when it does the +/// main repository almost certainly ignores that directory — you do not want a +/// worktree showing up as untracked files in its own parent. Asking every root +/// and taking any "yes" would let that answer suppress events for files the +/// worktree itself tracks, leaving its status and diff stale while the user +/// edits them. Only the innermost checkout has standing to answer. +fn owning_root<'a>(roots: impl Iterator, path: &Path) -> Option<&'a Path> { + roots + .filter(|root| { + path.strip_prefix(root) + .is_ok_and(|relative| !relative.as_os_str().is_empty()) + }) + .max_by_key(|root| root.components().count()) +} + +impl GitProject { + /// Start watching the repository for filesystem changes. + /// + /// Always watches the main repo path. When the `watch_all_worktrees` + /// setting is enabled, additionally watches every linked worktree's path + /// and fingerprints its per-worktree git dir. The watched set is + /// re-evaluated on every poll tick, so toggling the setting or adding / + /// removing worktrees takes effect without restarting the app. + pub(super) fn start_watcher(&mut self, cx: &mut Context) { + let repo_path = self.repo_path.clone(); + let (main_git_dir, common_git_dir) = repository_git_dirs(&repo_path).unwrap_or_else(|| { + let fallback = repo_path.join(".git"); + (fallback.clone(), fallback) + }); + let dirty = Arc::new(AtomicBool::new(false)); + let worktree_cache: Arc> = self.worktree_status_cache.clone(); + + // Keep the notify watcher behind a mutex so the poll loop can add / + // remove watched paths when the all-worktrees setting flips. + let watcher_handle: Arc>> = Arc::new(Mutex::new(None)); + + // Set when something under a git directory changed, which is the only + // time the fingerprint scan can tell us anything new. + let git_dirty = Arc::new(AtomicBool::new(false)); + + // One gitignore handle per watched checkout, kept in step with the + // watched set by the poll loop below. + let ignore_repos: Arc> = Arc::new(Mutex::new(IgnoreRepos::new())); + ignore_repos.lock().expect("ignore mutex").add(&repo_path); + + { + let dirty_flag = dirty.clone(); + let git_dirty_flag = git_dirty.clone(); + let callback_ignore_repos = ignore_repos.clone(); + let watcher = + notify::recommended_watcher(move |res: Result| { + if let Ok(event) = res { + // A change under `.git` is not a working-tree change, + // but it is exactly what the fingerprint scan exists to + // detect — a commit, a checkout, a branch move. Noting + // it here is what lets the poll loop skip that scan + // while nothing is happening, instead of walking every + // loose ref ten times a minute forever. + let dominated_by_git = event + .paths + .iter() + .all(|p| p.components().any(|c| c.as_os_str() == ".git")); + if dominated_by_git { + git_dirty_flag.store(true, Ordering::Relaxed); + return; + } + // Skip events where every changed path is gitignored + // (e.g. `target/`, `node_modules/`). Tracked files — + // even modified ones — are never reported as ignored, + // so legitimate changes still pass through. + if let Ok(repos) = callback_ignore_repos.lock() { + if repos.all_ignored(&event.paths) { + return; + } + } + match event.kind { + notify::EventKind::Create(_) + | notify::EventKind::Modify(_) + | notify::EventKind::Remove(_) => { + dirty_flag.store(true, Ordering::Relaxed); + } + _ => {} + } + } + }); + + match watcher { + Ok(mut w) => { + if let Err(e) = w.watch(&repo_path, RecursiveMode::Recursive) { + log::warn!( + "Failed to watch repository directory for filesystem changes: {}", + e + ); + } + // A linked worktree or a `.git` file pointing elsewhere puts + // the git directory outside the tree just watched, and + // without this the fingerprint scan would only ever run on + // the slow fallback for those repositories. + for git_dir in [&main_git_dir, &common_git_dir] { + if git_dir.starts_with(&repo_path) { + continue; + } + if let Err(e) = w.watch(git_dir, RecursiveMode::Recursive) { + log::warn!( + "Failed to watch git directory {}: {}", + git_dir.display(), + e + ); + } + } + *watcher_handle.lock().expect("watcher mutex") = Some(w); + } + Err(e) => { + log::warn!("Failed to create filesystem watcher: {}", e); + } + } + } + + let last_fingerprint: Arc>> = Arc::new(Mutex::new(combined_fingerprint( + &main_git_dir, + &common_git_dir, + &HashSet::new(), + ))); + let watched_extras: Arc>> = Arc::new(Mutex::new(HashSet::new())); + + let watcher_commit_limit = self.commit_limit; + cx.spawn({ + let watcher_handle = watcher_handle.clone(); + let watched_extras = watched_extras.clone(); + let ignore_repos = ignore_repos.clone(); + let last_fingerprint = last_fingerprint.clone(); + let dirty = dirty.clone(); + let repo_path = repo_path.clone(); + let main_git_dir = main_git_dir.clone(); + let common_git_dir = common_git_dir.clone(); + let worktree_cache = worktree_cache.clone(); + async move |weak, cx: &mut AsyncApp| { + // Keep the watcher handle alive for the lifetime of this task. + // When the task ends (because the entity was dropped), the Arc + // refcount hits zero and the notify watcher shuts down. + let _keepalive = watcher_handle.clone(); + // Forces a scan on the first tick, so a change made between + // opening the repository and the watcher starting is not missed. + let mut last_scan = std::time::Instant::now() - FINGERPRINT_FALLBACK; + + loop { + smol::Timer::after(Duration::from_millis(300)).await; + log::trace!("watcher: poll tick"); + + // Re-read the all-worktrees setting and current worktree + // list. Sync the notify watcher's watched paths and the + // fingerprint set to match. `None` signals the entity + // has been dropped so we should end the watcher loop. The + // refresh generation captured here gates whether this tick's + // (lightweight) result is allowed to land later (BUG-22). + let snapshot: Option<(HashSet, usize, Option, u64)> = cx + .update(|app| { + let entity = weak.upgrade()?; + let watch_all = app + .try_global::() + .map(|s| s.settings().watch_all_worktrees) + .unwrap_or(false); + let proj = entity.read(app); + // Refetch at least as many commits as are currently loaded + // so a background refresh doesn't truncate commits the user + // paged in via "load more" (the gather otherwise replaces + // the whole list with just `commit_limit` commits). + let effective_limit = + proj.loaded_commit_count().max(watcher_commit_limit); + let mut paths = if watch_all { + proj.worktrees + .iter() + .filter(|w| !w.is_current) + .map(|w| w.path.clone()) + .collect::>() + } else { + HashSet::new() + }; + // The worktree the user is inspecting is watched + // whether or not the setting is on: its file list + // and diff are on screen, so they have to stay live. + if let Some(inspected) = proj.inspected_worktree() { + if inspected != proj.repo_path { + paths.insert(inspected.to_path_buf()); + } + } + Some(( + paths, + effective_limit, + proj.commit_author_filter.clone(), + proj.refresh_generation(), + )) + }); + let (desired, effective_limit, author_filter, generation) = match snapshot { + Some(d) => d, + None => break, + }; + + // A change to the watched set changes what the fingerprint + // covers, so the next scan must run even if nothing on disk + // moved — otherwise a newly watched worktree stays invisible + // until the fallback. + let watched_changed = { + let mut current = watched_extras.lock().expect("watched mutex"); + let changed = *current != desired; + if changed { + if let Ok(mut guard) = watcher_handle.lock() { + if let Some(w) = guard.as_mut() { + for to_remove in current.difference(&desired) { + if let Err(e) = w.unwatch(to_remove) { + log::debug!( + "Failed to unwatch {}: {}", + to_remove.display(), + e + ); + } + } + for to_add in desired.difference(¤t) { + if let Err(e) = w.watch(to_add, RecursiveMode::Recursive) { + log::warn!( + "Failed to watch worktree {}: {}", + to_add.display(), + e + ); + } + } + } + } + *current = desired.clone(); + + if let Ok(mut repos) = ignore_repos.lock() { + let mut keep = desired.clone(); + keep.insert(repo_path.clone()); + repos.retain_roots(&keep); + for root in &desired { + repos.add(root); + } + } + } + changed + }; + + // Detect git internal state changes across main + watched + // worktrees (commits, pushes, branch ops, staging + // changes, etc.) that don't produce working-directory + // events on the main repo. The fingerprint scan walks the + // `refs/` directories and reads sentinel files, so it runs on + // the background executor — never the UI thread (QUAL-01, + // PERF-13). + // + // Only when the watcher saw a git directory change, or the + // fallback is due. The scan reads sentinel files and + // `metadata()`s every loose ref, which measured 6.4ms p95 + // against a 200-ref repository — running it unconditionally + // every 300ms cost around 2% of a core per open tab with + // nothing happening at all, and scaled with ref count. + // The fallback covers network mounts, where notify is + // unreliable enough that it cannot be the only signal. + let fallback_due = last_scan.elapsed() >= FINGERPRINT_FALLBACK; + let fingerprint_changed = if git_dirty.swap(false, Ordering::Relaxed) + || fallback_due + || watched_changed + { + last_scan = std::time::Instant::now(); + let fp_git_dir = main_git_dir.clone(); + let fp_common_git_dir = common_git_dir.clone(); + let fp_extras = desired.clone(); + let current = cx + .background_executor() + .spawn(async move { + combined_fingerprint(&fp_git_dir, &fp_common_git_dir, &fp_extras) + }) + .await; + let mut guard = last_fingerprint.lock().expect("fp mutex"); + if current.is_some() && current != *guard { + *guard = current; + true + } else { + false + } + } else { + false + }; + + if fingerprint_changed { + log::debug!("watcher: git state changed, scheduling refresh"); + dirty.store(true, Ordering::Relaxed); + } + + if dirty.swap(false, Ordering::Relaxed) { + // Debounce: coalesce a burst of events (e.g. `git checkout` + // touching many files) into one refresh. `swap` already + // cleared the flag; any event arriving during this wait or + // the refresh below re-arms `dirty` and is picked up on the + // next poll tick, so no refresh is lost. + smol::Timer::after(Duration::from_millis(200)).await; + + let path = repo_path.clone(); + let cache = worktree_cache.clone(); + // Carry the active "My Commits" filter through the + // lightweight gather so a watcher tick doesn't replace + // the filtered list with the full unfiltered log (BUG-06). + let watcher_author_filter = author_filter.clone(); + let data = cx + .background_executor() + .spawn(async move { + gather_refresh_data_lightweight_cached( + &path, + effective_limit, + &cache, + watcher_author_filter.as_deref(), + ) + }) + .await; + + let data = match data { + Ok(d) => { + log::debug!("watcher: lightweight refresh complete"); + d + } + Err(_) => continue, + }; + + let result = cx.update(|cx| { + weak.update(cx, |this, cx| { + // Generation guard (BUG-22): if a full/operation + // refresh applied while this lightweight gather + // was in flight, its accurate state (correct + // ahead/behind, filtered commits) must win. Drop + // this stale lightweight result and re-arm `dirty` + // so the next tick re-gathers against the current + // generation — the fingerprint already advanced, + // so without re-arming the change could otherwise + // be missed until an unrelated event. + if this.refresh_generation() != generation { + log::debug!( + "watcher: dropping stale lightweight refresh (gen {} != {})", + generation, + this.refresh_generation() + ); + return false; + } + + // Ahead/behind survives this apply through + // `carry_forward_branch_graph_state`, which keys + // on the branch tip and the upstream tip. The + // name-keyed overlay that used to live here + // predated that and re-applied counts across a + // moved upstream, which is exactly what a push + // does to them. + this.apply_refresh_data(data); + + // A reference point may have moved — HEAD most + // often, via a checkout in a terminal — which + // drops the flags derived from it. Nothing else + // on this path recomputes them, so without this + // the sidebar would keep rendering a "merged" + // badge measured against the previous HEAD until + // an unrelated full refresh happened along. + if this.needs_graph_state_refresh() { + this.refresh_ahead_behind(cx); + } + + cx.emit(GitProjectEvent::StatusChanged); + cx.notify(); + true + }) + }); + + match result { + Ok(true) => {} + // The lightweight result was dropped as stale; re-arm + // so the next poll tick reapplies against the current + // generation. + Ok(false) => dirty.store(true, Ordering::Relaxed), + // Entity released (or app shutting down): end the loop. + Err(_) => break, + } + } + } + drop(_keepalive); + } + }) + .detach(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_nested_worktree_answers_for_its_own_files() { + let main = Path::new("/repo"); + let nested = Path::new("/repo/feature"); + let roots = [main, nested]; + + // The main checkout very likely ignores `feature/` — a worktree inside + // it would otherwise read as a pile of untracked files — so letting it + // answer would suppress events for files the worktree tracks. + assert_eq!( + owning_root(roots.into_iter(), Path::new("/repo/feature/src/main.rs")), + Some(nested) + ); + assert_eq!( + owning_root(roots.into_iter(), Path::new("/repo/src/main.rs")), + Some(main) + ); + } + + #[test] + fn a_path_under_no_watched_root_has_no_owner() { + let roots = [Path::new("/repo")]; + assert_eq!( + owning_root(roots.into_iter(), Path::new("/elsewhere/file.rs")), + None + ); + } + + #[test] + fn a_root_does_not_own_itself() { + // Stripping the prefix leaves nothing, and asking libgit2 whether a + // checkout ignores its own directory is not a question with an answer. + let roots = [Path::new("/repo")]; + assert_eq!(owning_root(roots.into_iter(), Path::new("/repo")), None); + } + + #[test] + fn loose_ref_fingerprint_includes_oid_content() { + let temp = tempfile::TempDir::new().unwrap(); + let git_dir = temp.path(); + std::fs::create_dir_all(git_dir.join("refs/heads")).unwrap(); + std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + let reference = git_dir.join("refs/heads/main"); + std::fs::write(&reference, format!("{}\n", "1".repeat(40))).unwrap(); + let before = git_state_fingerprint(git_dir).unwrap(); + std::fs::write(&reference, format!("{}\n", "2".repeat(40))).unwrap(); + let after = git_state_fingerprint(git_dir).unwrap(); + assert_ne!(before, after); + } + + /// Ref files outside the checked-out branch are tracked by metadata and + /// directory mtime rather than by reading each one, so adding or removing a + /// ref must still move the fingerprint. + #[test] + fn fingerprint_tracks_added_and_removed_refs() { + let temp = tempfile::TempDir::new().unwrap(); + let git_dir = temp.path(); + std::fs::create_dir_all(git_dir.join("refs/heads")).unwrap(); + std::fs::create_dir_all(git_dir.join("refs/remotes/origin")).unwrap(); + std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + std::fs::write( + git_dir.join("refs/heads/main"), + format!("{}\n", "1".repeat(40)), + ) + .unwrap(); + + let baseline = git_state_fingerprint(git_dir).unwrap(); + + let remote_ref = git_dir.join("refs/remotes/origin/feature"); + std::fs::write(&remote_ref, format!("{}\n", "3".repeat(40))).unwrap(); + let with_ref = git_state_fingerprint(git_dir).unwrap(); + assert_ne!(baseline, with_ref, "adding a remote ref must be visible"); + + std::fs::remove_file(&remote_ref).unwrap(); + let without_ref = git_state_fingerprint(git_dir).unwrap(); + assert_ne!(with_ref, without_ref, "removing a ref must be visible"); + // Not expected to equal `baseline`: the directory mtime moved when the + // ref was created and again when it was removed, and that mtime is part + // of the fingerprint. Fingerprints only need to differ when state + // differs, never to be reproducible across a round trip. + } + + /// The accumulator must not depend on `read_dir` ordering. + #[test] + fn fingerprint_is_stable_across_repeated_scans() { + let temp = tempfile::TempDir::new().unwrap(); + let git_dir = temp.path(); + std::fs::create_dir_all(git_dir.join("refs/heads")).unwrap(); + std::fs::create_dir_all(git_dir.join("refs/tags")).unwrap(); + std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + std::fs::write( + git_dir.join("refs/heads/main"), + format!("{}\n", "1".repeat(40)), + ) + .unwrap(); + for i in 0..25 { + std::fs::write( + git_dir.join(format!("refs/tags/v{i}")), + format!("{}\n", "a".repeat(40)), + ) + .unwrap(); + } + + let first = git_state_fingerprint(git_dir).unwrap(); + let second = git_state_fingerprint(git_dir).unwrap(); + assert_eq!(first, second); + } + + #[test] + fn linked_worktree_uses_redirected_and_common_git_dirs() { + let temp = tempfile::TempDir::new().unwrap(); + let main_path = temp.path().join("main"); + let linked_path = temp.path().join("linked"); + let repo = git2::Repository::init(&main_path).unwrap(); + let sig = git2::Signature::now("Test", "test@example.com").unwrap(); + let tree_oid = repo.index().unwrap().write_tree().unwrap(); + let tree = repo.find_tree(tree_oid).unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[]) + .unwrap(); + drop(tree); + repo.worktree("linked", &linked_path, None).unwrap(); + + let (git_dir, common_dir) = repository_git_dirs(&linked_path).unwrap(); + assert_ne!(git_dir, linked_path.join(".git")); + assert_eq!(common_dir, repo.commondir()); + assert!(git_state_fingerprint(&git_dir).is_some()); + assert!(git_state_fingerprint(&common_dir).is_some()); + } +} + +#[cfg(test)] +mod ignore_repos_tests { + use super::*; + use rgitui_test_support::TempRepo; + + /// Main repo with a committed `/target` ignore rule plus a linked worktree + /// that inherits it. The worktree lives *outside* the main working + /// directory, which is where linked worktrees normally sit and where the + /// cross-checkout ignore question actually bites. + fn repo_with_worktree() -> (TempRepo, tempfile::TempDir) { + let fixture = TempRepo::init(); + fixture.commit_file(".gitignore", "/target\n", "add gitignore"); + + let elsewhere = tempfile::TempDir::new().unwrap(); + let worktree_path = elsewhere.path().join("feature"); + { + let repo = fixture.repo(); + let head = repo.head().unwrap().peel_to_commit().unwrap(); + repo.branch("feature", &head, false).unwrap(); + let reference = repo.find_reference("refs/heads/feature").unwrap(); + let mut opts = git2::WorktreeAddOptions::new(); + opts.reference(Some(&reference)); + repo.worktree("feature", &worktree_path, Some(&opts)) + .unwrap(); + } + + (fixture, elsewhere) + } + + /// The checkout [`repo_with_worktree`] added, inside its temp directory. + fn worktree_path(elsewhere: &tempfile::TempDir) -> PathBuf { + elsewhere.path().join("feature") + } + + #[test] + fn ignored_build_output_in_the_main_repo_is_filtered() { + let fixture = TempRepo::init(); + fixture.commit_file(".gitignore", "/target\n", "add gitignore"); + let mut repos = IgnoreRepos::new(); + repos.add(fixture.path()); + + assert!(repos.all_ignored(&[fixture.path().join("target/debug/app.exe")])); + assert!(!repos.all_ignored(&[fixture.path().join("src/main.rs")])); + } + + /// The main repository cannot answer for a path inside a *different* + /// checkout: libgit2 resolves the path against its own workdir, so the + /// answer comes back "not ignored" and every file a compiler writes in the + /// worktree used to wake a refresh. + #[test] + fn a_worktree_needs_its_own_handle_to_filter_its_build_output() { + let (fixture, elsewhere) = repo_with_worktree(); + let worktree_path = worktree_path(&elsewhere); + let build_output = worktree_path.join("target/debug/app.exe"); + + let mut main_only = IgnoreRepos::new(); + main_only.add(fixture.path()); + assert!( + !main_only.all_ignored(std::slice::from_ref(&build_output)), + "the main repo has no basis to answer for another checkout" + ); + + let mut both = IgnoreRepos::new(); + both.add(fixture.path()); + both.add(&worktree_path); + assert!(both.all_ignored(&[build_output])); + assert!(!both.all_ignored(&[worktree_path.join("src/main.rs")])); + } + + #[test] + fn a_mixed_event_is_not_filtered() { + let fixture = TempRepo::init(); + fixture.commit_file(".gitignore", "/target\n", "add gitignore"); + let mut repos = IgnoreRepos::new(); + repos.add(fixture.path()); + + assert!(!repos.all_ignored(&[ + fixture.path().join("target/debug/app.exe"), + fixture.path().join("src/main.rs"), + ])); + assert!(!repos.all_ignored(&[]), "an empty event is never filtered"); + } + + #[test] + fn dropping_a_root_stops_it_answering() { + let (fixture, elsewhere) = repo_with_worktree(); + let worktree_path = worktree_path(&elsewhere); + let build_output = worktree_path.join("target/debug/app.exe"); + let mut repos = IgnoreRepos::new(); + repos.add(fixture.path()); + repos.add(&worktree_path); + assert!(repos.all_ignored(std::slice::from_ref(&build_output))); + + let keep: HashSet = std::iter::once(fixture.path().to_path_buf()).collect(); + repos.retain_roots(&keep); + assert!(!repos.all_ignored(&[build_output])); + } +} diff --git a/third_party/rgitui/crates/rgitui_git/src/project/worktree_patch.rs b/third_party/rgitui/crates/rgitui_git/src/project/worktree_patch.rs new file mode 100644 index 000000000..31b0708e0 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_git/src/project/worktree_patch.rs @@ -0,0 +1,3265 @@ +//! Applying and reverting diff content in the working tree. +//! +//! The patch comes from a pair of trees that need not include the working tree +//! at all — a past commit against its parent, a stash entry, or two arbitrary +//! branches — and the result is written to files on disk. A dirty working tree is +//! the normal case: you compare against another revision precisely because you +//! are mid-change. +//! +//! ## Constraints on the two obvious appliers +//! +//! Neither off-the-shelf applier tolerates that dirty working tree: +//! +//! * `repo.apply(.., ApplyLocation::WorkDir, ..)` matches each hunk's context +//! against the target and fails outright when it does not line up, with no +//! three-way fallback. An uncommitted edit anywhere inside a hunk's context +//! window (three lines either side) defeats it. +//! * `git apply --3way` merges into the *index*, and refuses with +//! `error: : does not match index` whenever the working-tree file differs +//! from its index entry. On failure it also leaves conflict markers and an +//! unmerged index entry behind. +//! +//! ## What this module does +//! +//! Reconstructs the two sides and lets libgit2 merge them: +//! +//! * `base` — the file as it is on the side the patch starts from. +//! * `target` — `base` rewritten with exactly the selected hunk or lines +//! applied (or reverted). Computed from the diff rather than by matching +//! context, so it is exact by construction. +//! * `ours` — the file as it is in the working tree right now. +//! +//! A three-way merge of (base, ours, target) then keeps unrelated local edits +//! and conflicts only where an edit overlaps the selected region. The index is +//! never touched and never has to match the working tree. + +use std::collections::HashSet; +use std::ffi::OsString; +use std::io::{Seek as _, Write as _}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use anyhow::{Context as _, Result}; +use git2::{FileMode, IndexEntry, IndexTime, Oid, Repository}; + +/// Serializes every in-process filesystem transaction performed by this +/// module. The filesystem compare-and-swap checks still defend against edits +/// from editors and other processes; this lock prevents two background tasks +/// in rgitui from both accepting the same pre-image. +static WORKTREE_MUTATION_LOCK: parking_lot::Mutex<()> = parking_lot::Mutex::new(()); + +/// Which side of a diff the working tree should be moved toward. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorktreePatchDirection { + /// Bring the diff's new-side content into the working tree — the + /// hunk-level equivalent of a cherry-pick. + Apply, + /// Restore the diff's old-side content in the working tree — the + /// hunk-level equivalent of a revert. + Revert, +} + +impl WorktreePatchDirection { + /// Verb used in progress and success messages ("Applying", "Applied"). + pub fn present_participle(self) -> &'static str { + match self { + WorktreePatchDirection::Apply => "Applying", + WorktreePatchDirection::Revert => "Reverting", + } + } + + /// Verb used in success messages. + pub fn past_tense(self) -> &'static str { + match self { + WorktreePatchDirection::Apply => "Applied", + WorktreePatchDirection::Revert => "Reverted", + } + } + + fn verb(self) -> &'static str { + match self { + WorktreePatchDirection::Apply => "apply", + WorktreePatchDirection::Revert => "revert", + } + } + + /// The direction that undoes this one. + pub fn inverse(self) -> Self { + match self { + WorktreePatchDirection::Apply => WorktreePatchDirection::Revert, + WorktreePatchDirection::Revert => WorktreePatchDirection::Apply, + } + } +} + +/// How much of a file's diff a working-tree apply or revert covers. +/// +/// The three variants are the three granularities the diff viewer offers: the +/// hunk under the cursor by default, a manual line selection when the user has +/// made one, and the whole file from the file-level menu. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorktreePatchScope { + /// Every hunk in the file. + File, + /// One hunk, indexed as the diff viewer displays it. + Hunk(usize), + /// Only the given change lines, as `(old_lineno, new_lineno)` pairs from + /// the diff viewer: an addition is `(None, Some(n))`, a deletion + /// `(Some(n), None)`. + Lines(Vec<(Option, Option)>), +} + +impl WorktreePatchScope { + /// Human-readable description used in operation summaries and toasts. + pub fn describe(&self) -> String { + match self { + WorktreePatchScope::File => "all changes".to_string(), + WorktreePatchScope::Hunk(index) => format!("hunk {}", index + 1), + WorktreePatchScope::Lines(pairs) => format!( + "{} line{}", + pairs.len(), + if pairs.len() == 1 { "" } else { "s" } + ), + } + } +} + +/// The pair of revisions whose difference is being applied or reverted. +/// +/// Both variants resolve to a `from`/`to` tree pair, so a commit diff and a +/// cross-branch comparison travel identical code from there on. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorktreePatchSource { + /// The change one commit (or stash entry) introduced: its first parent → + /// itself. A root commit's `from` side is the empty tree. + Commit(Oid), + /// The difference between two arbitrary revisions, `from` → `to`. Both are + /// resolved with `revparse_single`, so branch names, tags, `HEAD~3`, + /// `origin/main` and raw OIDs all work. + Compare { from: String, to: String }, +} + +impl WorktreePatchSource { + /// Short label naming this source in user-facing messages. + pub fn label(&self) -> String { + match self { + WorktreePatchSource::Commit(oid) => short_oid(&oid.to_string()), + WorktreePatchSource::Compare { from, to } => format!("{from}...{to}"), + } + } + + /// The (from, to) trees this source compares. Either side may be absent — + /// a root commit has no parent tree, and an explicitly empty revision + /// resolves to no tree. + fn trees<'repo>( + &self, + repo: &'repo Repository, + ) -> Result<(Option>, Option>)> { + match self { + WorktreePatchSource::Commit(oid) => { + let commit = repo.find_commit(*oid).with_context(|| { + format!( + "Commit {} is not in this repository", + short_oid(&oid.to_string()) + ) + })?; + let to = commit.tree()?; + let from = if commit.parent_count() > 0 { + Some(commit.parent(0)?.tree()?) + } else { + None + }; + Ok((from, Some(to))) + } + WorktreePatchSource::Compare { from, to } => Ok(( + Some(resolve_tree(repo, from)?), + Some(resolve_tree(repo, to)?), + )), + } + } +} + +fn resolve_tree<'repo>(repo: &'repo Repository, rev: &str) -> Result> { + let object = repo.revparse_single(rev).with_context(|| { + format!("Can't resolve '{rev}' — check the branch, tag or commit name and try again.") + })?; + object + .peel_to_tree() + .with_context(|| format!("'{rev}' does not name a commit or tree.")) +} + +/// Shorten a hex OID for display, leaving anything already short untouched. +pub(crate) fn short_oid(oid_hex: &str) -> String { + oid_hex[..7.min(oid_hex.len())].to_string() +} + +/// A regular working-tree file's exact on-disk state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorktreeFileState { + /// Bytes as stored in the working tree, after Git's smudge/encoding/EOL + /// conversions rather than the canonical bytes stored in an object. + pub contents: Vec, + /// Permissions needed to restore the file without silently changing its + /// executable or read-only state. + pub permissions: WorktreeFilePermissions, +} + +/// Portable portion of a file's permissions, plus the complete Unix mode when +/// that platform makes it available. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorktreeFilePermissions { + pub readonly: bool, + #[cfg(unix)] + pub mode: u32, +} + +/// The state of one working-tree file around an apply or revert. +/// +/// Undo restores `before` only while the path still equals `expected_after`. +/// That compare-and-swap contract prevents Undo from destroying edits made +/// after the operation completed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorktreeFileSnapshot { + /// Path relative to the worktree root. + pub path: PathBuf, + /// State before the operation; `None` when the file did not exist. + pub before: Option, + /// State written by the operation; `None` when it deleted the file. + pub expected_after: Option, +} + +/// Largest total snapshot the undo stack will hold for one operation. Past this +/// the operation still runs, but it is not offered as undoable rather than +/// parking tens of megabytes in a 20-deep history. +pub const MAX_UNDO_SNAPSHOT_BYTES: usize = 4 * 1024 * 1024; + +/// Whether `snapshots` are small enough to keep in the undo stack. +/// +/// Pure so the cap is testable without a repository. +pub fn snapshots_fit_undo_stack(snapshots: &[WorktreeFileSnapshot]) -> bool { + snapshots + .iter() + .flat_map(|snapshot| [snapshot.before.as_ref(), snapshot.expected_after.as_ref()]) + .flatten() + .map(|state| state.contents.len()) + .sum::() + <= MAX_UNDO_SNAPSHOT_BYTES +} + +/// Outcome of a successful working-tree apply or revert. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorktreePatchOutcome { + /// Exact before/after states of every file the operation rewrote. + pub snapshots: Vec, + /// True when the change could not be dropped in verbatim and was merged + /// around unrelated local edits. Worth telling the user about: their file + /// now holds both their edit and the applied change. + pub merged_with_local_changes: bool, +} + +// ── Pure line rewriting ─────────────────────────────────────────────────────── + +/// Split `text` into lines, keeping each line's terminator so a file with no +/// trailing newline round-trips unchanged. +pub(crate) fn split_keeping_terminators(text: &str) -> Vec { + let mut lines = Vec::new(); + let mut current = String::new(); + for ch in text.chars() { + current.push(ch); + if ch == '\n' { + lines.push(std::mem::take(&mut current)); + } + } + if !current.is_empty() { + lines.push(current); + } + lines +} + +/// One line of a hunk, reduced to what the rewrite needs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ScopedLine { + Context { + old_lineno: usize, + new_lineno: usize, + }, + Addition { + new_lineno: usize, + text: String, + }, + Deletion { + old_lineno: usize, + text: String, + }, +} + +impl ScopedLine { + /// This line's position on `direction`'s base side, or `None` when it only + /// exists on the target side. + fn base_lineno(&self, direction: WorktreePatchDirection) -> Option { + match (self, direction) { + (ScopedLine::Context { old_lineno, .. }, WorktreePatchDirection::Apply) => { + Some(*old_lineno) + } + (ScopedLine::Context { new_lineno, .. }, WorktreePatchDirection::Revert) => { + Some(*new_lineno) + } + (ScopedLine::Deletion { old_lineno, .. }, WorktreePatchDirection::Apply) => { + Some(*old_lineno) + } + (ScopedLine::Addition { new_lineno, .. }, WorktreePatchDirection::Revert) => { + Some(*new_lineno) + } + _ => None, + } + } + + fn is_context(&self) -> bool { + matches!(self, ScopedLine::Context { .. }) + } +} + +/// Rewrite `base` — the file as it stands on `direction`'s starting side — into +/// the side the operation moves toward, covering only what the scope selects. +/// +/// `hunks` is the file's diff, hunk by hunk, in display order. `selected_hunks` +/// is `None` for "every hunk"; `selected_lines` is `None` for "every change line +/// of the selected hunks" and otherwise the `(old, new)` pairs the viewer +/// emitted. +/// +/// Within a run of consecutive `-`/`+` lines the *i*-th removal is treated as +/// paired with the *i*-th insertion, matching what the side-by-side view shows +/// the user. Git's unified output groups all removals ahead of all insertions, +/// so reading the rows in order would put a kept line on the wrong side of an +/// applied one when only part of a run is selected. +/// +/// Per pair the rule is uniform in both directions: emit the target-side line +/// when its row is selected, and keep the base-side line when its row is not. +/// Selecting both sides of a substitution therefore replaces the line, selecting +/// neither leaves it alone, selecting only the insertion adds a line beside the +/// original, and selecting only the removal drops it. +/// +/// Pure: no repository, no filesystem, no GPUI, and both directions run the same +/// walk with the two sides swapped, so an apply and a revert can never disagree +/// about which lines they touch. +pub(crate) fn rewrite_side( + base: &[String], + hunks: &[Vec], + direction: WorktreePatchDirection, + selected_hunks: Option<&HashSet>, + selected_lines: Option<&SelectedLines>, +) -> String { + /// Emit base lines from `cursor` up to (but not including) `upto`. + fn copy_base_through<'a>( + pieces: &mut Vec<&'a str>, + base: &'a [String], + cursor: &mut usize, + upto: usize, + ) { + while *cursor < upto && *cursor <= base.len() { + pieces.push(&base[*cursor - 1]); + *cursor += 1; + } + } + + let mut pieces: Vec<&str> = Vec::new(); + // Next base line number not yet emitted, 1-based. + let mut cursor = 1usize; + + for (hunk_index, lines) in hunks.iter().enumerate() { + let hunk_selected = selected_hunks.is_none_or(|set| set.contains(&hunk_index)); + let is_selected = |line: &ScopedLine| -> bool { + if !hunk_selected { + return false; + } + match (line, selected_lines) { + (_, None) => true, + (ScopedLine::Addition { new_lineno, .. }, Some(selected)) => { + selected.contains_addition(*new_lineno) + } + (ScopedLine::Deletion { old_lineno, .. }, Some(selected)) => { + selected.contains_deletion(*old_lineno) + } + (ScopedLine::Context { .. }, Some(_)) => false, + } + }; + + // Copy the untouched region ahead of this hunk. + if let Some(first) = lines.iter().filter_map(|l| l.base_lineno(direction)).min() { + copy_base_through(&mut pieces, base, &mut cursor, first); + } + + let mut index = 0; + while index < lines.len() { + if lines[index].is_context() { + if let Some(lineno) = lines[index].base_lineno(direction) { + copy_base_through(&mut pieces, base, &mut cursor, lineno); + if lineno <= base.len() { + pieces.push(&base[lineno - 1]); + } + cursor = lineno + 1; + } + index += 1; + continue; + } + + // A run of consecutive change lines, split into the two sides. + let run_end = lines[index..] + .iter() + .position(ScopedLine::is_context) + .map(|offset| index + offset) + .unwrap_or(lines.len()); + let run = &lines[index..run_end]; + let base_side: Vec<&ScopedLine> = run + .iter() + .filter(|line| line.base_lineno(direction).is_some()) + .collect(); + let target_side: Vec<&ScopedLine> = run + .iter() + .filter(|line| line.base_lineno(direction).is_none()) + .collect(); + + for pair_index in 0..base_side.len().max(target_side.len()) { + let base_line = base_side.get(pair_index); + let target_line = target_side.get(pair_index); + let kept_base = base_line + .filter(|line| !is_selected(line)) + .and_then(|line| line.base_lineno(direction)) + .filter(|lineno| *lineno <= base.len()) + .map(|lineno| base[lineno - 1].as_str()); + let applied_target = + target_line + .filter(|line| is_selected(line)) + .and_then(|line| match line { + ScopedLine::Addition { text, .. } + | ScopedLine::Deletion { text, .. } => Some(text.as_str()), + ScopedLine::Context { .. } => None, + }); + + // Unified diffs list removals before insertions, so applying + // emits the kept base line first and reverting emits the + // restored line first. + let ordered = match direction { + WorktreePatchDirection::Apply => [kept_base, applied_target], + WorktreePatchDirection::Revert => [applied_target, kept_base], + }; + pieces.extend(ordered.into_iter().flatten()); + } + + if let Some(last) = base_side + .iter() + .filter_map(|line| line.base_lineno(direction)) + .max() + { + cursor = last + 1; + } + index = run_end; + } + } + + copy_base_through(&mut pieces, base, &mut cursor, base.len() + 1); + + // Only the last piece may lack a newline: any earlier one that arrives + // without one gains it, so the other side's unterminated final line landing + // mid-file cannot glue two lines together. + let count = pieces.len(); + let mut out = String::new(); + for (index, piece) in pieces.into_iter().enumerate() { + out.push_str(piece); + if !piece.ends_with('\n') && index + 1 < count { + out.push('\n'); + } + } + out +} + +/// The viewer's line selection, split into the two sides it targets. +/// +/// Additions are matched on their new-side line number and deletions on their +/// old-side one, kept apart so an addition and a deletion that happen to share +/// a number are never confused. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(crate) struct SelectedLines { + additions: HashSet, + deletions: HashSet, +} + +impl SelectedLines { + pub(crate) fn from_pairs(pairs: &[(Option, Option)]) -> Self { + let mut selected = Self::default(); + for (old, new) in pairs { + match (old, new) { + (None, Some(new)) => { + selected.additions.insert(*new); + } + (Some(old), None) => { + selected.deletions.insert(*old); + } + // A context pair carries both numbers and a `(None, None)` row + // carries neither; neither selects a change line. + _ => {} + } + } + selected + } + + fn contains_addition(&self, new_lineno: usize) -> bool { + self.additions.contains(&new_lineno) + } + + fn contains_deletion(&self, old_lineno: usize) -> bool { + self.deletions.contains(&old_lineno) + } + + fn is_empty(&self) -> bool { + self.additions.is_empty() && self.deletions.is_empty() + } +} + +// ── Repository-facing work ──────────────────────────────────────────────────── + +/// One file's diff between the source's two trees, plus the blob on each side. +struct FileDiffSides { + hunks: Vec>, + /// Blob on the diff's old side, `None` when the file was added. + old_blob: Option, + /// Blob on the diff's new side, `None` when the file was deleted. + new_blob: Option, + old_mode: FileMode, + new_mode: FileMode, +} + +fn is_regular_blob_mode(mode: FileMode) -> bool { + matches!( + mode, + FileMode::Blob | FileMode::BlobGroupWritable | FileMode::BlobExecutable + ) +} + +/// Read `file_path`'s diff out of `source` as hunks of [`ScopedLine`]s. +/// +/// Textual deltas use the viewer's default diff shape so their hunk indices line +/// up with `parse_multi_file_diff`. Type changes are kept intact only so an +/// unsupported symlink or submodule cannot masquerade as a plain deletion. +fn scoped_hunks( + repo: &Repository, + source: &WorktreePatchSource, + file_path: &Path, +) -> Result { + let (from_tree, to_tree) = source.trees(repo)?; + // Keep regular-file/symlink/submodule transitions in one delta. With + // libgit2's default split form, the first matching delta can look like a + // harmless deletion and hide the non-regular addition that follows it. + let mut options = git2::DiffOptions::new(); + options.include_typechange(true); + let diff = repo.diff_tree_to_tree(from_tree.as_ref(), to_tree.as_ref(), Some(&mut options))?; + + for (delta_index, delta) in diff.deltas().enumerate() { + let old_path = delta.old_file().path().map(Path::to_path_buf); + let new_path = delta.new_file().path().map(Path::to_path_buf); + if old_path.as_deref() != Some(file_path) && new_path.as_deref() != Some(file_path) { + continue; + } + + let old_blob = delta.old_file().id(); + let new_blob = delta.new_file().id(); + let old_blob = (!old_blob.is_zero()).then_some(old_blob); + let new_blob = (!new_blob.is_zero()).then_some(new_blob); + if old_blob.is_some() && !is_regular_blob_mode(delta.old_file().mode()) + || new_blob.is_some() && !is_regular_blob_mode(delta.new_file().mode()) + { + anyhow::bail!( + "Can't patch {} because one side is not a regular file. Symbolic links and submodules are not supported by working-tree content patches.", + file_path.display() + ); + } + // Validate both canonical sides before asking libgit2 for line hunks. + // Binary/invalid text must never be silently replacement-decoded. + blob_text(repo, old_blob, file_path)?; + blob_text(repo, new_blob, file_path)?; + let patch = git2::Patch::from_diff(&diff, delta_index)?.ok_or_else(|| { + anyhow::anyhow!( + "Can't patch {} because Git does not expose it as a textual diff.", + file_path.display() + ) + })?; + + let mut hunks = Vec::with_capacity(patch.num_hunks()); + for hunk_index in 0..patch.num_hunks() { + let mut lines = Vec::new(); + for line_index in 0..patch.num_lines_in_hunk(hunk_index)? { + let line = patch.line_in_hunk(hunk_index, line_index)?; + match line.origin() { + ' ' => { + if let (Some(old), Some(new)) = (line.old_lineno(), line.new_lineno()) { + lines.push(ScopedLine::Context { + old_lineno: old as usize, + new_lineno: new as usize, + }); + } + } + '+' => { + if let Some(new) = line.new_lineno() { + let text = std::str::from_utf8(line.content()) + .with_context(|| { + format!( + "Can't patch {} because its canonical Git content is not valid UTF-8.", + file_path.display() + ) + })? + .to_owned(); + lines.push(ScopedLine::Addition { + new_lineno: new as usize, + text, + }); + } + } + '-' => { + if let Some(old) = line.old_lineno() { + let text = std::str::from_utf8(line.content()) + .with_context(|| { + format!( + "Can't patch {} because its canonical Git content is not valid UTF-8.", + file_path.display() + ) + })? + .to_owned(); + lines.push(ScopedLine::Deletion { + old_lineno: old as usize, + text, + }); + } + } + // '=', '>' and '<' carry the "\ No newline at end of file" + // note, which annotates the preceding line rather than + // being a line of its own. The preceding line's content + // already lacks its terminator, so drop these. + _ => {} + } + } + hunks.push(lines); + } + + return Ok(FileDiffSides { + hunks, + old_blob, + new_blob, + old_mode: delta.old_file().mode(), + new_mode: delta.new_file().mode(), + }); + } + + anyhow::bail!( + "{} is unchanged between {}, so there is nothing to apply or revert. Pick a file that \ + differs between them.", + file_path.display(), + source.label() + ) +} + +fn blob_text(repo: &Repository, blob: Option, file_path: &Path) -> Result { + match blob { + None => Ok(String::new()), + Some(oid) => { + let blob = repo.find_blob(oid)?; + std::str::from_utf8(blob.content()) + .map(str::to_owned) + .with_context(|| { + format!( + "Can't patch {} because its canonical Git content is not valid UTF-8.", + file_path.display() + ) + }) + } + } +} + +fn validate_relative_file_path(path: &Path) -> Result<()> { + use std::path::Component; + + let structurally_invalid = path.as_os_str().is_empty() + || path + .components() + .any(|component| !matches!(component, Component::Normal(_))); + #[cfg(windows)] + let structurally_invalid = structurally_invalid + || path.components().any(|component| { + matches!( + component, + Component::Normal(value) + if value.to_str().is_some_and(|value| value.contains(':')) + ) + }); + if structurally_invalid { + anyhow::bail!( + "Refusing to modify '{}': the path must contain only normal components relative to the working tree, with no root or parent traversal.", + path.display() + ); + } + Ok(()) +} + +#[cfg(windows)] +fn is_reparse_point(metadata: &std::fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt as _; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +fn is_reparse_point(_metadata: &std::fs::Metadata) -> bool { + false +} + +fn is_link_or_reparse(metadata: &std::fs::Metadata) -> bool { + metadata.file_type().is_symlink() || is_reparse_point(metadata) +} + +/// Verify every existing parent without following a link or Windows reparse +/// point. Missing parents are optionally created one component at a time and +/// inspected immediately after creation. +fn validate_parent_chain(workdir: &Path, path: &Path, create_missing: bool) -> Result { + let mut current = workdir.to_path_buf(); + let Some(parent) = path.parent() else { + return Ok(true); + }; + + for component in parent.components() { + current.push(component.as_os_str()); + let metadata = match std::fs::symlink_metadata(¤t) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound && create_missing => { + match std::fs::create_dir(¤t) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => { + return Err(error) + .with_context(|| format!("Failed to create {}", current.display())); + } + } + std::fs::symlink_metadata(¤t) + .with_context(|| format!("Failed to inspect {}", current.display()))? + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(error) + .with_context(|| format!("Failed to inspect {}", current.display())); + } + }; + + if is_link_or_reparse(&metadata) { + anyhow::bail!( + "Refusing to modify '{}': parent '{}' is a symbolic link or reparse point.", + path.display(), + current.display() + ); + } + if !metadata.is_dir() { + anyhow::bail!( + "Refusing to modify '{}': parent '{}' is not a directory.", + path.display(), + current.display() + ); + } + } + + // A second, independent containment check catches paths whose parents were + // exchanged while they were being inspected. + if parent.as_os_str().is_empty() { + return Ok(true); + } + let resolved_parent = workdir + .join(parent) + .canonicalize() + .with_context(|| format!("Failed to resolve {}", workdir.join(parent).display()))?; + if !resolved_parent.starts_with(workdir) { + anyhow::bail!( + "Refusing to modify '{}': its parent resolves outside the working tree.", + path.display() + ); + } + Ok(true) +} + +fn permissions_from_metadata(metadata: &std::fs::Metadata) -> WorktreeFilePermissions { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + WorktreeFilePermissions { + readonly: metadata.permissions().readonly(), + mode: metadata.permissions().mode() & 0o7777, + } + } + #[cfg(not(unix))] + { + WorktreeFilePermissions { + readonly: metadata.permissions().readonly(), + } + } +} + +fn permissions_for_new_file(mode: FileMode) -> WorktreeFilePermissions { + #[cfg(unix)] + { + WorktreeFilePermissions { + readonly: false, + mode: match mode { + FileMode::BlobExecutable => 0o755, + FileMode::BlobGroupWritable => 0o664, + _ => 0o644, + }, + } + } + #[cfg(not(unix))] + { + let _ = mode; + WorktreeFilePermissions { readonly: false } + } +} + +fn apply_permissions(file: &std::fs::File, permissions: &WorktreeFilePermissions) -> Result<()> { + #[cfg(unix)] + let fs_permissions = { + use std::os::unix::fs::PermissionsExt as _; + std::fs::Permissions::from_mode(permissions.mode) + }; + #[cfg(not(unix))] + let fs_permissions = { + let mut value = file.metadata()?.permissions(); + value.set_readonly(permissions.readonly); + value + }; + file.set_permissions(fs_permissions) + .context("Failed to set permissions on the temporary working-tree file") +} + +/// Read a path only after lstat-style validation. `None` means the path or one +/// of its parents does not exist; links and non-regular files are errors. +fn read_worktree_file_state(workdir: &Path, path: &Path) -> Result> { + validate_relative_file_path(path)?; + if !validate_parent_chain(workdir, path, false)? { + return Ok(None); + } + let absolute = workdir.join(path); + let metadata = match std::fs::symlink_metadata(&absolute) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(error).with_context(|| format!("Failed to inspect {}", absolute.display())); + } + }; + if is_link_or_reparse(&metadata) { + anyhow::bail!( + "Refusing to modify '{}': it is a symbolic link or reparse point.", + path.display() + ); + } + if !metadata.is_file() { + anyhow::bail!( + "Refusing to modify '{}': it is not a regular file.", + path.display() + ); + } + + let contents = std::fs::read(&absolute) + .with_context(|| format!("Failed to read {}", absolute.display()))?; + let metadata_after = std::fs::symlink_metadata(&absolute) + .with_context(|| format!("Failed to revalidate {}", absolute.display()))?; + if is_link_or_reparse(&metadata_after) || !metadata_after.is_file() { + anyhow::bail!( + "Refusing to modify '{}': its filesystem type changed while it was being read.", + path.display() + ); + } + if metadata_after.len() != contents.len() as u64 { + anyhow::bail!( + "Refusing to modify '{}': it changed while it was being read. Try again after the other edit finishes.", + path.display() + ); + } + + Ok(Some(WorktreeFileState { + contents, + permissions: permissions_from_metadata(&metadata_after), + })) +} + +fn git_path_arg(path: &Path) -> OsString { + let mut argument = OsString::from("--path="); + argument.push(path.as_os_str()); + argument +} + +fn run_git_with_input( + workdir: &Path, + arguments: &[OsString], + input: &[u8], + description: &str, +) -> Result> { + // Feed stdin from a temporary file so a noisy required filter cannot + // deadlock us by filling Git's stderr pipe while this process is still + // synchronously writing a large input pipe. + let mut input_file = tempfile::tempfile() + .with_context(|| format!("Failed to prepare input for Git while {description}"))?; + input_file + .write_all(input) + .with_context(|| format!("Failed to prepare content for Git while {description}"))?; + input_file + .rewind() + .with_context(|| format!("Failed to rewind Git input while {description}"))?; + let output = Command::new("git") + .arg("-C") + .arg(workdir) + .args(arguments) + .stdin(Stdio::from(input_file)) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .with_context(|| format!("Failed to run Git while {description}"))?; + if !output.status.success() { + let details = String::from_utf8(output.stderr) + .unwrap_or_else(|_| "Git returned non-UTF-8 error output".to_string()); + anyhow::bail!("Git failed while {description}: {}", details.trim()); + } + Ok(output.stdout) +} + +fn git_object_bytes(workdir: &Path, oid: &str, description: &str) -> Result> { + run_git_with_input( + workdir, + &[ + OsString::from("cat-file"), + OsString::from("blob"), + OsString::from(oid), + ], + &[], + description, + ) +} + +/// Convert exact worktree bytes into canonical Git bytes, honoring text/EOL, +/// working-tree-encoding, clean filters, and LFS. +pub(super) fn clean_worktree_bytes(workdir: &Path, path: &Path, bytes: &[u8]) -> Result> { + let oid_output = run_git_with_input( + workdir, + &[ + OsString::from("hash-object"), + OsString::from("-w"), + OsString::from("--stdin"), + git_path_arg(path), + ], + bytes, + &format!("cleaning {} through Git attributes", path.display()), + )?; + let oid = std::str::from_utf8(&oid_output) + .context("Git returned a non-UTF-8 object ID while cleaning the working-tree file")? + .trim(); + git_object_bytes( + workdir, + oid, + &format!("reading the cleaned content for {}", path.display()), + ) +} + +/// Convert canonical Git bytes into exact worktree bytes, honoring smudge +/// filters, working-tree-encoding and configured line endings. +pub(super) fn smudge_canonical_bytes(workdir: &Path, path: &Path, bytes: &[u8]) -> Result> { + let oid_output = run_git_with_input( + workdir, + &[ + OsString::from("hash-object"), + OsString::from("-w"), + OsString::from("--stdin"), + ], + bytes, + &format!("storing the merged content for {}", path.display()), + )?; + let oid = std::str::from_utf8(&oid_output) + .context("Git returned a non-UTF-8 object ID while preparing working-tree content")? + .trim(); + run_git_with_input( + workdir, + &[ + OsString::from("cat-file"), + OsString::from("--filters"), + git_path_arg(path), + OsString::from(oid), + ], + &[], + &format!("smudging {} through Git attributes", path.display()), + ) +} + +fn changed_during_operation(path: &Path, purpose: &str) -> anyhow::Error { + anyhow::anyhow!( + "Can't {purpose} '{}': it changed after the operation started. No file was overwritten; retry after the other edit finishes.", + path.display() + ) +} + +/// Atomically replace or delete one path only if its exact current bytes and +/// permissions still match `expected`. +fn replace_file_if_unchanged( + workdir: &Path, + path: &Path, + expected: &Option, + desired: &Option, + purpose: &str, +) -> Result<()> { + if &read_worktree_file_state(workdir, path)? != expected { + return Err(changed_during_operation(path, purpose)); + } + + let absolute = workdir.join(path); + match desired { + Some(desired) => { + validate_parent_chain(workdir, path, true)?; + let parent = absolute + .parent() + .context("A validated working-tree path had no parent directory")?; + let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| { + format!("Failed to create a temporary file in {}", parent.display()) + })?; + temporary.write_all(&desired.contents).with_context(|| { + format!("Failed to write a temporary copy of {}", path.display()) + })?; + temporary.as_file().sync_all().with_context(|| { + format!("Failed to flush a temporary copy of {}", path.display()) + })?; + apply_permissions(temporary.as_file(), &desired.permissions)?; + + // Revalidate immediately before the rename. Renaming replaces a + // final symlink rather than following it, while parent validation + // protects the directory traversal itself. + validate_parent_chain(workdir, path, false)?; + if &read_worktree_file_state(workdir, path)? != expected { + return Err(changed_during_operation(path, purpose)); + } + temporary + .persist(&absolute) + .map_err(|error| error.error) + .with_context(|| format!("Failed to atomically replace {}", absolute.display()))?; + } + None => { + validate_parent_chain(workdir, path, false)?; + if &read_worktree_file_state(workdir, path)? != expected { + return Err(changed_during_operation(path, purpose)); + } + std::fs::remove_file(&absolute) + .with_context(|| format!("Failed to delete {}", absolute.display()))?; + } + } + + if &read_worktree_file_state(workdir, path)? != desired { + anyhow::bail!( + "The filesystem changed '{}' while the atomic update was being published. Check the file before retrying.", + path.display() + ); + } + Ok(()) +} + +/// Apply or revert part of `source`'s diff for one file in `worktree_path`. +/// +/// Returns the pre-operation snapshot so the caller can offer undo. Every error +/// is a sentence naming the file, the reason and what to do next. +pub fn apply_worktree_patch( + worktree_path: &Path, + file_path: &Path, + source: &WorktreePatchSource, + scope: &WorktreePatchScope, + direction: WorktreePatchDirection, +) -> Result { + let repo = Repository::open(worktree_path).with_context(|| { + format!( + "Failed to open the repository at {}", + worktree_path.display() + ) + })?; + let workdir = repo + .workdir() + .ok_or_else(|| { + anyhow::anyhow!( + "This is a bare repository, so there is no working tree to {} into.", + direction.verb() + ) + })? + .canonicalize() + .context("Failed to resolve the working-tree root")?; + + validate_relative_file_path(file_path)?; + + let FileDiffSides { + hunks, + old_blob, + new_blob, + old_mode, + new_mode, + } = scoped_hunks(&repo, source, file_path)?; + + let selected_hunks = match scope { + WorktreePatchScope::File | WorktreePatchScope::Lines(_) => None, + WorktreePatchScope::Hunk(index) => { + if *index >= hunks.len() { + anyhow::bail!( + "Hunk {} is no longer part of {}'s diff — reselect the hunk and try again.", + index + 1, + file_path.display() + ); + } + Some(HashSet::from([*index])) + } + }; + let selected_lines = match scope { + WorktreePatchScope::File | WorktreePatchScope::Hunk(_) => None, + WorktreePatchScope::Lines(pairs) => { + let selected = SelectedLines::from_pairs(pairs); + if selected.is_empty() { + anyhow::bail!( + "No added or removed lines are selected, so there is nothing to {}. Select \ + the lines you want first.", + direction.verb() + ); + } + Some(selected) + } + }; + + let (base_blob, target_side_blob, target_mode) = match direction { + WorktreePatchDirection::Apply => (old_blob, new_blob, new_mode), + WorktreePatchDirection::Revert => (new_blob, old_blob, old_mode), + }; + + let base_text = blob_text(&repo, base_blob, file_path)?; + let base_lines = split_keeping_terminators(&base_text); + let target_text = rewrite_side( + &base_lines, + &hunks, + direction, + selected_hunks.as_ref(), + selected_lines.as_ref(), + ); + + if target_text == base_text { + anyhow::bail!( + "None of the selected lines are part of {}'s diff against {} — reselect them and \ + try again.", + file_path.display(), + source.label() + ); + } + + // The whole file moving to a side that does not have it is a deletion, not + // an empty file. Only the file-level scope can express that. + let deletes_file = matches!(scope, WorktreePatchScope::File) + && target_side_blob.is_none() + && target_text.is_empty(); + + let _mutation_guard = WORKTREE_MUTATION_LOCK.lock(); + let existing = read_worktree_file_state(&workdir, file_path)?; + let existing_canonical = match &existing { + Some(state) => Some(clean_worktree_bytes(&workdir, file_path, &state.contents)?), + None => None, + }; + let existing_text = existing_canonical + .as_deref() + .map(|bytes| { + std::str::from_utf8(bytes).map(str::to_owned).with_context(|| { + format!( + "Can't {} {} because its canonical working-tree content is not valid UTF-8. No file was changed.", + direction.verb(), + file_path.display() + ) + }) + }) + .transpose()?; + + if deletes_file { + let Some(existing_text) = existing_text.as_deref() else { + anyhow::bail!( + "{} is already absent from the working tree, so there is nothing to {}.", + file_path.display(), + direction.verb() + ); + }; + if existing_text != base_text { + anyhow::bail!( + "Can't {} the deletion of {}: the file has uncommitted edits that would be lost. \ + Commit or stash them, then try again.", + direction.verb(), + file_path.display() + ); + } + replace_file_if_unchanged(&workdir, file_path, &existing, &None, direction.verb())?; + return Ok(WorktreePatchOutcome { + snapshots: vec![WorktreeFileSnapshot { + path: file_path.to_path_buf(), + before: existing, + expected_after: None, + }], + merged_with_local_changes: false, + }); + } + + let ours_text = existing_text.clone().unwrap_or_default(); + + // Clean case: the working tree still matches the side the patch starts + // from, so the rewrite is exact and no merge is needed. + let merged_with_local_changes = ours_text != base_text; + let merged_text = if merged_with_local_changes { + match three_way_merge(&repo, file_path, &base_text, &ours_text, &target_text)? { + Some(merged) => merged, + None => { + anyhow::bail!(conflict_message(&repo, file_path, scope, source, direction)); + } + } + } else { + target_text + }; + + // The merge folding back to what is already on disk means the selected + // change is already present (or already gone). Reporting that beats writing + // the same bytes and claiming to have done something — and it is the general + // test, since a scope covering part of a file leaves the rest of `target` + // disagreeing with the working tree for reasons that are none of its + // business. + if merged_text == ours_text { + anyhow::bail!(already_applied_message(file_path, scope, source, direction)); + } + + let worktree_bytes = smudge_canonical_bytes(&workdir, file_path, merged_text.as_bytes())?; + let after = Some(WorktreeFileState { + contents: worktree_bytes, + permissions: existing + .as_ref() + .map(|state| state.permissions.clone()) + .unwrap_or_else(|| permissions_for_new_file(target_mode)), + }); + replace_file_if_unchanged(&workdir, file_path, &existing, &after, direction.verb())?; + + Ok(WorktreePatchOutcome { + snapshots: vec![WorktreeFileSnapshot { + path: file_path.to_path_buf(), + before: existing, + expected_after: after, + }], + merged_with_local_changes, + }) +} + +/// Restore files to the contents captured before an apply or revert. +pub fn restore_worktree_files( + worktree_path: &Path, + snapshots: &[WorktreeFileSnapshot], +) -> Result<()> { + let repo = Repository::open(worktree_path).with_context(|| { + format!( + "Failed to open the repository at {}", + worktree_path.display() + ) + })?; + let workdir = repo + .workdir() + .ok_or_else(|| anyhow::anyhow!("This is a bare repository, so it has no working tree."))? + .canonicalize() + .context("Failed to resolve the working-tree root")?; + + let _mutation_guard = WORKTREE_MUTATION_LOCK.lock(); + + // Validate every CAS before making the first mutation. A later external + // edit is checked again immediately before each atomic replace. + for snapshot in snapshots { + validate_relative_file_path(&snapshot.path)?; + if read_worktree_file_state(&workdir, &snapshot.path)? != snapshot.expected_after { + anyhow::bail!( + "Can't undo the change to '{}': the file was edited after the operation. Those later edits were left untouched.", + snapshot.path.display() + ); + } + } + + for snapshot in snapshots { + replace_file_if_unchanged( + &workdir, + &snapshot.path, + &snapshot.expected_after, + &snapshot.before, + "undo the change to", + )?; + } + + Ok(()) +} + +/// Three-way merge three versions of one file's text. +/// +/// Returns `None` when the merge would need conflict markers. libgit2's file +/// merge works on blobs, so the three versions are written to the object +/// database first; they are unreferenced and collected by the next `git gc`. +fn three_way_merge( + repo: &Repository, + file_path: &Path, + ancestor: &str, + ours: &str, + theirs: &str, +) -> Result> { + let path_bytes = file_path + .to_str() + .context("Working-tree patch paths must be valid UTF-8")? + .replace('\\', "/") + .into_bytes(); + let entry = |text: &str| -> Result { + Ok(IndexEntry { + ctime: IndexTime::new(0, 0), + mtime: IndexTime::new(0, 0), + dev: 0, + ino: 0, + mode: 0o100644, + uid: 0, + gid: 0, + file_size: text.len() as u32, + id: repo.blob(text.as_bytes())?, + flags: 0, + flags_extended: 0, + path: path_bytes.clone(), + }) + }; + + // Default (non-diff3) conflict style: we never keep a conflicted result, so + // the markers only ever feed `is_automergeable()`. + let mut options = git2::MergeFileOptions::new(); + options.style_standard(true); + let result = repo.merge_file_from_index( + &entry(ancestor)?, + &entry(ours)?, + &entry(theirs)?, + Some(&mut options), + )?; + + if !result.is_automergeable() { + return Ok(None); + } + Ok(Some( + std::str::from_utf8(result.content()) + .with_context(|| { + format!( + "Git produced invalid UTF-8 while merging {}. No working-tree file was changed.", + file_path.display() + ) + })? + .to_owned(), + )) +} + +// ── Error messages ──────────────────────────────────────────────────────────── + +fn already_applied_message( + file_path: &Path, + scope: &WorktreePatchScope, + source: &WorktreePatchSource, + direction: WorktreePatchDirection, +) -> String { + match direction { + WorktreePatchDirection::Apply => format!( + "{} already matches {} for {} — there is nothing to apply. Press r to revert it \ + instead.", + file_path.display(), + source.label(), + scope.describe() + ), + WorktreePatchDirection::Revert => format!( + "{}'s {} from {} is already absent from the working tree — there is nothing to \ + revert. Press a to apply it instead.", + file_path.display(), + scope.describe(), + source.label() + ), + } +} + +fn conflict_message( + repo: &Repository, + file_path: &Path, + scope: &WorktreePatchScope, + source: &WorktreePatchSource, + direction: WorktreePatchDirection, +) -> String { + let dirty = repo + .status_file(file_path) + .map(|status| { + status.intersects( + git2::Status::WT_MODIFIED + | git2::Status::WT_NEW + | git2::Status::WT_TYPECHANGE + | git2::Status::INDEX_MODIFIED + | git2::Status::INDEX_NEW, + ) + }) + .unwrap_or(false); + + if dirty { + // A conflicting local edit: the user has uncommitted work in the same + // place. Naming the file tells them exactly what to park. + format!( + "Can't {} {} of {}: your uncommitted edits to that file overlap it. Commit or stash \ + {}, then try again.", + direction.verb(), + scope.describe(), + file_path.display(), + file_path.display() + ) + } else { + // A context mismatch: the file is clean, so the patch does not fit + // because the file itself has moved on since that revision. + format!( + "Can't {} {} of {}: the surrounding lines have changed since {}, so the patch no \ + longer fits cleanly. Select a closer revision or make the change manually.", + direction.verb(), + scope.describe(), + file_path.display(), + source.label() + ) + } +} + +// ── GitProject operations ───────────────────────────────────────────────────── + +use gpui::{AsyncApp, Context, Task, WeakEntity}; + +use super::refresh::gather_refresh_data_lightweight_cached; +use super::{GitProject, GitProjectEvent, RefreshData}; +use crate::types::GitOperationKind; + +fn run_mutation_then_refresh( + mutation: impl FnOnce() -> Result, + refresh: impl FnOnce() -> Result, +) -> Result<(M, Result)> { + let mutation = mutation()?; + Ok((mutation, refresh())) +} + +impl GitProject { + /// Apply or revert part of another revision's diff in `worktree_path`. + /// + /// The heavy work — resolving trees, rewriting lines, merging and writing — + /// happens on the background executor; only the refresh snapshot and the + /// events come back to the UI thread. + #[allow(clippy::too_many_arguments)] + pub fn patch_worktree_at( + &mut self, + file_path: &Path, + source: WorktreePatchSource, + scope: WorktreePatchScope, + direction: WorktreePatchDirection, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + let file_path = file_path.to_path_buf(); + let task_file_path = file_path.clone(); + let task_worktree_path = worktree_path.to_path_buf(); + let origin_worktree_path = task_worktree_path.clone(); + let origin_repo_path = self.repo_path.clone(); + let refresh_repo_path = self.repo_path.clone(); + let worktree_cache = self.worktree_status_cache.clone(); + let author_filter = self.commit_author_filter.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let kind = match direction { + WorktreePatchDirection::Apply => GitOperationKind::ApplyToWorktree, + WorktreePatchDirection::Revert => GitOperationKind::RevertInWorktree, + }; + let scope_text = scope.describe(); + let source_label = source.label(); + let operation_id = self.begin_operation( + kind, + format!( + "{} {} of {} from {}...", + direction.present_participle(), + scope_text, + file_path.display(), + source_label + ), + None, + branch_name.clone(), + cx, + ); + let task_source = source.clone(); + let task_scope = scope.clone(); + + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result<(WorktreePatchOutcome, anyhow::Result)> = cx + .background_executor() + .spawn(async move { + run_mutation_then_refresh( + || { + apply_worktree_patch( + &task_worktree_path, + &task_file_path, + &task_source, + &task_scope, + direction, + ) + }, + || { + gather_refresh_data_lightweight_cached( + &refresh_repo_path, + commit_limit, + &worktree_cache, + author_filter.as_deref(), + ) + }, + ) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok((outcome, refresh_result)) => { + let mut summary = format!( + "{} {} of {} from {}", + direction.past_tense(), + scope_text, + file_path.display(), + source_label + ); + if outcome.merged_with_local_changes { + summary.push_str( + " — merged around your uncommitted edits to that file", + ); + } + let undoable = snapshots_fit_undo_stack(&outcome.snapshots); + if !undoable { + summary.push_str( + "; Undo is unavailable because the before/after snapshot exceeds 4 MiB", + ); + } + let refresh_succeeded = match refresh_result { + Ok(data) => { + this.apply_refresh_data(data); + true + } + Err(error) => { + log::warn!( + "worktree patch succeeded but status refresh failed: {error:#}" + ); + summary.push_str( + "; the file changed successfully, but status refresh failed; refresh the repository to update the UI", + ); + false + } + }; + this.complete_op( + operation_id, + kind, + summary.clone(), + (None, None, branch_name.clone()), + cx, + ); + if undoable { + cx.emit(GitProjectEvent::WorktreePatchApplied { + label: summary, + snapshots: outcome.snapshots, + repo_path: origin_repo_path.clone(), + worktree_path: origin_worktree_path.clone(), + }); + } + if refresh_succeeded { + cx.emit(GitProjectEvent::StatusChanged); + } + } + Err(e) => { + this.fail_op( + operation_id, + kind, + format!("{} failed", kind.display_name()), + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } + + /// Restore working-tree files to a snapshot taken before an apply or revert. + pub fn restore_worktree_files_at( + &mut self, + snapshots: Vec, + worktree_path: &Path, + cx: &mut Context, + ) -> Task> { + let task_worktree_path = worktree_path.to_path_buf(); + let refresh_repo_path = self.repo_path.clone(); + let worktree_cache = self.worktree_status_cache.clone(); + let author_filter = self.commit_author_filter.clone(); + let commit_limit = self.commit_limit; + let branch_name = self.head_branch.clone(); + let file_count = snapshots.len(); + let operation_id = self.begin_operation( + GitOperationKind::Discard, + format!( + "Restoring {} file{}...", + file_count, + if file_count == 1 { "" } else { "s" } + ), + None, + branch_name.clone(), + cx, + ); + + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let result: anyhow::Result<((), anyhow::Result)> = cx + .background_executor() + .spawn(async move { + run_mutation_then_refresh( + || restore_worktree_files(&task_worktree_path, &snapshots), + || { + gather_refresh_data_lightweight_cached( + &refresh_repo_path, + commit_limit, + &worktree_cache, + author_filter.as_deref(), + ) + }, + ) + }) + .await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + match result { + Ok(((), refresh_result)) => { + let (summary, refresh_succeeded) = match refresh_result { + Ok(data) => { + this.apply_refresh_data(data); + ( + format!( + "Restored {} file{}", + file_count, + if file_count == 1 { "" } else { "s" } + ), + true, + ) + } + Err(error) => { + log::warn!( + "worktree restore succeeded but status refresh failed: {error:#}" + ); + ( + format!( + "Restored {} file{}; status refresh failed, so refresh the repository to update the UI", + file_count, + if file_count == 1 { "" } else { "s" } + ), + false, + ) + } + }; + this.complete_op( + operation_id, + GitOperationKind::Discard, + summary, + (None, None, branch_name.clone()), + cx, + ); + if refresh_succeeded { + cx.emit(GitProjectEvent::StatusChanged); + } + } + Err(e) => { + this.fail_op( + operation_id, + GitOperationKind::Discard, + "Restore failed", + e.to_string(), + (None, branch_name.clone(), false), + cx, + ); + } + } + cx.notify(); + Ok(()) + }) + })? + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn state(contents: Vec) -> WorktreeFileState { + WorktreeFileState { + contents, + permissions: WorktreeFilePermissions { + readonly: false, + #[cfg(unix)] + mode: 0o644, + }, + } + } + + fn context(old: usize, new: usize) -> ScopedLine { + ScopedLine::Context { + old_lineno: old, + new_lineno: new, + } + } + + fn addition(new: usize, text: &str) -> ScopedLine { + ScopedLine::Addition { + new_lineno: new, + text: text.to_string(), + } + } + + fn deletion(old: usize, text: &str) -> ScopedLine { + ScopedLine::Deletion { + old_lineno: old, + text: text.to_string(), + } + } + + // ── split_keeping_terminators ───────────────────────────────── + + #[test] + fn split_keeps_line_terminators() { + assert_eq!(split_keeping_terminators("a\nb\n"), vec!["a\n", "b\n"]); + } + + #[test] + fn split_keeps_a_final_line_without_a_newline() { + assert_eq!(split_keeping_terminators("a\nb"), vec!["a\n", "b"]); + } + + #[test] + fn split_of_empty_text_has_no_lines() { + assert!(split_keeping_terminators("").is_empty()); + } + + // ── rewrite_side ────────────────────────────────────────────── + + fn rewrite( + base: &str, + hunks: &[Vec], + direction: WorktreePatchDirection, + selected_hunks: Option<&HashSet>, + selected_lines: Option<&SelectedLines>, + ) -> String { + rewrite_side( + &split_keeping_terminators(base), + hunks, + direction, + selected_hunks, + selected_lines, + ) + } + + /// `b` → `B2` as one hunk of a three-line file. + fn substitution_hunk() -> Vec> { + vec![vec![ + context(1, 1), + deletion(2, "b\n"), + addition(2, "B2\n"), + context(3, 3), + ]] + } + + #[test] + fn applying_a_substitution_replaces_the_base_line() { + let hunks = substitution_hunk(); + let out = rewrite( + "a\nb\nc\n", + &hunks, + WorktreePatchDirection::Apply, + None, + None, + ); + assert_eq!(out, "a\nB2\nc\n"); + } + + #[test] + fn reverting_a_substitution_restores_the_old_line() { + let hunks = substitution_hunk(); + let out = rewrite( + "a\nB2\nc\n", + &hunks, + WorktreePatchDirection::Revert, + None, + None, + ); + assert_eq!(out, "a\nb\nc\n"); + } + + #[test] + fn apply_then_revert_round_trips() { + let hunks = substitution_hunk(); + let original = "a\nb\nc\n"; + let applied = rewrite(original, &hunks, WorktreePatchDirection::Apply, None, None); + let reverted = rewrite(&applied, &hunks, WorktreePatchDirection::Revert, None, None); + assert_eq!(reverted, original); + } + + #[test] + fn a_hunk_scope_leaves_other_hunks_alone() { + // Two independent substitutions, far enough apart to be separate hunks. + let hunks = vec![ + vec![ + context(1, 1), + deletion(2, "b\n"), + addition(2, "B2\n"), + context(3, 3), + ], + vec![ + context(5, 5), + deletion(6, "f\n"), + addition(6, "F2\n"), + context(7, 7), + ], + ]; + let only_second = HashSet::from([1usize]); + let out = rewrite( + "a\nb\nc\nd\ne\nf\ng\n", + &hunks, + WorktreePatchDirection::Apply, + Some(&only_second), + None, + ); + assert_eq!(out, "a\nb\nc\nd\ne\nF2\ng\n"); + } + + /// Two adjacent substitutions with no context between them, which is how + /// git emits them: both removals, then both insertions. + fn adjacent_substitutions() -> Vec> { + vec![vec![ + context(1, 1), + deletion(2, "b\n"), + deletion(3, "c\n"), + addition(2, "B2\n"), + addition(3, "C2\n"), + context(4, 4), + ]] + } + + #[test] + fn a_line_scope_rewrites_only_the_selected_lines() { + let hunks = adjacent_substitutions(); + // The second substitution: old line 3 removed, new line 3 added. + let selected = SelectedLines::from_pairs(&[(Some(3), None), (None, Some(3))]); + let out = rewrite( + "a\nb\nc\nd\n", + &hunks, + WorktreePatchDirection::Apply, + None, + Some(&selected), + ); + assert_eq!(out, "a\nb\nC2\nd\n"); + } + + #[test] + fn a_line_scope_keeps_an_unselected_line_in_its_own_place() { + // Selecting the *first* substitution of a run is where reading the + // unified rows in order goes wrong: it would emit the kept `c` before + // the applied `B2`. + let hunks = adjacent_substitutions(); + let selected = SelectedLines::from_pairs(&[(Some(2), None), (None, Some(2))]); + let out = rewrite( + "a\nb\nc\nd\n", + &hunks, + WorktreePatchDirection::Apply, + None, + Some(&selected), + ); + assert_eq!(out, "a\nB2\nc\nd\n"); + } + + #[test] + fn reverting_the_first_of_two_adjacent_substitutions_keeps_the_order() { + let hunks = adjacent_substitutions(); + let selected = SelectedLines::from_pairs(&[(Some(2), None), (None, Some(2))]); + let out = rewrite( + "a\nB2\nC2\nd\n", + &hunks, + WorktreePatchDirection::Revert, + None, + Some(&selected), + ); + assert_eq!(out, "a\nb\nC2\nd\n"); + } + + #[test] + fn selecting_only_an_addition_leaves_the_deletion_in_place() { + let hunks = substitution_hunk(); + let selected = SelectedLines::from_pairs(&[(None, Some(2))]); + let out = rewrite( + "a\nb\nc\n", + &hunks, + WorktreePatchDirection::Apply, + None, + Some(&selected), + ); + assert_eq!(out, "a\nb\nB2\nc\n"); + } + + #[test] + fn selecting_only_a_deletion_drops_the_line() { + let hunks = substitution_hunk(); + let selected = SelectedLines::from_pairs(&[(Some(2), None)]); + let out = rewrite( + "a\nb\nc\n", + &hunks, + WorktreePatchDirection::Apply, + None, + Some(&selected), + ); + assert_eq!(out, "a\nc\n"); + } + + #[test] + fn selection_pairs_ignore_context_and_empty_rows() { + let selected = SelectedLines::from_pairs(&[(Some(1), Some(1)), (None, None)]); + assert!(selected.is_empty()); + } + + #[test] + fn pure_insertion_at_the_top_of_a_file_lands_first() { + let hunks = vec![vec![addition(1, "new\n"), context(1, 2)]]; + let out = rewrite("a\n", &hunks, WorktreePatchDirection::Apply, None, None); + assert_eq!(out, "new\na\n"); + } + + #[test] + fn insertion_without_a_trailing_newline_gains_one_when_lines_follow() { + // The other side's last line becoming a middle line must not glue two + // lines together. + let hunks = vec![vec![context(1, 1), addition(2, "tail"), context(2, 3)]]; + let out = rewrite("a\nb\n", &hunks, WorktreePatchDirection::Apply, None, None); + assert_eq!(out, "a\ntail\nb\n"); + } + + #[test] + fn a_final_line_keeps_its_missing_newline() { + let hunks = vec![vec![context(1, 1), deletion(2, "b"), addition(2, "B2")]]; + let out = rewrite("a\nb", &hunks, WorktreePatchDirection::Apply, None, None); + assert_eq!(out, "a\nB2"); + } + + #[test] + fn deleting_every_line_yields_empty_text() { + let hunks = vec![vec![deletion(1, "a\n"), deletion(2, "b\n")]]; + let out = rewrite("a\nb\n", &hunks, WorktreePatchDirection::Apply, None, None); + assert_eq!(out, ""); + } + + #[test] + fn an_unselected_hunk_contributes_its_base_lines_verbatim() { + let hunks = substitution_hunk(); + let nothing = HashSet::new(); + let out = rewrite( + "a\nb\nc\n", + &hunks, + WorktreePatchDirection::Apply, + Some(¬hing), + None, + ); + assert_eq!(out, "a\nb\nc\n"); + } + + // ── snapshot cap ────────────────────────────────────────────── + + #[test] + fn small_snapshots_fit_the_undo_stack() { + let snapshots = vec![WorktreeFileSnapshot { + path: PathBuf::from("a.txt"), + before: Some(state(vec![0; 1024])), + expected_after: None, + }]; + assert!(snapshots_fit_undo_stack(&snapshots)); + } + + #[test] + fn oversized_snapshots_do_not_fit_the_undo_stack() { + let snapshots = vec![WorktreeFileSnapshot { + path: PathBuf::from("a.txt"), + before: Some(state(vec![0; MAX_UNDO_SNAPSHOT_BYTES + 1])), + expected_after: None, + }]; + assert!(!snapshots_fit_undo_stack(&snapshots)); + } + + #[test] + fn a_deleted_file_snapshot_costs_nothing() { + let snapshots = vec![WorktreeFileSnapshot { + path: PathBuf::from("a.txt"), + before: None, + expected_after: None, + }]; + assert!(snapshots_fit_undo_stack(&snapshots)); + } + + // ── labels ──────────────────────────────────────────────────── + + #[test] + fn scope_descriptions_read_as_prose() { + assert_eq!(WorktreePatchScope::File.describe(), "all changes"); + assert_eq!(WorktreePatchScope::Hunk(0).describe(), "hunk 1"); + assert_eq!( + WorktreePatchScope::Lines(vec![(None, Some(1))]).describe(), + "1 line" + ); + assert_eq!( + WorktreePatchScope::Lines(vec![(None, Some(1)), (Some(2), None)]).describe(), + "2 lines" + ); + } + + #[test] + fn a_commit_source_is_labelled_by_its_short_oid() { + let oid = Oid::from_str("1234567890abcdef1234567890abcdef12345678").unwrap(); + assert_eq!(WorktreePatchSource::Commit(oid).label(), "1234567"); + } + + #[test] + fn a_compare_source_is_labelled_by_both_endpoints() { + let source = WorktreePatchSource::Compare { + from: "main".to_string(), + to: "feature".to_string(), + }; + assert_eq!(source.label(), "main...feature"); + } + + #[test] + fn direction_inverts() { + assert_eq!( + WorktreePatchDirection::Apply.inverse(), + WorktreePatchDirection::Revert + ); + assert_eq!( + WorktreePatchDirection::Revert.inverse(), + WorktreePatchDirection::Apply + ); + } + + #[test] + fn a_refresh_failure_does_not_turn_a_successful_mutation_into_a_failure() { + let result = run_mutation_then_refresh( + || Ok::<_, anyhow::Error>("mutated"), + || Err::<(), _>(anyhow::anyhow!("refresh unavailable")), + ) + .expect("the mutation itself succeeded"); + + assert_eq!(result.0, "mutated"); + assert!(result.1.is_err()); + } + + #[test] + fn a_mutation_failure_prevents_the_refresh_from_running() { + let refresh_ran = std::cell::Cell::new(false); + let result = run_mutation_then_refresh( + || Err::<(), _>(anyhow::anyhow!("mutation failed")), + || { + refresh_ran.set(true); + Ok::<_, anyhow::Error>(()) + }, + ); + + assert!(result.is_err()); + assert!(!refresh_ran.get()); + } +} + +// ── Integration tests against real repositories ─────────────────────────────── +// +// Every assertion here reads the file back off disk. A test that only checked +// `is_ok()` would pass for a patch applied in the wrong direction, which is the +// one mistake this feature cannot afford. +#[cfg(test)] +mod worktree_patch_integration_tests { + use super::*; + use rgitui_test_support::TempRepo; + use tempfile::TempDir; + + struct Fixture { + repo: TempRepo, + path: PathBuf, + } + + impl Fixture { + fn new() -> Self { + let repo = TempRepo::init(); + let path = repo.path().to_path_buf(); + Self { repo, path } + } + + fn write(&self, name: &str, contents: &str) { + self.repo.write_file(name, contents); + } + + fn write_bytes(&self, name: &str, contents: &[u8]) { + self.repo.write_file_bytes(name, contents); + } + + fn read(&self, name: &str) -> String { + std::fs::read_to_string(self.path.join(name)).unwrap() + } + + fn commit(&self, message: &str, files: &[&str]) -> Oid { + for file in files { + self.repo.stage(file); + } + self.repo.commit(message) + } + + fn commit_symlink(&self, message: &str, name: &str, target: &Path) -> Oid { + let target = target.to_string_lossy(); + let blob = self.repo.repo().blob(target.as_bytes()).unwrap(); + let mut index = self.repo.repo().index().unwrap(); + index + .add(&IndexEntry { + ctime: IndexTime::new(0, 0), + mtime: IndexTime::new(0, 0), + dev: 0, + ino: 0, + mode: 0o120000, + uid: 0, + gid: 0, + file_size: target.len() as u32, + id: blob, + flags: 0, + flags_extended: 0, + path: name.as_bytes().to_vec(), + }) + .unwrap(); + index.write().unwrap(); + let tree = index.write_tree().unwrap(); + assert_eq!( + self.repo + .repo() + .find_tree(tree) + .unwrap() + .get_path(Path::new(name)) + .unwrap() + .filemode(), + 0o120000 + ); + self.repo + .commit_tree(Some("HEAD"), message, tree, &[self.repo.head_oid()]) + } + + fn branch(&self, name: &str) { + self.repo.branch(name); + } + + fn checkout(&self, name: &str) { + let reference = format!("refs/heads/{name}"); + let object = self.repo.repo().revparse_single(&reference).unwrap(); + self.repo.repo().checkout_tree(&object, None).unwrap(); + self.repo.repo().set_head(&reference).unwrap(); + } + + fn head_branch_name(&self) -> String { + self.repo + .repo() + .head() + .unwrap() + .shorthand() + .unwrap() + .to_string() + } + + fn set_config(&self, name: &str, value: &str) { + self.repo + .repo() + .config() + .unwrap() + .set_str(name, value) + .unwrap(); + } + } + + /// Numbered lines `l1..l20`, with `substitutions` replacing the given + /// 1-based line numbers. + fn numbered_lines(substitutions: &[(usize, &str)]) -> String { + (1..=20) + .map( + |lineno| match substitutions.iter().find(|(at, _)| *at == lineno) { + Some((_, text)) => format!("{text}\n"), + None => format!("l{lineno}\n"), + }, + ) + .collect() + } + + /// A repo whose HEAD commit changes lines 2 and 15 of a 20-line `f.txt`. + /// The two edits are more than twice the default context apart, so the diff + /// against the parent really has two hunks rather than one merged one. + fn two_hunk_commit() -> (Fixture, Oid) { + let fixture = Fixture::new(); + fixture.write("f.txt", &numbered_lines(&[])); + fixture.commit("base", &["f.txt"]); + fixture.write("f.txt", &numbered_lines(&[(2, "L2X"), (15, "L15X")])); + let oid = fixture.commit("two edits", &["f.txt"]); + // Leave the working tree matching the commit's parent so an apply has + // somewhere to land. + fixture.write("f.txt", &numbered_lines(&[])); + (fixture, oid) + } + + fn apply( + fixture: &Fixture, + file: &str, + source: &WorktreePatchSource, + scope: WorktreePatchScope, + direction: WorktreePatchDirection, + ) -> Result { + apply_worktree_patch(&fixture.path, Path::new(file), source, &scope, direction) + } + + // ── hunk apply / revert on a clean tree ─────────────────────── + + #[test] + fn applying_a_commits_hunk_writes_only_that_hunk_to_disk() { + let (fixture, oid) = two_hunk_commit(); + apply( + &fixture, + "f.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::Hunk(0), + WorktreePatchDirection::Apply, + ) + .unwrap(); + assert_eq!( + fixture.read("f.txt"), + numbered_lines(&[(2, "L2X")]), + "hunk 0 changes line 2 and must leave line 15 alone" + ); + } + + #[test] + fn applying_the_second_hunk_targets_the_second_change() { + let (fixture, oid) = two_hunk_commit(); + apply( + &fixture, + "f.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::Hunk(1), + WorktreePatchDirection::Apply, + ) + .unwrap(); + assert_eq!(fixture.read("f.txt"), numbered_lines(&[(15, "L15X")])); + } + + #[test] + fn reverting_a_hunk_restores_the_parents_line() { + let (fixture, oid) = two_hunk_commit(); + // Start from the committed content so there is something to revert. + fixture.write("f.txt", &numbered_lines(&[(2, "L2X"), (15, "L15X")])); + apply( + &fixture, + "f.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::Hunk(0), + WorktreePatchDirection::Revert, + ) + .unwrap(); + assert_eq!( + fixture.read("f.txt"), + numbered_lines(&[(15, "L15X")]), + "only the first hunk should be rolled back" + ); + } + + #[test] + fn applying_then_reverting_the_same_hunk_returns_the_original_bytes() { + let (fixture, oid) = two_hunk_commit(); + let original = fixture.read("f.txt"); + let source = WorktreePatchSource::Commit(oid); + apply( + &fixture, + "f.txt", + &source, + WorktreePatchScope::Hunk(0), + WorktreePatchDirection::Apply, + ) + .unwrap(); + apply( + &fixture, + "f.txt", + &source, + WorktreePatchScope::Hunk(0), + WorktreePatchDirection::Revert, + ) + .unwrap(); + assert_eq!(fixture.read("f.txt"), original); + } + + // ── whole file and line subsets ─────────────────────────────── + + #[test] + fn applying_the_whole_file_takes_every_hunk() { + let (fixture, oid) = two_hunk_commit(); + apply( + &fixture, + "f.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::File, + WorktreePatchDirection::Apply, + ) + .unwrap(); + assert_eq!( + fixture.read("f.txt"), + numbered_lines(&[(2, "L2X"), (15, "L15X")]) + ); + } + + #[test] + fn applying_a_line_subset_rewrites_only_the_selected_lines() { + let fixture = Fixture::new(); + fixture.write("f.txt", "a\nb\nc\nd\n"); + fixture.commit("base", &["f.txt"]); + fixture.write("f.txt", "a\nB2\nC2\nd\n"); + let oid = fixture.commit("two lines", &["f.txt"]); + fixture.write("f.txt", "a\nb\nc\nd\n"); + + // Select only the `c` → `C2` substitution: old line 3 and new line 3. + apply( + &fixture, + "f.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::Lines(vec![(Some(3), None), (None, Some(3))]), + WorktreePatchDirection::Apply, + ) + .unwrap(); + assert_eq!(fixture.read("f.txt"), "a\nb\nC2\nd\n"); + } + + #[test] + fn reverting_a_line_subset_restores_only_the_selected_lines() { + let fixture = Fixture::new(); + fixture.write("f.txt", "a\nb\nc\nd\n"); + fixture.commit("base", &["f.txt"]); + fixture.write("f.txt", "a\nB2\nC2\nd\n"); + let oid = fixture.commit("two lines", &["f.txt"]); + + apply( + &fixture, + "f.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::Lines(vec![(Some(3), None), (None, Some(3))]), + WorktreePatchDirection::Revert, + ) + .unwrap(); + assert_eq!(fixture.read("f.txt"), "a\nB2\nc\nd\n"); + } + + // ── local edits ─────────────────────────────────────────────── + + #[test] + fn applying_over_an_unrelated_local_edit_keeps_both_changes() { + // The hunk changes line 2, so its trailing context runs to line 5. A + // local edit on line 5 is inside that context window, which is what + // makes a plain context-matching apply (libgit2's, and `git apply`'s) + // fail here. The three-way merge keeps both. + let (fixture, oid) = two_hunk_commit(); + fixture.write("f.txt", &numbered_lines(&[(5, "LOCAL5")])); + + let outcome = apply( + &fixture, + "f.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::Hunk(0), + WorktreePatchDirection::Apply, + ) + .unwrap(); + + assert_eq!( + fixture.read("f.txt"), + numbered_lines(&[(2, "L2X"), (5, "LOCAL5")]), + "the applied hunk and the local edit must both survive" + ); + assert!( + outcome.merged_with_local_changes, + "the caller needs to know a merge happened so it can say so" + ); + } + + #[test] + fn libgit2s_own_apply_refuses_the_case_the_merge_handles() { + // Pins the libgit2 constraint the module docs state: the same hunk, as a + // patch, against the same working tree that + // `applying_over_an_unrelated_local_edit_keeps_both_changes` handles. + let (fixture, _) = two_hunk_commit(); + fixture.write("f.txt", &numbered_lines(&[(5, "LOCAL5")])); + let patch = "diff --git a/f.txt b/f.txt\n\ + --- a/f.txt\n\ + +++ b/f.txt\n\ + @@ -1,5 +1,5 @@\n\ + \x20l1\n\ + -l2\n\ + +L2X\n\ + \x20l3\n\ + \x20l4\n\ + \x20l5\n"; + let diff = git2::Diff::from_buffer(patch.as_bytes()).unwrap(); + fixture + .repo + .repo() + .apply(&diff, git2::ApplyLocation::WorkDir, None) + .expect_err("libgit2 matches context literally, and line 5 no longer matches"); + assert_eq!( + fixture.read("f.txt"), + numbered_lines(&[(5, "LOCAL5")]), + "and it leaves the file alone, so there is nothing to fall back from" + ); + } + + #[test] + fn applying_over_an_overlapping_local_edit_is_refused_and_writes_nothing() { + let (fixture, oid) = two_hunk_commit(); + fixture.write("f.txt", &numbered_lines(&[(2, "MINE")])); + + let error = apply( + &fixture, + "f.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::Hunk(0), + WorktreePatchDirection::Apply, + ) + .expect_err("an overlapping edit must not be silently merged"); + + let message = error.to_string(); + assert!( + message.contains("uncommitted edits"), + "expected the local-edit message, got: {message}" + ); + assert!( + message.contains("Commit or stash"), + "the message must say what to do, got: {message}" + ); + assert_eq!( + fixture.read("f.txt"), + numbered_lines(&[(2, "MINE")]), + "a refused apply must leave the file untouched" + ); + } + + #[test] + fn a_context_mismatch_on_a_clean_file_reports_the_revision_moving_on() { + // Commit a change, then commit again so the hunk's context no longer + // exists anywhere in the working tree. The file itself is clean. + let fixture = Fixture::new(); + fixture.write("f.txt", "a\nb\nc\n"); + fixture.commit("base", &["f.txt"]); + fixture.write("f.txt", "a\nB2\nc\n"); + let target = fixture.commit("change b", &["f.txt"]); + fixture.write("f.txt", "totally\ndifferent\nfile\n"); + fixture.commit("rewrite", &["f.txt"]); + + let error = apply( + &fixture, + "f.txt", + &WorktreePatchSource::Commit(target), + WorktreePatchScope::Hunk(0), + WorktreePatchDirection::Apply, + ) + .expect_err("the patch cannot fit"); + + let message = error.to_string(); + assert!( + message.contains("surrounding lines have changed"), + "expected the context-mismatch message, got: {message}" + ); + assert_eq!( + fixture.read("f.txt"), + "totally\ndifferent\nfile\n", + "a refused apply must leave the file untouched" + ); + } + + // ── already applied ─────────────────────────────────────────── + + #[test] + fn applying_an_already_applied_hunk_is_a_clear_error_not_a_silent_mess() { + let (fixture, oid) = two_hunk_commit(); + let source = WorktreePatchSource::Commit(oid); + apply( + &fixture, + "f.txt", + &source, + WorktreePatchScope::Hunk(0), + WorktreePatchDirection::Apply, + ) + .unwrap(); + let after_first = fixture.read("f.txt"); + + let error = apply( + &fixture, + "f.txt", + &source, + WorktreePatchScope::Hunk(0), + WorktreePatchDirection::Apply, + ) + .expect_err("a second apply has nothing to do"); + + let message = error.to_string(); + assert!( + message.contains("nothing to apply"), + "expected the already-applied message, got: {message}" + ); + assert!( + message.contains("press r to revert") || message.contains("Press r to revert"), + "the message must point at the way out, got: {message}" + ); + assert_eq!( + fixture.read("f.txt"), + after_first, + "the second attempt must not double-apply" + ); + } + + #[test] + fn reverting_something_already_absent_is_a_clear_error() { + let (fixture, oid) = two_hunk_commit(); + let error = apply( + &fixture, + "f.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::Hunk(0), + WorktreePatchDirection::Revert, + ) + .expect_err("the change was never applied"); + + let message = error.to_string(); + assert!( + message.contains("nothing to \nrevert") || message.contains("nothing to revert"), + "expected the already-reverted message, got: {message}" + ); + } + + // ── cross-branch compare ────────────────────────────────────── + + /// Two branches that differ in `f.txt`, with `main` checked out. + fn two_branch_repo() -> Fixture { + let fixture = Fixture::new(); + fixture.write("f.txt", "shared\nours\nshared2\n"); + fixture.commit("base", &["f.txt"]); + let base_branch = fixture.head_branch_name(); + fixture.branch("feature"); + fixture.checkout("feature"); + fixture.write("f.txt", "shared\ntheirs\nshared2\n"); + fixture.commit("feature edit", &["f.txt"]); + fixture.checkout(&base_branch); + fixture.write("f.txt", "shared\nours\nshared2\n"); + fixture + } + + #[test] + fn applying_a_cross_branch_hunk_brings_the_other_branchs_content_in() { + let fixture = two_branch_repo(); + let base_branch = fixture.head_branch_name(); + apply( + &fixture, + "f.txt", + &WorktreePatchSource::Compare { + from: base_branch, + to: "feature".to_string(), + }, + WorktreePatchScope::Hunk(0), + WorktreePatchDirection::Apply, + ) + .unwrap(); + assert_eq!( + fixture.read("f.txt"), + "shared\ntheirs\nshared2\n", + "applying main→feature must pull feature's line into the working tree" + ); + } + + #[test] + fn the_compare_direction_decides_which_branch_wins() { + // Same two branches, endpoints swapped. Applying feature→main while on + // main is a no-op, which proves the patch really is generated from the + // endpoints and not from index→workdir. + let fixture = two_branch_repo(); + let base_branch = fixture.head_branch_name(); + let error = apply( + &fixture, + "f.txt", + &WorktreePatchSource::Compare { + from: "feature".to_string(), + to: base_branch, + }, + WorktreePatchScope::Hunk(0), + WorktreePatchDirection::Apply, + ) + .expect_err("the working tree already holds main's content"); + assert!( + error.to_string().contains("nothing to apply"), + "got: {error}" + ); + assert_eq!(fixture.read("f.txt"), "shared\nours\nshared2\n"); + } + + #[test] + fn reverting_a_cross_branch_hunk_restores_this_branchs_content() { + let fixture = two_branch_repo(); + let base_branch = fixture.head_branch_name(); + let source = WorktreePatchSource::Compare { + from: base_branch, + to: "feature".to_string(), + }; + apply( + &fixture, + "f.txt", + &source, + WorktreePatchScope::Hunk(0), + WorktreePatchDirection::Apply, + ) + .unwrap(); + apply( + &fixture, + "f.txt", + &source, + WorktreePatchScope::Hunk(0), + WorktreePatchDirection::Revert, + ) + .unwrap(); + assert_eq!(fixture.read("f.txt"), "shared\nours\nshared2\n"); + } + + #[test] + fn a_compare_endpoint_that_does_not_resolve_names_itself() { + let fixture = two_branch_repo(); + let error = apply( + &fixture, + "f.txt", + &WorktreePatchSource::Compare { + from: "no-such-branch".to_string(), + to: "feature".to_string(), + }, + WorktreePatchScope::File, + WorktreePatchDirection::Apply, + ) + .expect_err("the endpoint does not exist"); + assert!(error.to_string().contains("no-such-branch"), "got: {error}"); + } + + // ── file creation and deletion ──────────────────────────────── + + #[test] + fn applying_a_commit_that_added_a_file_creates_it() { + let fixture = Fixture::new(); + fixture.write("keep.txt", "x\n"); + fixture.commit("base", &["keep.txt"]); + fixture.write("added.txt", "one\ntwo\n"); + let oid = fixture.commit("add a file", &["added.txt"]); + std::fs::remove_file(fixture.path.join("added.txt")).unwrap(); + + apply( + &fixture, + "added.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::File, + WorktreePatchDirection::Apply, + ) + .unwrap(); + assert_eq!(fixture.read("added.txt"), "one\ntwo\n"); + } + + #[test] + fn reverting_a_commit_that_added_a_file_deletes_it() { + let fixture = Fixture::new(); + fixture.write("keep.txt", "x\n"); + fixture.commit("base", &["keep.txt"]); + fixture.write("added.txt", "one\ntwo\n"); + let oid = fixture.commit("add a file", &["added.txt"]); + + apply( + &fixture, + "added.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::File, + WorktreePatchDirection::Revert, + ) + .unwrap(); + assert!( + !fixture.path.join("added.txt").exists(), + "reverting the addition of a file should remove it" + ); + } + + #[test] + fn a_file_unchanged_by_the_source_is_refused_by_name() { + let (fixture, oid) = two_hunk_commit(); + fixture.write("other.txt", "untouched\n"); + let error = apply( + &fixture, + "other.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::File, + WorktreePatchDirection::Apply, + ) + .expect_err("that file is not part of the commit"); + assert!(error.to_string().contains("other.txt"), "got: {error}"); + } + + #[test] + fn a_hunk_index_past_the_end_is_refused() { + let (fixture, oid) = two_hunk_commit(); + let error = apply( + &fixture, + "f.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::Hunk(9), + WorktreePatchDirection::Apply, + ) + .expect_err("there is no tenth hunk"); + assert!( + error.to_string().contains("reselect the hunk"), + "got: {error}" + ); + } + + // ── undo snapshots ──────────────────────────────────────────── + + #[test] + fn the_snapshot_captures_the_bytes_the_apply_overwrote() { + let (fixture, oid) = two_hunk_commit(); + let before = fixture.read("f.txt"); + let outcome = apply( + &fixture, + "f.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::File, + WorktreePatchDirection::Apply, + ) + .unwrap(); + + assert_eq!(outcome.snapshots.len(), 1); + assert_eq!(outcome.snapshots[0].path, PathBuf::from("f.txt")); + assert_eq!( + outcome.snapshots[0] + .before + .as_ref() + .map(|state| state.contents.as_slice()), + Some(before.as_bytes()) + ); + assert_eq!( + outcome.snapshots[0] + .expected_after + .as_ref() + .map(|state| state.contents.as_slice()), + Some(fixture.read("f.txt").as_bytes()) + ); + } + + #[test] + fn restoring_a_snapshot_puts_the_original_bytes_back() { + let (fixture, oid) = two_hunk_commit(); + let before = fixture.read("f.txt"); + let outcome = apply( + &fixture, + "f.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::File, + WorktreePatchDirection::Apply, + ) + .unwrap(); + assert_ne!(fixture.read("f.txt"), before); + + restore_worktree_files(&fixture.path, &outcome.snapshots).unwrap(); + assert_eq!(fixture.read("f.txt"), before); + } + + #[test] + fn restoring_an_absent_snapshot_deletes_the_created_file() { + let fixture = Fixture::new(); + fixture.write("keep.txt", "x\n"); + fixture.commit("base", &["keep.txt"]); + fixture.write("added.txt", "one\n"); + let oid = fixture.commit("add", &["added.txt"]); + std::fs::remove_file(fixture.path.join("added.txt")).unwrap(); + + let outcome = apply( + &fixture, + "added.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::File, + WorktreePatchDirection::Apply, + ) + .unwrap(); + assert!(fixture.path.join("added.txt").exists()); + assert_eq!(outcome.snapshots[0].before, None); + assert!(outcome.snapshots[0].expected_after.is_some()); + + restore_worktree_files(&fixture.path, &outcome.snapshots).unwrap(); + assert!(!fixture.path.join("added.txt").exists()); + } + + #[test] + fn restoring_a_snapshot_of_a_deleted_file_recreates_it() { + let fixture = Fixture::new(); + fixture.write("keep.txt", "x\n"); + fixture.commit("base", &["keep.txt"]); + fixture.write("added.txt", "one\ntwo\n"); + let oid = fixture.commit("add", &["added.txt"]); + + let outcome = apply( + &fixture, + "added.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::File, + WorktreePatchDirection::Revert, + ) + .unwrap(); + assert!(!fixture.path.join("added.txt").exists()); + + restore_worktree_files(&fixture.path, &outcome.snapshots).unwrap(); + assert_eq!(fixture.read("added.txt"), "one\ntwo\n"); + } + + // ── stash entries travel the commit path ────────────────────── + + #[test] + fn undo_refuses_to_overwrite_an_edit_made_after_apply() { + let (fixture, oid) = two_hunk_commit(); + let outcome = apply( + &fixture, + "f.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::File, + WorktreePatchDirection::Apply, + ) + .unwrap(); + fixture.write("f.txt", "a later editor change\n"); + + let error = restore_worktree_files(&fixture.path, &outcome.snapshots) + .expect_err("undo must preserve the later edit"); + assert!(error.to_string().contains("edited after"), "got: {error}"); + assert_eq!(fixture.read("f.txt"), "a later editor change\n"); + } + + #[test] + fn undo_of_a_created_file_refuses_to_delete_later_content() { + let fixture = Fixture::new(); + fixture.write("keep.txt", "x\n"); + fixture.commit("base", &["keep.txt"]); + fixture.write("added.txt", "from commit\n"); + let oid = fixture.commit("add", &["added.txt"]); + std::fs::remove_file(fixture.path.join("added.txt")).unwrap(); + let outcome = apply( + &fixture, + "added.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::File, + WorktreePatchDirection::Apply, + ) + .unwrap(); + fixture.write("added.txt", "new user content\n"); + + assert!(restore_worktree_files(&fixture.path, &outcome.snapshots).is_err()); + assert_eq!(fixture.read("added.txt"), "new user content\n"); + } + + #[test] + fn undo_of_a_deleted_file_refuses_to_replace_a_new_path() { + let fixture = Fixture::new(); + fixture.write("keep.txt", "x\n"); + fixture.commit("base", &["keep.txt"]); + fixture.write("added.txt", "from commit\n"); + let oid = fixture.commit("add", &["added.txt"]); + let outcome = apply( + &fixture, + "added.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::File, + WorktreePatchDirection::Revert, + ) + .unwrap(); + fixture.write("added.txt", "new path\n"); + + assert!(restore_worktree_files(&fixture.path, &outcome.snapshots).is_err()); + assert_eq!(fixture.read("added.txt"), "new path\n"); + } + + #[test] + fn concurrent_restores_accept_the_expected_after_image_only_once() { + let (fixture, oid) = two_hunk_commit(); + let outcome = apply( + &fixture, + "f.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::File, + WorktreePatchDirection::Apply, + ) + .unwrap(); + let path = fixture.path.clone(); + let left_snapshots = outcome.snapshots.clone(); + let right_snapshots = outcome.snapshots; + let barrier = std::sync::Arc::new(std::sync::Barrier::new(3)); + let left_barrier = barrier.clone(); + let left_path = path.clone(); + let left = std::thread::spawn(move || { + left_barrier.wait(); + restore_worktree_files(&left_path, &left_snapshots) + }); + let right_barrier = barrier.clone(); + let right = std::thread::spawn(move || { + right_barrier.wait(); + restore_worktree_files(&path, &right_snapshots) + }); + barrier.wait(); + + let results = [left.join().unwrap(), right.join().unwrap()]; + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1); + } + + #[test] + fn traversal_paths_are_rejected_before_any_filesystem_write() { + let (fixture, oid) = two_hunk_commit(); + let error = apply_worktree_patch( + &fixture.path, + Path::new("../outside.txt"), + &WorktreePatchSource::Commit(oid), + &WorktreePatchScope::File, + WorktreePatchDirection::Apply, + ) + .expect_err("parent traversal is never a working-tree file path"); + assert!(error.to_string().contains("relative"), "got: {error}"); + } + + #[test] + fn invalid_utf8_in_the_worktree_is_refused_without_rewriting_it() { + let (fixture, oid) = two_hunk_commit(); + let invalid = b"valid prefix\n\xff\xfe\n"; + fixture.write_bytes("f.txt", invalid); + + let error = apply( + &fixture, + "f.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::File, + WorktreePatchDirection::Apply, + ) + .expect_err("invalid canonical text cannot enter the merge"); + assert!(error.to_string().contains("valid UTF-8"), "got: {error}"); + assert_eq!(std::fs::read(fixture.path.join("f.txt")).unwrap(), invalid); + } + + #[test] + fn invalid_utf8_in_a_source_blob_is_refused_without_rewriting_the_worktree() { + let fixture = Fixture::new(); + fixture.write("f.txt", "base\n"); + fixture.commit("base", &["f.txt"]); + fixture.write_bytes("f.txt", b"target\n\xff\n"); + let oid = fixture.commit("invalid target", &["f.txt"]); + fixture.write("f.txt", "base\n"); + + let error = apply( + &fixture, + "f.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::File, + WorktreePatchDirection::Apply, + ) + .expect_err("invalid canonical source text must be refused"); + assert!(error.to_string().contains("valid UTF-8"), "got: {error}"); + assert_eq!(fixture.read("f.txt"), "base\n"); + } + + #[test] + fn git_eol_conversion_round_trips_canonical_and_worktree_bytes() { + let fixture = Fixture::new(); + fixture.write(".gitattributes", "*.txt text eol=crlf\n"); + + let canonical = b"one\ntwo\n"; + let worktree = smudge_canonical_bytes(&fixture.path, Path::new("f.txt"), canonical) + .expect("Git should apply the eol attribute"); + assert_eq!(worktree, b"one\r\ntwo\r\n"); + assert_eq!( + clean_worktree_bytes(&fixture.path, Path::new("f.txt"), &worktree).unwrap(), + canonical + ); + } + + #[test] + fn a_full_apply_and_undo_preserve_crlf_worktree_form() { + let fixture = Fixture::new(); + fixture.write(".gitattributes", "*.txt text eol=crlf\n"); + fixture.commit("attributes", &[".gitattributes"]); + fixture.write_bytes("f.txt", b"base\r\n"); + fixture.commit("base", &["f.txt"]); + fixture.write_bytes("f.txt", b"target\r\n"); + let oid = fixture.commit("target", &["f.txt"]); + fixture.write_bytes("f.txt", b"base\r\n"); + + let outcome = apply( + &fixture, + "f.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::File, + WorktreePatchDirection::Apply, + ) + .unwrap(); + assert_eq!( + std::fs::read(fixture.path.join("f.txt")).unwrap(), + b"target\r\n" + ); + + restore_worktree_files(&fixture.path, &outcome.snapshots).unwrap(); + assert_eq!( + std::fs::read(fixture.path.join("f.txt")).unwrap(), + b"base\r\n" + ); + } + + #[test] + fn git_working_tree_encoding_round_trips_through_canonical_utf8() { + let fixture = Fixture::new(); + fixture.write( + ".gitattributes", + "*.utf16 text working-tree-encoding=UTF-16LE\n", + ); + let canonical = b"one\ntwo\n"; + + let encoded = smudge_canonical_bytes(&fixture.path, Path::new("f.utf16"), canonical) + .expect("Git should encode the working-tree form"); + assert_ne!(encoded, canonical); + assert_eq!( + clean_worktree_bytes(&fixture.path, Path::new("f.utf16"), &encoded).unwrap(), + canonical + ); + } + + #[test] + fn required_clean_and_smudge_filters_are_honoured() { + let fixture = Fixture::new(); + fixture.write(".gitattributes", "*.flt filter=rgitui-test\n"); + fixture.set_config("filter.rgitui-test.required", "true"); + fixture.set_config("filter.rgitui-test.clean", "sed s/WORKTREE/CANONICAL/g"); + fixture.set_config("filter.rgitui-test.smudge", "sed s/CANONICAL/WORKTREE/g"); + + assert_eq!( + clean_worktree_bytes(&fixture.path, Path::new("f.flt"), b"WORKTREE\n").unwrap(), + b"CANONICAL\n" + ); + assert_eq!( + smudge_canonical_bytes(&fixture.path, Path::new("f.flt"), b"CANONICAL\n").unwrap(), + b"WORKTREE\n" + ); + } + + #[test] + fn a_required_filter_failure_writes_nothing() { + let (fixture, oid) = two_hunk_commit(); + fixture.write(".gitattributes", "f.txt filter=broken\n"); + fixture.set_config("filter.broken.required", "true"); + fixture.set_config("filter.broken.clean", "false"); + fixture.set_config("filter.broken.smudge", "false"); + let before = fixture.read("f.txt"); + + assert!(apply( + &fixture, + "f.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::File, + WorktreePatchDirection::Apply, + ) + .is_err()); + assert_eq!(fixture.read("f.txt"), before); + } + + #[cfg(unix)] + #[test] + fn undo_restores_the_recorded_unix_mode() { + use std::os::unix::fs::PermissionsExt as _; + + let fixture = Fixture::new(); + fixture.write("f.txt", "after\n"); + std::fs::set_permissions( + fixture.path.join("f.txt"), + std::fs::Permissions::from_mode(0o600), + ) + .unwrap(); + let snapshot = WorktreeFileSnapshot { + path: PathBuf::from("f.txt"), + before: Some(WorktreeFileState { + contents: b"before\n".to_vec(), + permissions: WorktreeFilePermissions { + readonly: false, + mode: 0o751, + }, + }), + expected_after: read_worktree_file_state(&fixture.path, Path::new("f.txt")).unwrap(), + }; + + restore_worktree_files(&fixture.path, &[snapshot]).unwrap(); + assert_eq!(fixture.read("f.txt"), "before\n"); + assert_eq!( + std::fs::metadata(fixture.path.join("f.txt")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o751 + ); + } + + #[cfg(unix)] + #[test] + fn applying_a_new_executable_file_preserves_its_git_mode() { + use std::os::unix::fs::PermissionsExt as _; + + let fixture = Fixture::new(); + fixture.write("keep.txt", "base\n"); + fixture.commit("base", &["keep.txt"]); + fixture.write("script.sh", "#!/bin/sh\nexit 0\n"); + std::fs::set_permissions( + fixture.path.join("script.sh"), + std::fs::Permissions::from_mode(0o755), + ) + .unwrap(); + let oid = fixture.commit("script", &["script.sh"]); + std::fs::remove_file(fixture.path.join("script.sh")).unwrap(); + + apply( + &fixture, + "script.sh", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::File, + WorktreePatchDirection::Apply, + ) + .unwrap(); + assert_eq!( + std::fs::metadata(fixture.path.join("script.sh")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o755 + ); + } + + #[cfg(any(unix, windows))] + #[test] + fn a_symlink_file_is_rejected_without_touching_its_target() { + let (fixture, oid) = two_hunk_commit(); + let outside = TempDir::new().unwrap(); + let target = outside.path().join("target.txt"); + std::fs::write(&target, "outside\n").unwrap(); + std::fs::remove_file(fixture.path.join("f.txt")).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&target, fixture.path.join("f.txt")).unwrap(); + #[cfg(windows)] + if std::os::windows::fs::symlink_file(&target, fixture.path.join("f.txt")).is_err() { + return; + } + + let error = apply( + &fixture, + "f.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::File, + WorktreePatchDirection::Apply, + ) + .expect_err("a final symlink must never be followed"); + assert!( + error.to_string().contains("symbolic link") + || error.to_string().contains("reparse point"), + "got: {error}" + ); + assert_eq!(std::fs::read_to_string(target).unwrap(), "outside\n"); + } + + #[cfg(any(unix, windows))] + #[test] + fn a_symlink_parent_is_rejected_without_touching_outside_files() { + let fixture = Fixture::new(); + fixture.write("dir/f.txt", "base\n"); + fixture.commit("base", &["dir/f.txt"]); + fixture.write("dir/f.txt", "changed\n"); + let oid = fixture.commit("change", &["dir/f.txt"]); + std::fs::remove_file(fixture.path.join("dir/f.txt")).unwrap(); + std::fs::remove_dir(fixture.path.join("dir")).unwrap(); + let outside = TempDir::new().unwrap(); + std::fs::write(outside.path().join("f.txt"), "outside\n").unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(outside.path(), fixture.path.join("dir")).unwrap(); + #[cfg(windows)] + if std::os::windows::fs::symlink_dir(outside.path(), fixture.path.join("dir")).is_err() { + return; + } + + let error = apply( + &fixture, + "dir/f.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::File, + WorktreePatchDirection::Apply, + ) + .expect_err("a linked parent must never be followed"); + assert!( + error.to_string().contains("symbolic link") + || error.to_string().contains("reparse point"), + "got: {error}" + ); + assert_eq!( + std::fs::read_to_string(outside.path().join("f.txt")).unwrap(), + "outside\n" + ); + } + + #[test] + fn a_tracked_symlink_diff_is_not_materialized_as_a_regular_file() { + let fixture = Fixture::new(); + fixture.write("f.txt", "base\n"); + fixture.commit("base", &["f.txt"]); + let outside = TempDir::new().unwrap(); + let target = outside.path().join("target.txt"); + std::fs::write(&target, "outside\n").unwrap(); + let oid = fixture.commit_symlink("replace with symlink", "f.txt", &target); + + let error = apply( + &fixture, + "f.txt", + &WorktreePatchSource::Commit(oid), + WorktreePatchScope::File, + WorktreePatchDirection::Apply, + ) + .expect_err("a symlink diff needs link-aware semantics"); + assert!(error.to_string().contains("not a regular file")); + assert_eq!(fixture.read("f.txt"), "base\n"); + assert_eq!(std::fs::read_to_string(target).unwrap(), "outside\n"); + } + + #[test] + fn a_stash_entrys_hunk_applies_like_any_other_commit() { + let fixture = Fixture::new(); + fixture.write("f.txt", "a\nb\nc\n"); + fixture.commit("base", &["f.txt"]); + fixture.write("f.txt", "a\nSTASHED\nc\n"); + let mut repo = Repository::open(&fixture.path).unwrap(); + let signature = git2::Signature::now("Test", "t@t.com").unwrap(); + let stash_oid = repo + .stash_save(&signature, "wip", Some(git2::StashFlags::DEFAULT)) + .unwrap(); + assert_eq!(fixture.read("f.txt"), "a\nb\nc\n", "stash reset the file"); + + apply( + &fixture, + "f.txt", + &WorktreePatchSource::Commit(stash_oid), + WorktreePatchScope::Hunk(0), + WorktreePatchDirection::Apply, + ) + .unwrap(); + assert_eq!(fixture.read("f.txt"), "a\nSTASHED\nc\n"); + } +} diff --git a/third_party/rgitui/crates/rgitui_git/src/types.rs b/third_party/rgitui/crates/rgitui_git/src/types.rs new file mode 100644 index 000000000..1842d1dfa --- /dev/null +++ b/third_party/rgitui/crates/rgitui_git/src/types.rs @@ -0,0 +1,894 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// A single line from a global content search result. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SearchResult { + /// Absolute path to the file containing the match. + pub path: PathBuf, + /// 1-based line number within the file. + pub line_number: usize, + /// Full text of the matching line. + pub content: String, +} + +/// Information about a Git signature (author or committer). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Signature { + pub name: String, + pub email: String, +} + +/// A reference label attached to a commit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RefLabel { + Head, + LocalBranch(String), + RemoteBranch(String), + Tag(String), +} + +impl RefLabel { + pub fn display_name(&self) -> &str { + match self { + RefLabel::Head => "HEAD", + RefLabel::LocalBranch(name) => name, + RefLabel::RemoteBranch(name) => name, + RefLabel::Tag(name) => name, + } + } +} + +/// A ref label prepared for compact UI display. A local branch absorbs matching +/// remote-tracking refs so `topic` and `origin/topic` can be shown as one chip. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompactRefLabel { + pub label: RefLabel, + pub remotes: Vec, +} + +/// Combine a local branch with remote-tracking refs that point at the same +/// commit and have the same branch path after the remote name. +pub fn compact_ref_labels(refs: &[RefLabel]) -> Vec { + let local_names: std::collections::HashSet<&str> = refs + .iter() + .filter_map(|label| match label { + RefLabel::LocalBranch(name) => Some(name.as_str()), + _ => None, + }) + .collect(); + let mut remotes_by_local: std::collections::HashMap<&str, Vec> = + std::collections::HashMap::new(); + + for label in refs { + let RefLabel::RemoteBranch(name) = label else { + continue; + }; + let Some((remote, branch_path)) = name.split_once('/') else { + continue; + }; + if local_names.contains(branch_path) { + remotes_by_local + .entry(branch_path) + .or_default() + .push(remote.to_string()); + } + } + + refs.iter() + .filter_map(|label| match label { + RefLabel::RemoteBranch(name) => { + let paired = name + .split_once('/') + .is_some_and(|(_, branch_path)| local_names.contains(branch_path)); + (!paired).then(|| CompactRefLabel { + label: label.clone(), + remotes: Vec::new(), + }) + } + RefLabel::LocalBranch(name) => Some(CompactRefLabel { + label: label.clone(), + remotes: remotes_by_local.remove(name.as_str()).unwrap_or_default(), + }), + _ => Some(CompactRefLabel { + label: label.clone(), + remotes: Vec::new(), + }), + }) + .collect() +} + +/// What checking out a commit from the graph should do. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommitCheckout { + /// The checked-out branch already points at the commit. + AlreadyCheckedOut(String), + /// A local branch points at the commit; checking it out keeps HEAD on a + /// branch. + Branch(String), + /// No local branch points at the commit, so HEAD detaches there. + Detached, +} + +/// Decides what activating a commit with `refs` checks out while `head_branch` +/// is checked out (`None` when HEAD is detached). +/// +/// Detaching at a commit a branch already names only strands the user off that +/// branch, so a local branch is preferred, and the one already checked out +/// before any other. Tags and remote-tracking branches still detach, as they do +/// in git. +pub fn commit_checkout(refs: &[RefLabel], head_branch: Option<&str>) -> CommitCheckout { + let local_branches = || { + refs.iter().filter_map(|label| match label { + RefLabel::LocalBranch(name) => Some(name.as_str()), + _ => None, + }) + }; + if let Some(current) = local_branches().find(|name| Some(*name) == head_branch) { + return CommitCheckout::AlreadyCheckedOut(current.to_owned()); + } + local_branches() + .next() + .map_or(CommitCheckout::Detached, |branch| { + CommitCheckout::Branch(branch.to_owned()) + }) +} + +/// Information about a single commit. +#[derive(Debug, Clone)] +pub struct CommitInfo { + pub oid: git2::Oid, + pub short_id: String, + pub summary: String, + pub message: String, + pub author: Signature, + pub committer: Signature, + pub co_authors: Vec, + pub time: DateTime, + pub parent_oids: Vec, + pub refs: Vec, + /// Whether this commit has a GPG signature (gpgsig header present). + pub is_signed: bool, +} + +/// Information about a branch. +#[derive(Debug, Clone, PartialEq)] +pub struct BranchInfo { + pub name: String, + pub is_head: bool, + pub is_remote: bool, + pub upstream: Option, + /// Commits this branch has that its upstream does not. `None` until the + /// walk that computes it has run — which is a different thing from `Some(0)`, + /// "level with upstream", and conflating the two is how a pushed branch + /// keeps reporting the count it had before the push. + pub ahead: Option, + /// Commits the upstream has that this branch does not. See [`BranchInfo::ahead`]. + pub behind: Option, + pub tip_oid: Option, + /// Author email of the tip commit — used to filter "My Branches". + pub author_email: Option, + /// Unix timestamp of the tip commit, if available. + pub last_commit_time: Option, + /// Whether this branch is merged into the default branch (main/master). + /// None = not yet computed, Some(false) = checked and not merged, Some(true) = merged. + pub is_merged_into_main: Option, + /// Whether this local branch is merged into the currently checked-out branch. + pub is_merged_into_head: Option, +} + +impl BranchInfo { + /// Commits ahead of upstream, treating "not computed yet" as none. + /// + /// For display only. Anything deciding whether the value is *known* — a + /// cache carrying state forward, a filter asserting divergence — must look + /// at the [`Option`] itself, or it will read a pending walk as a result. + pub fn ahead_count(&self) -> usize { + self.ahead.unwrap_or(0) + } + + /// Commits behind upstream. See [`BranchInfo::ahead_count`]. + pub fn behind_count(&self) -> usize { + self.behind.unwrap_or(0) + } + + /// Whether the ahead/behind walk has produced an answer for this branch. + pub fn has_ahead_behind(&self) -> bool { + self.ahead.is_some() || self.behind.is_some() + } +} + +/// Information about a tag. +#[derive(Debug, Clone, PartialEq)] +pub struct TagInfo { + pub name: String, + pub oid: git2::Oid, + pub message: Option, +} + +/// Information about a remote. +#[derive(Debug, Clone, PartialEq)] +pub struct RemoteInfo { + pub name: String, + pub url: Option, + pub push_url: Option, +} + +/// Information about a worktree attached to this repository. +#[derive(Debug, Clone, PartialEq)] +pub struct WorktreeInfo { + /// The worktree name (directory name or custom name). + pub name: String, + /// Absolute path to the worktree working directory. + pub path: PathBuf, + /// Whether the worktree is locked (e.g. by a running operation). + pub is_locked: bool, + /// Whether this is the worktree the open `Repository` handle points at — + /// i.e. the one rgitui was launched on. That is the main checkout only when + /// rgitui was opened on the main repository; opening a linked worktree + /// directly makes *it* the current one. + pub is_current: bool, + /// The branch currently checked out in this worktree. `None` when HEAD is + /// detached — git2's `shorthand()` reports a short OID in that case, which + /// must not be rendered as a branch name. + pub branch: Option, + /// Whether this worktree's HEAD is detached. + pub head_detached: bool, + /// OID of the HEAD commit in this worktree. + pub head_oid: Option, + /// Cached pending-change status for this worktree, if available. + pub status: Option, + /// Whether this checkout is mid-merge, mid-rebase, mid-cherry-pick and so + /// on. Each worktree keeps its own — `MERGE_HEAD` for a linked worktree + /// lives under `.git/worktrees//`, so asking the main repository + /// about it always answers `Clean`. + pub state: RepoState, +} + +/// Information about a stash entry. +#[derive(Debug, Clone, PartialEq)] +pub struct StashEntry { + pub index: usize, + pub message: String, + pub oid: git2::Oid, +} + +/// Status of a file in the working tree. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FileChangeKind { + Added, + Modified, + Deleted, + Renamed, + Copied, + TypeChange, + Untracked, + Conflicted, +} + +impl FileChangeKind { + pub fn short_code(&self) -> &'static str { + match self { + FileChangeKind::Added => "A", + FileChangeKind::Modified => "M", + FileChangeKind::Deleted => "D", + FileChangeKind::Renamed => "R", + FileChangeKind::Copied => "C", + FileChangeKind::TypeChange => "T", + FileChangeKind::Untracked => "?", + FileChangeKind::Conflicted => "!", + } + } +} + +/// A file change in the working tree or staging area. +#[derive(Debug, Clone, PartialEq)] +pub struct FileStatus { + pub path: PathBuf, + pub kind: FileChangeKind, + pub old_path: Option, + /// Number of lines added in this file change. + pub additions: usize, + /// Number of lines deleted in this file change. + pub deletions: usize, +} + +/// Summary of all working tree changes. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct WorkingTreeStatus { + pub staged: Vec, + pub unstaged: Vec, +} + +/// A hunk in a diff. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiffHunk { + pub old_start: u32, + pub old_lines: u32, + pub new_start: u32, + pub new_lines: u32, + pub header: String, + pub lines: Vec, +} + +/// A single line in a diff hunk. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DiffLine { + Context(String), + Addition(String), + Deletion(String), +} + +/// A complete file diff. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileDiff { + pub path: PathBuf, + pub hunks: Vec, + pub additions: usize, + pub deletions: usize, + pub kind: FileChangeKind, +} + +/// A complete commit diff (all files). +#[derive(Debug, Clone)] +pub struct CommitDiff { + pub files: Vec, + pub total_additions: usize, + pub total_deletions: usize, +} + +/// One lossless section of Git's three-way merge result. +/// +/// Bytes are retained rather than converted to lines here so CRLF, invalid +/// UTF-8 and a missing final newline survive a round-trip through the conflict +/// resolver. Text conversion is a presentation concern in `rgitui_diff`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MergeSection { + /// Content Git merged without user input. + Resolved(Vec), + /// A section for which Git needs an explicit result. + Conflict { + ancestor: Vec, + ours: Vec, + theirs: Vec, + }, +} + +/// The kind, bytes and Git-relevant permissions of a conflicted worktree path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConflictWorktreeSnapshot { + Missing, + Regular { + bytes: Vec, + executable: Option, + readonly: bool, + }, + Symlink { + target: Vec, + }, + Other, +} + +impl ConflictWorktreeSnapshot { + pub fn bytes(&self) -> Option<&[u8]> { + match self { + Self::Regular { bytes, .. } => Some(bytes), + Self::Symlink { target } => Some(target), + Self::Missing | Self::Other => None, + } + } +} + +/// The index and working-tree state the resolver was built from. +/// +/// Resolution compares this snapshot with the repository again immediately +/// before writing. That prevents an old view from overwriting edits or a newer +/// merge state that appeared while it was open. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConflictSnapshot { + pub ancestor_oid: Option, + pub ancestor_mode: Option, + pub ours_oid: Option, + pub ours_mode: Option, + pub theirs_oid: Option, + pub theirs_mode: Option, + pub worktree: ConflictWorktreeSnapshot, +} + +/// A merge-aware conflict model for one unmerged index entry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThreeWayFileDiff { + pub path: PathBuf, + pub sections: Vec, + pub snapshot: ConflictSnapshot, + pub ancestor_exists: bool, + pub ours_exists: bool, + pub theirs_exists: bool, + /// True when at least one side cannot be represented safely as UTF-8 text. + pub is_binary: bool, + /// True for symlinks, submodules and other entries that cannot use the text + /// result composer. Whole-side resolution remains available for them. + pub is_special_file: bool, + /// File mode selected by libgit2 for an assembled text result. + pub result_mode: u32, +} + +impl ThreeWayFileDiff { + pub fn conflict_count(&self) -> usize { + self.sections + .iter() + .filter(|section| matches!(section, MergeSection::Conflict { .. })) + .count() + } + + pub fn supports_text_resolution(&self) -> bool { + !self.is_binary && !self.is_special_file && self.ours_exists && self.theirs_exists + } +} + +/// The current state of the repository (normal, mid-merge, mid-rebase, etc). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RepoState { + Clean, + Merge, + Revert, + RevertSequence, + CherryPick, + CherryPickSequence, + Bisect, + Rebase, + RebaseInteractive, + RebaseMerge, + ApplyMailbox, + ApplyMailboxOrRebase, +} + +impl RepoState { + /// Convert from git2::RepositoryState. + pub fn from_git2(state: git2::RepositoryState) -> Self { + match state { + git2::RepositoryState::Clean => RepoState::Clean, + git2::RepositoryState::Merge => RepoState::Merge, + git2::RepositoryState::Revert => RepoState::Revert, + git2::RepositoryState::RevertSequence => RepoState::RevertSequence, + git2::RepositoryState::CherryPick => RepoState::CherryPick, + git2::RepositoryState::CherryPickSequence => RepoState::CherryPickSequence, + git2::RepositoryState::Bisect => RepoState::Bisect, + git2::RepositoryState::Rebase => RepoState::Rebase, + git2::RepositoryState::RebaseInteractive => RepoState::RebaseInteractive, + git2::RepositoryState::RebaseMerge => RepoState::RebaseMerge, + git2::RepositoryState::ApplyMailbox => RepoState::ApplyMailbox, + git2::RepositoryState::ApplyMailboxOrRebase => RepoState::ApplyMailboxOrRebase, + } + } + + pub fn is_clean(&self) -> bool { + matches!(self, RepoState::Clean) + } + + /// The `git` subcommand whose `--continue` carries this state forward, or + /// `None` for the states that have no continuation. A clean repository has + /// nothing to continue, and a bisect advances by marking the checked-out + /// commit good or bad rather than by continuing. + pub fn continue_subcommand(&self) -> Option<&'static str> { + match self { + RepoState::Merge => Some("merge"), + RepoState::Revert | RepoState::RevertSequence => Some("revert"), + RepoState::CherryPick | RepoState::CherryPickSequence => Some("cherry-pick"), + RepoState::Rebase | RepoState::RebaseInteractive | RepoState::RebaseMerge => { + Some("rebase") + } + RepoState::ApplyMailbox | RepoState::ApplyMailboxOrRebase => Some("am"), + RepoState::Clean | RepoState::Bisect => None, + } + } + + /// The operation kind this state belongs to, so continuing or aborting it + /// is reported under the same kind that started it. + pub fn operation_kind(&self) -> GitOperationKind { + match self { + RepoState::Revert | RepoState::RevertSequence => GitOperationKind::Revert, + RepoState::CherryPick | RepoState::CherryPickSequence => GitOperationKind::CherryPick, + RepoState::Rebase | RepoState::RebaseInteractive | RepoState::RebaseMerge => { + GitOperationKind::Rebase + } + RepoState::Bisect => GitOperationKind::Bisect, + RepoState::Clean + | RepoState::Merge + | RepoState::ApplyMailbox + | RepoState::ApplyMailboxOrRebase => GitOperationKind::Merge, + } + } + + /// Human-readable label for the repo state. + pub fn label(&self) -> &'static str { + match self { + RepoState::Clean => "Clean", + RepoState::Merge => "Merging", + RepoState::Revert => "Reverting", + RepoState::RevertSequence => "Reverting", + RepoState::CherryPick => "Cherry-picking", + RepoState::CherryPickSequence => "Cherry-picking", + RepoState::Bisect => "Bisecting", + RepoState::Rebase => "Rebasing", + RepoState::RebaseInteractive => "Rebasing (interactive)", + RepoState::RebaseMerge => "Rebasing", + RepoState::ApplyMailbox => "Applying patches", + RepoState::ApplyMailboxOrRebase => "Applying patches", + } + } +} + +/// The action to perform on a commit during interactive rebase. +#[derive(Debug, Clone)] +pub enum RebaseEntryAction { + Pick, + Reword(String), + Squash, + Fixup, + Drop, +} + +/// A single entry in an interactive rebase plan. +#[derive(Debug, Clone)] +pub struct RebasePlanEntry { + pub oid: String, + pub message: String, + pub action: RebaseEntryAction, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GitOperationKind { + Fetch, + Pull, + Push, + Checkout, + Merge, + CherryPick, + Revert, + Reset, + RemoveRemote, + Commit, + Stage, + Unstage, + Stash, + Branch, + Tag, + Discard, + Rebase, + Bisect, + Worktree, + ResolveConflict, + Clean, + Clone, + /// Writing diff content from another revision into the working tree. + ApplyToWorktree, + /// Removing diff content from another revision out of the working tree. + RevertInWorktree, +} + +impl GitOperationKind { + pub fn display_name(&self) -> &'static str { + match self { + GitOperationKind::Fetch => "Fetch", + GitOperationKind::Pull => "Pull", + GitOperationKind::Push => "Push", + GitOperationKind::Checkout => "Checkout", + GitOperationKind::Merge => "Merge", + GitOperationKind::CherryPick => "Cherry-pick", + GitOperationKind::Revert => "Revert", + GitOperationKind::Reset => "Reset", + GitOperationKind::RemoveRemote => "Remove remote", + GitOperationKind::Commit => "Commit", + GitOperationKind::Stage => "Stage", + GitOperationKind::Unstage => "Unstage", + GitOperationKind::Stash => "Stash", + GitOperationKind::Branch => "Branch", + GitOperationKind::Tag => "Tag", + GitOperationKind::Discard => "Discard", + GitOperationKind::Rebase => "Rebase", + GitOperationKind::Bisect => "Bisect", + GitOperationKind::Worktree => "Worktree", + GitOperationKind::ResolveConflict => "Resolve conflict", + GitOperationKind::Clean => "Clean", + GitOperationKind::Clone => "Clone", + GitOperationKind::ApplyToWorktree => "Apply to working tree", + GitOperationKind::RevertInWorktree => "Revert in working tree", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GitOperationState { + Running, + Succeeded, + Failed, +} + +#[derive(Debug, Clone)] +pub struct GitOperationUpdate { + pub id: u64, + pub kind: GitOperationKind, + pub state: GitOperationState, + pub summary: String, + pub details: Option, + pub remote_name: Option, + pub branch_name: Option, + /// The checkout the operation ran in, for the operations that record one. + /// + /// Retrying has to go back to where it failed. Resolving that at retry time + /// from whatever is being inspected then is wrong as soon as the failure + /// arrives after the user has moved on — which for a network operation, + /// where the failure is a timeout or a rejected push, is the normal case. + pub worktree_path: Option, + pub retryable: bool, +} + +#[cfg(test)] +mod tests { + #[test] + fn every_in_progress_state_but_bisect_has_a_continue_subcommand() { + for state in [ + RepoState::Merge, + RepoState::Revert, + RepoState::RevertSequence, + RepoState::CherryPick, + RepoState::CherryPickSequence, + RepoState::Rebase, + RepoState::RebaseInteractive, + RepoState::RebaseMerge, + RepoState::ApplyMailbox, + RepoState::ApplyMailboxOrRebase, + ] { + assert!( + state.continue_subcommand().is_some(), + "{:?} is shown in the conflict banner and must be continuable", + state + ); + } + + // A clean repo has nothing to continue, and a bisect advances by judging + // the checked-out commit rather than by continuing. + assert_eq!(RepoState::Clean.continue_subcommand(), None); + assert_eq!(RepoState::Bisect.continue_subcommand(), None); + } + + #[test] + fn continue_subcommand_names_the_operation_that_stopped() { + assert_eq!(RepoState::Merge.continue_subcommand(), Some("merge")); + assert_eq!( + RepoState::CherryPickSequence.continue_subcommand(), + Some("cherry-pick") + ); + assert_eq!( + RepoState::RevertSequence.continue_subcommand(), + Some("revert") + ); + assert_eq!(RepoState::RebaseMerge.continue_subcommand(), Some("rebase")); + assert_eq!( + RepoState::ApplyMailboxOrRebase.continue_subcommand(), + Some("am") + ); + } + + #[test] + fn operation_kind_follows_the_state_that_stopped() { + assert_eq!(RepoState::Merge.operation_kind(), GitOperationKind::Merge); + assert_eq!( + RepoState::CherryPickSequence.operation_kind(), + GitOperationKind::CherryPick + ); + assert_eq!(RepoState::Revert.operation_kind(), GitOperationKind::Revert); + assert_eq!( + RepoState::RebaseInteractive.operation_kind(), + GitOperationKind::Rebase + ); + assert_eq!(RepoState::Bisect.operation_kind(), GitOperationKind::Bisect); + } + + use super::*; + + #[test] + fn ref_label_display_name() { + assert_eq!(RefLabel::Head.display_name(), "HEAD"); + assert_eq!(RefLabel::LocalBranch("main".into()).display_name(), "main"); + assert_eq!( + RefLabel::RemoteBranch("origin/main".into()).display_name(), + "origin/main" + ); + assert_eq!(RefLabel::Tag("v1.0.0".into()).display_name(), "v1.0.0"); + } + + #[test] + fn compact_refs_merge_matching_local_and_remote_branches() { + let refs = vec![ + RefLabel::Head, + RefLabel::LocalBranch("codex/topic".into()), + RefLabel::RemoteBranch("origin/codex/topic".into()), + RefLabel::RemoteBranch("upstream/other".into()), + ]; + + assert_eq!( + compact_ref_labels(&refs), + vec![ + CompactRefLabel { + label: RefLabel::Head, + remotes: vec![], + }, + CompactRefLabel { + label: RefLabel::LocalBranch("codex/topic".into()), + remotes: vec!["origin".into()], + }, + CompactRefLabel { + label: RefLabel::RemoteBranch("upstream/other".into()), + remotes: vec![], + }, + ] + ); + } + + #[test] + fn a_commit_with_no_local_branch_detaches() { + assert_eq!(commit_checkout(&[], Some("main")), CommitCheckout::Detached); + let refs = [ + RefLabel::Tag("v1.0.0".into()), + RefLabel::RemoteBranch("origin/main".into()), + ]; + assert_eq!(commit_checkout(&refs, None), CommitCheckout::Detached); + } + + #[test] + fn a_commit_a_local_branch_names_checks_that_branch_out() { + let refs = [ + RefLabel::LocalBranch("main".into()), + RefLabel::RemoteBranch("origin/main".into()), + ]; + assert_eq!( + commit_checkout(&refs, Some("feature")), + CommitCheckout::Branch("main".into()) + ); + // Detached at this very commit: back onto its branch. + let refs = [RefLabel::Head, RefLabel::LocalBranch("main".into())]; + assert_eq!( + commit_checkout(&refs, None), + CommitCheckout::Branch("main".into()) + ); + } + + #[test] + fn the_branch_already_checked_out_wins_over_its_neighbours() { + let refs = [ + RefLabel::Head, + RefLabel::LocalBranch("feature".into()), + RefLabel::LocalBranch("main".into()), + ]; + assert_eq!( + commit_checkout(&refs, Some("main")), + CommitCheckout::AlreadyCheckedOut("main".into()) + ); + assert_eq!( + commit_checkout(&refs, Some("other")), + CommitCheckout::Branch("feature".into()) + ); + } + + #[test] + fn file_change_kind_short_code() { + assert_eq!(FileChangeKind::Added.short_code(), "A"); + assert_eq!(FileChangeKind::Modified.short_code(), "M"); + assert_eq!(FileChangeKind::Deleted.short_code(), "D"); + assert_eq!(FileChangeKind::Renamed.short_code(), "R"); + assert_eq!(FileChangeKind::Copied.short_code(), "C"); + assert_eq!(FileChangeKind::TypeChange.short_code(), "T"); + assert_eq!(FileChangeKind::Untracked.short_code(), "?"); + assert_eq!(FileChangeKind::Conflicted.short_code(), "!"); + } + + #[test] + fn repo_state_from_git2() { + assert_eq!( + RepoState::from_git2(git2::RepositoryState::Clean), + RepoState::Clean + ); + assert_eq!( + RepoState::from_git2(git2::RepositoryState::Merge), + RepoState::Merge + ); + assert_eq!( + RepoState::from_git2(git2::RepositoryState::Revert), + RepoState::Revert + ); + assert_eq!( + RepoState::from_git2(git2::RepositoryState::RevertSequence), + RepoState::RevertSequence + ); + assert_eq!( + RepoState::from_git2(git2::RepositoryState::CherryPick), + RepoState::CherryPick + ); + assert_eq!( + RepoState::from_git2(git2::RepositoryState::CherryPickSequence), + RepoState::CherryPickSequence + ); + assert_eq!( + RepoState::from_git2(git2::RepositoryState::Bisect), + RepoState::Bisect + ); + assert_eq!( + RepoState::from_git2(git2::RepositoryState::Rebase), + RepoState::Rebase + ); + assert_eq!( + RepoState::from_git2(git2::RepositoryState::RebaseInteractive), + RepoState::RebaseInteractive + ); + assert_eq!( + RepoState::from_git2(git2::RepositoryState::RebaseMerge), + RepoState::RebaseMerge + ); + assert_eq!( + RepoState::from_git2(git2::RepositoryState::ApplyMailbox), + RepoState::ApplyMailbox + ); + assert_eq!( + RepoState::from_git2(git2::RepositoryState::ApplyMailboxOrRebase), + RepoState::ApplyMailboxOrRebase + ); + } + + #[test] + fn repo_state_is_clean() { + assert!(RepoState::Clean.is_clean()); + assert!(!RepoState::Merge.is_clean()); + assert!(!RepoState::Rebase.is_clean()); + assert!(!RepoState::RebaseInteractive.is_clean()); + assert!(!RepoState::CherryPick.is_clean()); + assert!(!RepoState::Bisect.is_clean()); + assert!(!RepoState::ApplyMailbox.is_clean()); + } + + #[test] + fn repo_state_label() { + assert_eq!(RepoState::Clean.label(), "Clean"); + assert_eq!(RepoState::Merge.label(), "Merging"); + assert_eq!(RepoState::Revert.label(), "Reverting"); + assert_eq!(RepoState::RevertSequence.label(), "Reverting"); + assert_eq!(RepoState::CherryPick.label(), "Cherry-picking"); + assert_eq!(RepoState::CherryPickSequence.label(), "Cherry-picking"); + assert_eq!(RepoState::Bisect.label(), "Bisecting"); + assert_eq!(RepoState::Rebase.label(), "Rebasing"); + assert_eq!( + RepoState::RebaseInteractive.label(), + "Rebasing (interactive)" + ); + assert_eq!(RepoState::RebaseMerge.label(), "Rebasing"); + assert_eq!(RepoState::ApplyMailbox.label(), "Applying patches"); + assert_eq!(RepoState::ApplyMailboxOrRebase.label(), "Applying patches"); + } + + #[test] + fn git_operation_kind_display_name() { + assert_eq!(GitOperationKind::Fetch.display_name(), "Fetch"); + assert_eq!(GitOperationKind::Pull.display_name(), "Pull"); + assert_eq!(GitOperationKind::Push.display_name(), "Push"); + assert_eq!(GitOperationKind::Checkout.display_name(), "Checkout"); + assert_eq!(GitOperationKind::Merge.display_name(), "Merge"); + assert_eq!(GitOperationKind::CherryPick.display_name(), "Cherry-pick"); + assert_eq!(GitOperationKind::Revert.display_name(), "Revert"); + assert_eq!(GitOperationKind::Reset.display_name(), "Reset"); + assert_eq!( + GitOperationKind::RemoveRemote.display_name(), + "Remove remote" + ); + assert_eq!(GitOperationKind::Commit.display_name(), "Commit"); + assert_eq!(GitOperationKind::Stage.display_name(), "Stage"); + assert_eq!(GitOperationKind::Unstage.display_name(), "Unstage"); + assert_eq!(GitOperationKind::Stash.display_name(), "Stash"); + assert_eq!(GitOperationKind::Branch.display_name(), "Branch"); + assert_eq!(GitOperationKind::Tag.display_name(), "Tag"); + assert_eq!(GitOperationKind::Discard.display_name(), "Discard"); + assert_eq!(GitOperationKind::Rebase.display_name(), "Rebase"); + assert_eq!(GitOperationKind::Bisect.display_name(), "Bisect"); + } +} diff --git a/third_party/rgitui/crates/rgitui_settings/Cargo.toml b/third_party/rgitui/crates/rgitui_settings/Cargo.toml new file mode 100644 index 000000000..e33b814fe --- /dev/null +++ b/third_party/rgitui/crates/rgitui_settings/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "rgitui_settings" +version = "0.4.1" +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +gpui.workspace = true +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +dirs.workspace = true +chrono.workspace = true +uuid.workspace = true +log.workspace = true +keyring = { version = "3.6.2", default-features = false, features = ["linux-native", "apple-native", "windows-native"] } diff --git a/third_party/rgitui/crates/rgitui_settings/src/lib.rs b/third_party/rgitui/crates/rgitui_settings/src/lib.rs new file mode 100644 index 000000000..f4859bf9f --- /dev/null +++ b/third_party/rgitui/crates/rgitui_settings/src/lib.rs @@ -0,0 +1,2143 @@ +use anyhow::Result; +use chrono::{DateTime, Utc}; +use gpui::{App, Global}; +use keyring::Entry; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashSet}; +use std::fmt; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::sync::mpsc::{channel, sync_channel, Sender, SyncSender}; +use std::sync::{Mutex, OnceLock, RwLock}; +use uuid::Uuid; + +/// Controls the compactness of the UI layout. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum Compactness { + Compact, + #[default] + Default, + Comfortable, +} + +/// How diffs are displayed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum DiffViewMode { + #[default] + Unified, + SideBySide, +} + +impl fmt::Display for DiffViewMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DiffViewMode::Unified => write!(f, "Unified"), + DiffViewMode::SideBySide => write!(f, "Side-by-Side"), + } + } +} + +/// Controls whether light or dark themes are shown in the theme picker. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum AppearanceMode { + #[default] + Auto, + Light, + Dark, +} + +impl fmt::Display for AppearanceMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AppearanceMode::Auto => write!(f, "Auto"), + AppearanceMode::Light => write!(f, "Light"), + AppearanceMode::Dark => write!(f, "Dark"), + } + } +} + +impl FromStr for AppearanceMode { + type Err = String; + fn from_str(s: &str) -> std::result::Result { + match s.to_lowercase().as_str() { + "auto" => Ok(AppearanceMode::Auto), + "light" => Ok(AppearanceMode::Light), + "dark" => Ok(AppearanceMode::Dark), + _ => Err(format!("Unknown appearance mode: {}", s)), + } + } +} + +impl FromStr for DiffViewMode { + type Err = String; + fn from_str(s: &str) -> std::result::Result { + match s.to_lowercase().replace('-', "_").as_str() { + "unified" => Ok(DiffViewMode::Unified), + "side_by_side" | "side by side" => Ok(DiffViewMode::SideBySide), + _ => Err(format!("Unknown diff view mode: {}", s)), + } + } +} + +/// The visual style used for the commit graph. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum GraphStyle { + Rails, + #[default] + Curved, + Angular, +} + +impl fmt::Display for GraphStyle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + GraphStyle::Rails => write!(f, "Rails"), + GraphStyle::Curved => write!(f, "Curved"), + GraphStyle::Angular => write!(f, "Angular"), + } + } +} + +impl FromStr for GraphStyle { + type Err = String; + fn from_str(s: &str) -> std::result::Result { + match s.to_lowercase().as_str() { + "rails" => Ok(GraphStyle::Rails), + "curved" => Ok(GraphStyle::Curved), + "angular" => Ok(GraphStyle::Angular), + _ => Err(format!("Unknown graph style: {}", s)), + } + } +} + +/// How often the application should automatically fetch from remotes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum AutoFetchInterval { + #[default] + Disabled, + OneMinute, + FiveMinutes, + FifteenMinutes, + ThirtyMinutes, +} + +impl AutoFetchInterval { + /// Every interval, in the order the settings UI lists them. + pub const ALL: &'static [AutoFetchInterval] = &[ + AutoFetchInterval::Disabled, + AutoFetchInterval::OneMinute, + AutoFetchInterval::FiveMinutes, + AutoFetchInterval::FifteenMinutes, + AutoFetchInterval::ThirtyMinutes, + ]; +} + +impl fmt::Display for AutoFetchInterval { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AutoFetchInterval::Disabled => write!(f, "Disabled"), + AutoFetchInterval::OneMinute => write!(f, "1 min"), + AutoFetchInterval::FiveMinutes => write!(f, "5 min"), + AutoFetchInterval::FifteenMinutes => write!(f, "15 min"), + AutoFetchInterval::ThirtyMinutes => write!(f, "30 min"), + } + } +} + +impl FromStr for AutoFetchInterval { + type Err = String; + fn from_str(s: &str) -> std::result::Result { + match s.to_lowercase().as_str() { + "disabled" => Ok(AutoFetchInterval::Disabled), + "1 min" | "one_minute" => Ok(AutoFetchInterval::OneMinute), + "5 min" | "five_minutes" => Ok(AutoFetchInterval::FiveMinutes), + "15 min" | "fifteen_minutes" => Ok(AutoFetchInterval::FifteenMinutes), + "30 min" | "thirty_minutes" => Ok(AutoFetchInterval::ThirtyMinutes), + _ => Err(format!("Unknown auto-fetch interval: {}", s)), + } + } +} + +impl Compactness { + pub fn multiplier(&self) -> f32 { + match self { + Compactness::Compact => 0.75, + Compactness::Default => 1.0, + Compactness::Comfortable => 1.25, + } + } + + pub fn spacing(&self, base: f32) -> f32 { + base * self.multiplier() + } +} + +impl fmt::Display for Compactness { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Compactness::Compact => write!(f, "Compact"), + Compactness::Default => write!(f, "Default"), + Compactness::Comfortable => write!(f, "Comfortable"), + } + } +} + +impl FromStr for Compactness { + type Err = String; + + fn from_str(s: &str) -> std::result::Result { + match s.to_lowercase().as_str() { + "compact" => Ok(Compactness::Compact), + "default" => Ok(Compactness::Default), + "comfortable" => Ok(Compactness::Comfortable), + _ => Err(format!("Unknown compactness value: {}", s)), + } + } +} + +/// Application settings persisted to disk. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppSettings { + #[serde(default = "default_settings_version")] + pub version: u32, + #[serde(default = "default_theme")] + pub theme: String, + #[serde(default)] + pub ui_font: String, + #[serde(default)] + pub ai: AiSettings, + #[serde(default)] + pub git: GitSettings, + #[serde(default)] + pub recent_repos: Vec, + #[serde(default = "default_max_recent")] + pub max_recent_repos: usize, + #[serde(default)] + pub last_workspace: Vec, + #[serde(default)] + pub layout: LayoutSettings, + #[serde(default)] + pub workspaces: Vec, + #[serde(default)] + pub active_workspace_id: Option, + #[serde(default)] + pub clean_exit: bool, + #[serde(default)] + pub compactness: Compactness, + #[serde(default = "default_appearance_mode")] + pub appearance_mode: AppearanceMode, + #[serde(default)] + pub terminal_command: String, + #[serde(default)] + pub editor_command: String, + #[serde(default = "default_font_size")] + pub font_size: u32, + #[serde(default = "default_show_line_numbers_in_diff")] + pub show_line_numbers_in_diff: bool, + #[serde(default)] + pub diff_view_mode: DiffViewMode, + #[serde(default)] + pub diff_wrap_lines: bool, + #[serde(default)] + pub graph_style: GraphStyle, + #[serde(default = "default_show_subject_column")] + pub show_subject_column: bool, + #[serde(default = "default_author_column_width")] + pub author_column_width: f32, + #[serde(default = "default_date_column_width")] + pub date_column_width: f32, + #[serde(default)] + pub auto_fetch_interval: AutoFetchInterval, + #[serde(default = "default_confirm_destructive")] + pub confirm_destructive_operations: bool, + #[serde(default = "default_auto_check_updates")] + pub auto_check_updates: bool, + /// Timestamp of the most recent successful GitHub release check. Used to + /// throttle background update polling so restarts don't spam the API. + #[serde(default)] + pub last_update_check_at: Option>, + #[serde(default = "default_commit_limit")] + pub commit_limit: usize, + #[serde(default)] + pub watch_all_worktrees: bool, + /// Last known position/size of the standalone Settings window. Restored + /// on next open if the saved origin still falls within a connected display. + #[serde(default)] + pub settings_window_bounds: Option, +} + +/// Serializable rectangle in screen coordinates. Used to persist window +/// geometry across sessions independently of GPUI's `Bounds`. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct SavedWindowBounds { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +/// Current settings version. Increment when making breaking changes. +const CURRENT_SETTINGS_VERSION: u32 = 3; + +fn default_settings_version() -> u32 { + CURRENT_SETTINGS_VERSION +} + +fn default_theme() -> String { + "Catppuccin Mocha".into() +} + +fn default_appearance_mode() -> AppearanceMode { + AppearanceMode::Auto +} + +fn default_font_size() -> u32 { + 14 +} + +fn default_show_line_numbers_in_diff() -> bool { + true +} + +fn default_show_subject_column() -> bool { + true +} + +fn default_author_column_width() -> f32 { + 140.0 +} + +fn default_date_column_width() -> f32 { + 100.0 +} + +fn default_confirm_destructive() -> bool { + true +} + +fn default_auto_check_updates() -> bool { + true +} + +fn default_commit_limit() -> usize { + 1000 +} + +/// A persisted local workspace snapshot. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StoredWorkspace { + pub id: String, + pub name: String, + #[serde(default)] + pub repos: Vec, + #[serde(default)] + pub active_repo_index: usize, + #[serde(default)] + pub layout: LayoutSettings, + #[serde(default = "utc_now")] + pub created_at: DateTime, + #[serde(default = "utc_now")] + pub last_opened_at: DateTime, +} + +/// Persisted layout dimensions. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LayoutSettings { + #[serde(default = "default_sidebar_width")] + pub sidebar_width: f32, + #[serde(default = "default_detail_panel_width")] + pub detail_panel_width: f32, + #[serde(default = "default_diff_viewer_height")] + pub diff_viewer_height: f32, + #[serde(default = "default_commit_input_height")] + pub commit_input_height: f32, + /// Whether the commit graph is hidden, handing its space to the bottom + /// panel. Files written before the option existed load with it off. + #[serde(default)] + pub graph_hidden: bool, +} + +fn default_sidebar_width() -> f32 { + 276.0 +} +fn default_detail_panel_width() -> f32 { + 352.0 +} +fn default_diff_viewer_height() -> f32 { + 345.0 +} +fn default_commit_input_height() -> f32 { + 385.0 +} + +impl Default for LayoutSettings { + fn default() -> Self { + Self { + sidebar_width: default_sidebar_width(), + detail_panel_width: default_detail_panel_width(), + diff_viewer_height: default_diff_viewer_height(), + commit_input_height: default_commit_input_height(), + graph_hidden: false, + } + } +} + +fn default_max_recent() -> usize { + 20 +} + +/// The AI providers rgitui can talk to. +/// +/// Persisted by its lowercase id, and the single source of truth for endpoint +/// shape, auth style and default model. Dispatching on a bare string is what +/// let a hand-edited `settings.json` reach the network layer before failing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[derive(Default)] +pub enum AiProvider { + #[default] + Gemini, + #[serde(rename = "openai")] + OpenAi, + Anthropic, + #[serde(rename = "deepseek")] + DeepSeek, + #[serde(rename = "openrouter")] + OpenRouter, +} + +impl AiProvider { + /// Every provider, in the order the settings UI lists them. + pub const ALL: &'static [AiProvider] = &[ + AiProvider::Gemini, + AiProvider::OpenAi, + AiProvider::Anthropic, + AiProvider::DeepSeek, + AiProvider::OpenRouter, + ]; + + /// The stable id used in `settings.json`, keychain accounts and catalogue + /// cache filenames. Changing one needs a migration. + pub fn id(self) -> &'static str { + match self { + AiProvider::Gemini => "gemini", + AiProvider::OpenAi => "openai", + AiProvider::Anthropic => "anthropic", + AiProvider::DeepSeek => "deepseek", + AiProvider::OpenRouter => "openrouter", + } + } + + pub fn display_name(self) -> &'static str { + match self { + AiProvider::Gemini => "Google Gemini", + AiProvider::OpenAi => "OpenAI", + AiProvider::Anthropic => "Anthropic", + AiProvider::DeepSeek => "DeepSeek", + AiProvider::OpenRouter => "OpenRouter", + } + } + + /// Parse a persisted id. Case- and whitespace-insensitive, so a hand-edited + /// `"Anthropic"` resolves instead of silently falling back. + pub fn from_id(value: &str) -> Option { + let normalized = value.trim().to_ascii_lowercase(); + Self::ALL + .iter() + .copied() + .find(|provider| provider.id() == normalized) + } + + /// The GA model a fresh install (or a provider switch with no remembered + /// choice) uses: cheap, fast and tool-capable, because commit-message + /// generation does not need a frontier model. + pub fn default_model(self) -> &'static str { + match self { + AiProvider::Gemini => "gemini-3.1-flash-lite", + AiProvider::OpenAi => "gpt-5.6-luna", + AiProvider::Anthropic => "claude-haiku-4-5", + AiProvider::DeepSeek => "deepseek-v4-flash", + AiProvider::OpenRouter => "google/gemini-3.1-flash-lite", + } + } + + /// Where the user creates an API key for this provider. + pub fn key_url(self) -> &'static str { + match self { + AiProvider::Gemini => "https://aistudio.google.com/apikey", + AiProvider::OpenAi => "https://platform.openai.com/api-keys", + AiProvider::Anthropic => "https://console.anthropic.com/settings/keys", + AiProvider::DeepSeek => "https://platform.deepseek.com/api_keys", + AiProvider::OpenRouter => "https://openrouter.ai/keys", + } + } + + /// The host requests reach by default. Shown when warning about a + /// `base_url_override` so the user sees what they are replacing. + pub fn default_host(self) -> &'static str { + match self { + AiProvider::Gemini => "generativelanguage.googleapis.com", + AiProvider::OpenAi => "api.openai.com", + AiProvider::Anthropic => "api.anthropic.com", + AiProvider::DeepSeek => "api.deepseek.com", + AiProvider::OpenRouter => "openrouter.ai", + } + } + + /// Whether this provider speaks the OpenAI `/chat/completions` shape. + /// Only these honour `base_url_override`. + pub fn is_openai_compatible(self) -> bool { + matches!( + self, + AiProvider::OpenAi | AiProvider::DeepSeek | AiProvider::OpenRouter + ) + } +} + +impl fmt::Display for AiProvider { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.display_name()) + } +} + +/// Deserialize a provider id leniently: an unknown value falls back to the +/// default rather than failing the whole settings file. [`init`] surfaces the +/// unknown value through `load_warnings` so the fallback is never silent. +fn deserialize_ai_provider<'de, D>(deserializer: D) -> std::result::Result +where + D: serde::Deserializer<'de>, +{ + let raw = String::deserialize(deserializer)?; + Ok(AiProvider::from_id(&raw).unwrap_or_default()) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AiSettings { + #[serde( + default = "default_ai_provider", + deserialize_with = "deserialize_ai_provider" + )] + pub provider: AiProvider, + #[serde(rename = "api_key", default, skip_serializing)] + pub legacy_api_key: Option, + /// Whether the *active* provider holds a key. Derived from + /// `has_api_key_for`; retained so existing readers keep working. + #[serde(default)] + pub has_api_key: bool, + /// Which providers hold a key in the OS keychain. Flags only — never the + /// secret itself. + #[serde(default)] + pub has_api_key_for: BTreeMap, + #[serde(default = "default_ai_model")] + pub model: String, + /// Per-provider model pin, keyed by provider id. Preserves the user's + /// choice per provider instead of resetting it on every provider switch. + #[serde(default)] + pub models_by_provider: BTreeMap, + /// Override endpoint for OpenAI-compatible providers (LiteLLM, Ollama's + /// `/v1`, self-hosted gateways). Empty means use the built-in URL — the + /// default is deliberately not stored here so it cannot freeze at whatever + /// shipped. + #[serde(default)] + pub base_url_override: String, + /// Send `HTTP-Referer`/`X-Title` to OpenRouter for leaderboard attribution. + #[serde(default = "default_openrouter_attribution")] + pub openrouter_attribution: bool, + #[serde(default = "default_commit_style")] + pub commit_style: String, + #[serde(default = "default_ai_enabled")] + pub enabled: bool, + #[serde(default = "default_inject_project_context")] + pub inject_project_context: bool, + #[serde(default = "default_use_tools")] + pub use_tools: bool, +} + +impl AiSettings { + /// The model pinned for `provider`, falling back to that provider's GA + /// default. Never returns another provider's model. + pub fn model_for(&self, provider: AiProvider) -> String { + if provider == self.provider && !self.model.trim().is_empty() { + return self.model.clone(); + } + self.models_by_provider + .get(provider.id()) + .filter(|model| !model.trim().is_empty()) + .cloned() + .unwrap_or_else(|| provider.default_model().to_string()) + } + + /// Whether `provider` has a key in the keychain, according to the persisted + /// flags. Does not touch the keychain. + pub fn has_key_for(&self, provider: AiProvider) -> bool { + self.has_api_key_for + .get(provider.id()) + .copied() + .unwrap_or(false) + } + + /// Record whether `provider` holds a key, keeping the active-provider + /// mirror `has_api_key` in step. + pub fn set_has_key_for(&mut self, provider: AiProvider, has_key: bool) { + self.has_api_key_for + .insert(provider.id().to_string(), has_key); + if provider == self.provider { + self.has_api_key = has_key; + } + } + + /// Point the active provider at `provider`, remembering the model the + /// previous provider used so switching back restores it. + pub fn set_active_provider(&mut self, provider: AiProvider) { + let previous = self.provider; + if !self.model.trim().is_empty() { + self.models_by_provider + .insert(previous.id().to_string(), self.model.clone()); + } + // Resolve the incoming model *before* reassigning `provider`, so + // `model_for`'s active-provider shortcut cannot hand back the model the + // previous provider was using. + let next_model = self + .models_by_provider + .get(provider.id()) + .filter(|model| !model.trim().is_empty()) + .cloned() + .unwrap_or_else(|| provider.default_model().to_string()); + self.provider = provider; + self.model = next_model; + self.has_api_key = self.has_key_for(provider); + } + + /// Pin `model` for the active provider. + pub fn set_active_model(&mut self, model: impl Into) { + let model = model.into(); + self.models_by_provider + .insert(self.provider.id().to_string(), model.clone()); + self.model = model; + } +} + +fn default_use_tools() -> bool { + true +} + +fn default_ai_enabled() -> bool { + true +} + +fn default_inject_project_context() -> bool { + true +} + +fn default_openrouter_attribution() -> bool { + true +} + +fn default_ai_provider() -> AiProvider { + AiProvider::Gemini +} + +fn default_ai_model() -> String { + default_ai_provider().default_model().into() +} + +fn default_commit_style() -> String { + "conventional".into() +} + +/// Git authentication and signing settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitSettings { + #[serde(rename = "https_token", default, skip_serializing)] + pub legacy_https_token: Option, + #[serde(default)] + pub has_https_token: bool, + #[serde(default)] + pub ssh_key_path: Option, + #[serde(default)] + pub gpg_key_id: Option, + #[serde(default)] + pub sign_commits: bool, + #[serde(default = "default_git_providers")] + pub providers: Vec, +} + +/// A provider-specific HTTPS auth entry. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GitProviderSettings { + pub id: String, + #[serde(default = "default_git_provider_kind")] + pub kind: String, + pub display_name: String, + pub host: String, + #[serde(default)] + pub username: String, + #[serde(rename = "token", default, skip_serializing)] + pub legacy_token: Option, + #[serde(default)] + pub has_token: bool, + #[serde(default)] + pub use_for_https: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct AuthRuntimeState { + /// Resolved AI keys, keyed by provider id. One slot per provider so a + /// provider switch cannot transmit the previous provider's credential. + pub ai_api_keys: BTreeMap, + pub git: GitAuthRuntime, +} + +#[derive(Debug, Clone, Default)] +pub struct GitAuthRuntime { + pub default_https_token: Option, + pub ssh_key_path: Option, + pub gpg_key_id: Option, + pub sign_commits: bool, + pub providers: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct GitProviderRuntime { + pub id: String, + pub kind: String, + pub display_name: String, + pub host: String, + pub username: String, + pub token: Option, + pub use_for_https: bool, +} + +impl Default for GitSettings { + fn default() -> Self { + Self { + legacy_https_token: None, + has_https_token: false, + ssh_key_path: None, + gpg_key_id: None, + sign_commits: false, + providers: default_git_providers(), + } + } +} + +impl Default for AiSettings { + fn default() -> Self { + Self { + provider: default_ai_provider(), + legacy_api_key: None, + has_api_key: false, + has_api_key_for: BTreeMap::new(), + model: default_ai_model(), + models_by_provider: BTreeMap::new(), + base_url_override: String::new(), + openrouter_attribution: default_openrouter_attribution(), + commit_style: default_commit_style(), + enabled: true, + inject_project_context: default_inject_project_context(), + use_tools: default_use_tools(), + } + } +} + +impl Default for AppSettings { + fn default() -> Self { + Self { + version: default_settings_version(), + theme: default_theme(), + ui_font: String::new(), + ai: AiSettings::default(), + git: GitSettings::default(), + recent_repos: Vec::new(), + max_recent_repos: default_max_recent(), + last_workspace: Vec::new(), + layout: LayoutSettings::default(), + workspaces: Vec::new(), + active_workspace_id: None, + clean_exit: true, // First run is considered clean + compactness: Compactness::default(), + appearance_mode: default_appearance_mode(), + terminal_command: String::new(), + editor_command: String::new(), + font_size: default_font_size(), + show_line_numbers_in_diff: default_show_line_numbers_in_diff(), + diff_view_mode: DiffViewMode::default(), + diff_wrap_lines: false, + graph_style: GraphStyle::default(), + show_subject_column: default_show_subject_column(), + author_column_width: default_author_column_width(), + date_column_width: default_date_column_width(), + auto_fetch_interval: AutoFetchInterval::default(), + confirm_destructive_operations: default_confirm_destructive(), + auto_check_updates: default_auto_check_updates(), + last_update_check_at: None, + commit_limit: default_commit_limit(), + watch_all_worktrees: false, + settings_window_bounds: None, + } + } +} + +/// Global settings state. +pub struct SettingsState { + pub settings: AppSettings, + config_path: PathBuf, + load_warnings: Vec, +} + +impl Global for SettingsState {} + +impl SettingsState { + pub fn settings(&self) -> &AppSettings { + &self.settings + } + + pub fn settings_mut(&mut self) -> &mut AppSettings { + &mut self.settings + } + + pub fn take_warnings(&mut self) -> Vec { + std::mem::take(&mut self.load_warnings) + } + + /// The most recently persisted bounds for the standalone Settings window, + /// or `None` if the user has never moved/resized it. + pub fn settings_window_bounds(&self) -> Option { + self.settings.settings_window_bounds + } + + /// Update the persisted Settings window bounds. Caller is responsible for + /// invoking [`SettingsState::save`] afterwards if write-through is desired. + pub fn set_settings_window_bounds(&mut self, bounds: Option) { + self.settings.settings_window_bounds = bounds; + } + + /// Persist the current settings to disk. + /// + /// The JSON is serialized synchronously on the calling thread so the write + /// reflects the state at the moment of the call, then handed to a single + /// dedicated writer thread. Because that thread drains its queue in + /// FIFO order, the on-disk file always reflects the most recent `save`, + /// eliminating the race where two concurrent writers could land in the + /// wrong order. + pub fn save(&self) -> Result<()> { + sync_auth_runtime(&self.settings); + let json = serde_json::to_string_pretty(&self.settings)?; + enqueue_write(WriteRequest { + config_path: self.config_path.clone(), + json, + ack: None, + }); + Ok(()) + } + + /// Persist the current settings to disk and block until the write has + /// completed (or failed). Use this on shutdown paths so the final state is + /// guaranteed to reach disk before the process exits, rather than racing a + /// detached writer thread that may be killed mid-write. + pub fn save_blocking(&self) -> Result<()> { + sync_auth_runtime(&self.settings); + let json = serde_json::to_string_pretty(&self.settings)?; + let (ack_tx, ack_rx) = sync_channel::<()>(1); + enqueue_write(WriteRequest { + config_path: self.config_path.clone(), + json, + ack: Some(ack_tx), + }); + // The writer always sends the ack, even on write failure, so this only + // blocks until the queued write (and every write enqueued before it) + // has drained. A dropped sender (writer thread gone) also unblocks us. + let _ = ack_rx.recv(); + Ok(()) + } + + pub fn set_last_workspace(&mut self, repos: Vec) { + self.settings.last_workspace = dedup_paths(repos); + } + + /// Record that an update check just completed. + pub fn mark_update_check_completed(&mut self) { + self.settings.last_update_check_at = Some(Utc::now()); + if let Err(error) = self.save() { + log::warn!("Failed to persist update-check timestamp: {}", error); + } + } + + pub fn add_recent_repo(&mut self, path: PathBuf) { + self.settings.recent_repos.retain(|p| p != &path); + self.settings.recent_repos.insert(0, path); + self.settings + .recent_repos + .truncate(self.settings.max_recent_repos); + } + + pub fn recent_workspaces(&self, limit: usize) -> Vec { + let mut workspaces = self.settings.workspaces.clone(); + workspaces.sort_by_key(|w| std::cmp::Reverse(w.last_opened_at)); + if limit > 0 { + workspaces.truncate(limit); + } + workspaces + } + + pub fn active_workspace(&self) -> Option<&StoredWorkspace> { + let active_id = self.settings.active_workspace_id.as_ref()?; + self.settings + .workspaces + .iter() + .find(|ws| &ws.id == active_id) + } + + pub fn workspace(&self, id: &str) -> Option<&StoredWorkspace> { + self.settings.workspaces.iter().find(|ws| ws.id == id) + } + + pub fn clear_active_workspace(&mut self) { + self.settings.active_workspace_id = None; + self.settings.last_workspace.clear(); + } + + /// Mark that the app is exiting cleanly (user-initiated close). + pub fn mark_clean_exit(&mut self) { + self.settings.clean_exit = true; + let _ = self.save_blocking(); + } + + /// Mark that the app is starting up (clear the clean exit flag). + /// Returns whether the previous session ended cleanly. + pub fn mark_startup(&mut self) -> bool { + let was_clean = self.settings.clean_exit; + self.settings.clean_exit = false; + let _ = self.save(); + was_clean + } + + /// Check if the last session ended cleanly without modifying state. + pub fn was_clean_exit(&self) -> bool { + self.settings.clean_exit + } + + pub fn save_workspace_snapshot( + &mut self, + workspace_id: Option<&str>, + repos: Vec, + active_repo_index: usize, + layout: LayoutSettings, + ) -> Option { + let repos = dedup_paths(repos); + if repos.is_empty() { + self.clear_active_workspace(); + return None; + } + + let now = Utc::now(); + let existing_index = workspace_id + .and_then(|id| self.settings.workspaces.iter().position(|ws| ws.id == id)) + .or_else(|| { + self.settings + .workspaces + .iter() + .position(|ws| ws.repos == repos) + }); + + let active_repo_index = active_repo_index.min(repos.len().saturating_sub(1)); + let id = if let Some(index) = existing_index { + let workspace = &mut self.settings.workspaces[index]; + workspace.repos = repos.clone(); + workspace.active_repo_index = active_repo_index; + workspace.layout = layout.clone(); + workspace.last_opened_at = now; + workspace.id.clone() + } else { + let workspace = StoredWorkspace { + id: Uuid::new_v4().to_string(), + name: workspace_name_from_repos(&repos), + repos: repos.clone(), + active_repo_index, + layout, + created_at: now, + last_opened_at: now, + }; + let id = workspace.id.clone(); + self.settings.workspaces.push(workspace); + id + }; + + self.settings.active_workspace_id = Some(id.clone()); + self.settings.last_workspace = repos; + Some(id) + } + + pub fn activate_workspace(&mut self, workspace_id: &str) -> bool { + if let Some(workspace) = self + .settings + .workspaces + .iter_mut() + .find(|workspace| workspace.id == workspace_id) + { + workspace.last_opened_at = Utc::now(); + self.settings.active_workspace_id = Some(workspace.id.clone()); + self.settings.last_workspace = workspace.repos.clone(); + true + } else { + false + } + } + + pub fn migrate_legacy_workspace_data(&mut self) { + self.settings.recent_repos = dedup_paths(self.settings.recent_repos.clone()); + self.settings.last_workspace = dedup_paths(self.settings.last_workspace.clone()); + + if self.settings.workspaces.is_empty() && !self.settings.last_workspace.is_empty() { + let now = Utc::now(); + let workspace = StoredWorkspace { + id: Uuid::new_v4().to_string(), + name: workspace_name_from_repos(&self.settings.last_workspace), + repos: self.settings.last_workspace.clone(), + active_repo_index: 0, + layout: self.settings.layout.clone(), + created_at: now, + last_opened_at: now, + }; + self.settings.active_workspace_id = Some(workspace.id.clone()); + self.settings.workspaces.push(workspace); + } + + if let Some(active_id) = self.settings.active_workspace_id.clone() { + if !self + .settings + .workspaces + .iter() + .any(|workspace| workspace.id == active_id) + { + self.settings.active_workspace_id = self + .settings + .workspaces + .iter() + .max_by_key(|workspace| workspace.last_opened_at) + .map(|workspace| workspace.id.clone()); + } + } + } + + /// Run version-based migrations. Returns true if any migration was applied. + pub fn migrate_settings(&mut self) -> bool { + let mut migrated = false; + let original_version = self.settings.version; + + // Migration 0 -> 1: Settings without version field (serde default) + // No action needed - serde defaults handle this + if self.settings.version == 0 { + self.settings.version = 1; + migrated = true; + log::info!("Migrated settings from version 0 to 1"); + } + + // Migration 1 -> 2: Add graph column width settings + if self.settings.version == 1 { + self.settings.author_column_width = default_author_column_width(); + self.settings.date_column_width = default_date_column_width(); + self.settings.version = 2; + migrated = true; + log::info!("Migrated settings from version 1 to 2"); + } + + // Migration 2 -> 3: AI keys became per-provider, and the shipped + // default model was a retired id absent from the picker. Remap the + // known-dead ids to their successors rather than leaving a pin that + // renders as no selection at all and 404s when used. + if self.settings.version == 2 { + if let Some(successor) = retired_model_successor(&self.settings.ai.model) { + log::info!( + "Remapping retired AI model '{}' to '{}'", + self.settings.ai.model, + successor + ); + self.settings.ai.model = successor.to_string(); + } + self.settings.version = 3; + migrated = true; + log::info!("Migrated settings from version 2 to 3"); + } + + // Ensure version is current + if self.settings.version < CURRENT_SETTINGS_VERSION { + self.settings.version = CURRENT_SETTINGS_VERSION; + migrated = true; + log::info!( + "Updated settings version from {} to {}", + original_version, + CURRENT_SETTINGS_VERSION + ); + } + + migrated + } + + /// The API key for the active AI provider. + pub fn ai_api_key(&self) -> Option { + self.ai_api_key_for(self.settings.ai.provider) + } + + /// The API key stored for a specific provider, materialized only for the + /// caller that asked for it. + pub fn ai_api_key_for(&self, provider: AiProvider) -> Option { + with_auth_runtime(|runtime| runtime.ai_api_keys.get(provider.id()).cloned()) + } + + /// Whether the active provider has a key, without cloning any secret. + /// + /// Render paths must use this rather than `ai_api_key().is_some()`, which + /// deep-clones every credential the app holds on every frame. + pub fn has_ai_api_key(&self) -> bool { + self.settings.ai.has_key_for(self.settings.ai.provider) + } + + /// Whether `provider` has a key, without cloning any secret. + pub fn has_ai_api_key_for(&self, provider: AiProvider) -> bool { + self.settings.ai.has_key_for(provider) + } + + pub fn git_https_token(&self) -> Option { + current_auth_runtime().git.default_https_token + } + + pub fn provider_token(&self, provider_id: &str) -> Option { + current_auth_runtime() + .git + .providers + .into_iter() + .find(|provider| provider.id == provider_id) + .and_then(|provider| provider.token) + } + + /// Store (or clear) the API key for the active provider. + pub fn set_ai_api_key(&mut self, value: Option<&str>) -> Result<()> { + self.set_ai_api_key_for(self.settings.ai.provider, value) + } + + /// Store (or clear) the API key for a specific provider. + /// + /// The keychain write happens *before* any settings mutation, so a failed + /// write leaves the recorded flags and the resolved runtime key agreeing + /// with what is actually in the keychain. + pub fn set_ai_api_key_for(&mut self, provider: AiProvider, value: Option<&str>) -> Result<()> { + let has_key = write_secret(&ai_provider_account(provider.id()), value)?; + self.settings.ai.legacy_api_key = None; + self.settings.ai.set_has_key_for(provider, has_key); + sync_auth_runtime(&self.settings); + Ok(()) + } + + pub fn set_git_https_token(&mut self, value: Option<&str>) -> Result<()> { + self.settings.git.legacy_https_token = None; + self.settings.git.has_https_token = write_secret(GIT_DEFAULT_HTTPS_ACCOUNT, value)?; + sync_auth_runtime(&self.settings); + Ok(()) + } + + pub fn replace_git_providers(&mut self, providers: Vec) { + let removed_ids: Vec = self + .settings + .git + .providers + .iter() + .filter(|existing| !providers.iter().any(|provider| provider.id == existing.id)) + .map(|provider| provider.id.clone()) + .collect(); + + self.settings.git.providers = providers; + for provider_id in removed_ids { + let _ = delete_secret(&git_provider_account(&provider_id)); + } + sync_auth_runtime(&self.settings); + } + + pub fn set_git_provider_token(&mut self, provider_id: &str, value: Option<&str>) -> Result<()> { + // Write the secret unconditionally, keyed by provider id. A provider that + // was just added in the settings window is not yet present in + // `self.settings.git.providers` (it only arrives via + // `replace_git_providers`), so guarding the keyring write on its presence + // here would silently drop the token on first save. + let has_token = write_secret(&git_provider_account(provider_id), value)?; + if let Some(provider) = self + .settings + .git + .providers + .iter_mut() + .find(|provider| provider.id == provider_id) + { + provider.legacy_token = None; + provider.has_token = has_token; + } + sync_auth_runtime(&self.settings); + Ok(()) + } + + pub fn migrate_legacy_secrets(&mut self) -> Result { + let mut migrated = false; + + if let Some(api_key) = self.settings.ai.legacy_api_key.clone() { + if !self.settings.ai.has_api_key && write_secret(AI_SECRET_ACCOUNT, Some(&api_key))? { + self.settings.ai.has_api_key = true; + self.settings.ai.legacy_api_key = None; + migrated = true; + } + } + + // Promote the single `ai/default` secret into the slot for whichever + // provider was active when it was written. `ai/default` is left in + // place so a downgrade still finds its key; a later save of that + // provider overwrites the new slot. Idempotent: the promotion is + // skipped once the per-provider slot exists. + if self.settings.ai.has_api_key { + let active = self.settings.ai.provider; + let account = ai_provider_account(active.id()); + if read_secret(&account).is_none() { + if let Some(key) = read_secret(AI_SECRET_ACCOUNT) { + if write_secret(&account, Some(&key))? { + self.settings.ai.set_has_key_for(active, true); + migrated = true; + log::info!( + "Promoted the shared AI key into the '{}' provider slot", + active.id() + ); + } + } + } + } + + // Re-derive the per-provider flags from what the keychain actually + // holds. A flag that says "connected" for a provider with no key is + // exactly the false-connected state the per-provider split fixes. + for provider in AiProvider::ALL { + let present = read_secret(&ai_provider_account(provider.id())).is_some(); + if self.settings.ai.has_key_for(*provider) != present { + self.settings.ai.set_has_key_for(*provider, present); + migrated = true; + } + } + + if let Some(token) = self.settings.git.legacy_https_token.clone() { + if !self.settings.git.has_https_token + && write_secret(GIT_DEFAULT_HTTPS_ACCOUNT, Some(&token))? + { + self.settings.git.has_https_token = true; + self.settings.git.legacy_https_token = None; + migrated = true; + } + } + + for provider in &mut self.settings.git.providers { + if let Some(token) = provider.legacy_token.clone() { + if !provider.has_token + && write_secret(&git_provider_account(&provider.id), Some(&token))? + { + provider.has_token = true; + provider.legacy_token = None; + migrated = true; + } + } + } + + sync_auth_runtime(&self.settings); + Ok(migrated) + } +} + +/// Where settings, the keymap, themes and cached avatars live. +pub fn config_dir() -> PathBuf { + state_root().config.clone() +} + +/// Where regenerable caches live — history, and anything else that is an +/// optimisation rather than user data. +pub fn cache_dir() -> PathBuf { + state_root().cache.clone() +} + +/// The two directories rgitui persists to, resolved once per process. +struct StateRoot { + config: PathBuf, + cache: PathBuf, +} + +static STATE_ROOT: OnceLock = OnceLock::new(); + +fn state_root() -> &'static StateRoot { + STATE_ROOT.get_or_init(|| StateRoot { + config: dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("rgitui"), + cache: dirs::cache_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("rgitui"), + }) +} + +/// Points config and cache at `root` instead of the user's real directories. +/// +/// This exists for measurement. A benchmark that reads and writes the same +/// settings file and history cache as the installed app is measuring whatever +/// the last run happened to leave behind, and leaves its own corpus in the +/// user's recent-repository list on the way out. Redirecting both to a scratch +/// directory is what makes a run start from a known state and end without a +/// trace of itself. +/// +/// Call before anything reads either path — first read wins, and this returns +/// `false` if it lost that race rather than pretending to have taken effect. +#[must_use] +pub fn redirect_state_to(root: &Path) -> bool { + STATE_ROOT + .set(StateRoot { + config: root.join("config"), + cache: root.join("cache"), + }) + .is_ok() +} + +/// File name of the settings file inside [`config_dir`]. +const SETTINGS_FILE_NAME: &str = "settings.json"; + +/// File name of the user keymap inside [`config_dir`]. +const KEYMAP_FILE_NAME: &str = "keymap.json"; + +/// Path of the settings file. +pub fn settings_path() -> PathBuf { + config_dir().join(SETTINGS_FILE_NAME) +} + +/// Path of the user keymap, alongside the settings file. +pub fn keymap_path() -> PathBuf { + config_dir().join(KEYMAP_FILE_NAME) +} + +/// A single queued settings write. `json` is pre-serialized on the caller's +/// thread so the snapshot reflects state at call time; `ack`, when present, +/// is fired after the write attempt completes so a blocking caller can wait. +struct WriteRequest { + config_path: PathBuf, + json: String, + ack: Option>, +} + +/// Sender for the dedicated settings writer thread. Wrapped in a `Mutex` +/// because the `Sync`-ness of `Sender` is not guaranteed across toolchains and +/// the sender is shared through a process-wide `OnceLock`. +fn write_sender() -> &'static Mutex> { + static WRITE_SENDER: OnceLock>> = OnceLock::new(); + WRITE_SENDER.get_or_init(|| { + let (tx, rx) = channel::(); + std::thread::Builder::new() + .name("rgitui-settings-writer".to_string()) + .spawn(move || { + // A single writer drains the queue in FIFO order, so on-disk + // state always matches the order saves were requested. + while let Ok(request) = rx.recv() { + write_settings_file(&request.config_path, &request.json); + // Fire the ack on every path (including write failure) so a + // `save_blocking` caller never deadlocks on a failed write. + if let Some(ack) = request.ack { + let _ = ack.send(()); + } + } + }) + .expect("failed to spawn settings writer thread"); + Mutex::new(tx) + }) +} + +fn enqueue_write(request: WriteRequest) { + let sender = write_sender() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if sender.send(request).is_err() { + log::error!("Settings writer thread is no longer running; write dropped"); + } +} + +/// Perform the actual atomic write of serialized settings JSON: write to a +/// temp file then rename over the target, falling back to a direct write if the +/// rename fails. +fn write_settings_file(config_path: &Path, json: &str) { + if let Some(parent) = config_path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + log::error!("Failed to create settings directory: {}", e); + return; + } + } + let tmp_path = config_path.with_extension("json.tmp"); + if let Err(e) = std::fs::write(&tmp_path, json) { + log::error!("Failed to write settings temp file: {}", e); + return; + } + if let Err(e) = std::fs::rename(&tmp_path, config_path) { + log::error!("Failed to rename settings file: {}", e); + if let Err(e2) = std::fs::write(config_path, json) { + log::error!("Fallback write also failed: {}", e2); + } + let _ = std::fs::remove_file(&tmp_path); + } +} + +/// Initialize settings. Must be called during app init. +pub fn init(cx: &mut App) { + let config_path = settings_path(); + let mut load_warnings = Vec::new(); + let settings = if config_path.exists() { + match std::fs::read_to_string(&config_path) { + Ok(json) => match serde_json::from_str::(&json) { + Ok(settings) => { + // The provider deserializer falls back rather than failing + // the whole file, so re-read the raw value to tell the user + // which id was not understood. Without this the settings UI + // would simply show a different provider selected than the + // one in their file. + if let Some(raw) = raw_ai_provider(&json) { + if AiProvider::from_id(&raw).is_none() { + let msg = format!( + "Unknown AI provider \"{}\" in settings.json; using {}. Valid values: {}.", + raw, + settings.ai.provider.id(), + AiProvider::ALL + .iter() + .map(|provider| provider.id()) + .collect::>() + .join(", ") + ); + log::warn!("{}", msg); + load_warnings.push(msg); + } + } + settings + } + Err(e) => { + // Preserve the unparseable file so the user can recover any + // hand-edited content instead of silently overwriting it with + // defaults. The timestamp suffix avoids `:` because that is an + // illegal filename character on Windows. + let stamp = Utc::now().format("%Y%m%d%H%M%S"); + let backup_name = format!( + "{}.corrupt-{}", + config_path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "settings.json".to_string()), + stamp + ); + let backup_path = config_path.with_file_name(backup_name); + let msg = match std::fs::rename(&config_path, &backup_path) { + Ok(()) => format!( + "Settings file could not be parsed and was preserved at {}. Defaults are in use: {}", + backup_path.display(), + e + ), + Err(rename_err) => format!( + "Settings file could not be parsed (using defaults). Failed to back up the original ({}): {}", + rename_err, e + ), + }; + log::warn!("{}", msg); + load_warnings.push(msg); + AppSettings::default() + } + }, + Err(e) => { + let msg = format!("Failed to read settings file (using defaults): {}", e); + log::warn!("{}", msg); + load_warnings.push(msg); + AppSettings::default() + } + } + } else { + AppSettings::default() + }; + + let mut state = SettingsState { + settings, + config_path, + load_warnings, + }; + + // Run version-based migrations first + state.migrate_settings(); + + // Run legacy data migrations + state.migrate_legacy_workspace_data(); + if let Err(error) = state.migrate_legacy_secrets() { + log::warn!("Failed to migrate secrets into keychain: {}", error); + } + + // Resolve secrets from the keyring into the auth runtime on every startup — + // not only when migration failed. (`save()` below also syncs, but doing it + // here keeps the runtime correct even if the save is skipped or fails.) + sync_auth_runtime(state.settings()); + + if let Err(error) = state.save() { + log::warn!("Failed to persist migrated settings: {}", error); + } + cx.set_global(state); +} + +/// The raw `ai.provider` string as it appears on disk, before the lenient +/// deserializer has had a chance to substitute a fallback. +fn raw_ai_provider(json: &str) -> Option { + serde_json::from_str::(json) + .ok()? + .get("ai")? + .get("provider")? + .as_str() + .map(str::to_string) +} + +/// Install default settings for a test app. +/// +/// Any view that reads `cx.global::()` in `render` needs this +/// before a headless test window draws it. Unlike [`init`], nothing is read +/// from or written to the user's config directory and the OS keychain is left +/// alone, so tests cannot disturb (or be disturbed by) real user state. The +/// config path points into the temp directory purely so a stray `save()` in a +/// view under test cannot land on the real file. +pub fn init_test(cx: &mut App) { + cx.set_global(SettingsState { + settings: AppSettings::default(), + config_path: std::env::temp_dir().join("rgitui-test-settings.json"), + load_warnings: Vec::new(), + }); +} + +/// Borrow the resolved credentials under the lock and return only what the +/// caller needs. +/// +/// Prefer this over [`current_auth_runtime`], which deep-clones every secret +/// the app holds — including ones the caller has no use for — into fresh heap +/// allocations that are dropped without zeroization. +pub fn with_auth_runtime(f: impl FnOnce(&AuthRuntimeState) -> R) -> R { + let guard = auth_runtime().read().expect( + "git auth runtime RwLock poisoned - a previous thread panicked while holding the lock", + ); + f(&guard) +} + +pub fn current_auth_runtime() -> AuthRuntimeState { + with_auth_runtime(|runtime| runtime.clone()) +} + +pub fn current_git_auth_runtime() -> GitAuthRuntime { + current_auth_runtime().git +} + +fn utc_now() -> DateTime { + Utc::now() +} + +fn dedup_paths(paths: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut deduped = Vec::new(); + for path in paths { + if seen.insert(path.clone()) { + deduped.push(path); + } + } + deduped +} + +fn workspace_name_from_repos(repos: &[PathBuf]) -> String { + match repos { + [] => "Workspace".to_string(), + [repo] => repo + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| repo.display().to_string()), + _ => { + let names = repos + .iter() + .take(2) + .filter_map(|repo| repo.file_name()) + .map(|name| name.to_string_lossy().to_string()) + .collect::>(); + if names.is_empty() { + format!("Workspace ({})", repos.len()) + } else if repos.len() == 2 { + names.join(" + ") + } else { + format!("{} +{}", names.join(", "), repos.len() - names.len()) + } + } + } +} + +fn default_git_provider_kind() -> String { + "generic".to_string() +} + +fn default_git_providers() -> Vec { + vec![] +} + +const KEYRING_SERVICE: &str = "rgitui"; +/// The pre-v3 single AI key slot. Retained so the v2 -> v3 migration can read +/// it and so a downgrade still finds a key; new writes never target it. +const AI_SECRET_ACCOUNT: &str = "ai/default"; +const GIT_DEFAULT_HTTPS_ACCOUNT: &str = "git/default-https"; + +fn git_provider_account(provider_id: &str) -> String { + format!("git/provider/{}", provider_id) +} + +fn ai_provider_account(provider_id: &str) -> String { + format!("ai/provider/{}", provider_id) +} + +/// Model ids that are retired or fabricated, mapped to the successor a user +/// pinned to them should land on. Applied once, by the v2 -> v3 migration. +fn retired_model_successor(model: &str) -> Option<&'static str> { + match model.trim() { + // Retired 2026-06-01, and the shipped default that appeared in no + // picker, so a fresh install rendered the Model row with nothing + // selected. + "gemini-2.0-flash" | "gemini-1.5-flash" | "gemini-1.5-pro" => Some("gemini-3.1-flash-lite"), + // `20241022` is the Claude 3.5 snapshot date on a 4.5 name: a + // guaranteed 404 that was offered in the picker. + "claude-sonnet-4-5-20241022" => Some("claude-haiku-4-5"), + // o-series rejects `max_tokens` and a non-default `temperature`, and + // both are being retired. + "o3" | "o4-mini" | "o1" | "o1-mini" => Some("gpt-5.6-luna"), + _ => None, + } +} + +fn auth_runtime() -> &'static RwLock { + static AUTH_RUNTIME: OnceLock> = OnceLock::new(); + AUTH_RUNTIME.get_or_init(|| RwLock::new(AuthRuntimeState::default())) +} + +fn sync_auth_runtime(settings: &AppSettings) { + let runtime = AuthRuntimeState { + ai_api_keys: resolve_ai_api_keys(&settings.ai), + git: GitAuthRuntime { + default_https_token: resolve_git_https_token(&settings.git), + ssh_key_path: settings.git.ssh_key_path.as_ref().map(PathBuf::from), + gpg_key_id: settings.git.gpg_key_id.clone(), + sign_commits: settings.git.sign_commits, + providers: settings + .git + .providers + .iter() + .map(|provider| GitProviderRuntime { + id: provider.id.clone(), + kind: provider.kind.clone(), + display_name: provider.display_name.clone(), + host: provider.host.clone(), + username: provider.username.clone(), + token: resolve_provider_token(provider), + use_for_https: provider.use_for_https, + }) + .collect(), + }, + }; + + *auth_runtime().write().expect( + "git auth runtime RwLock poisoned - a previous thread panicked while holding the lock", + ) = runtime; +} + +/// Resolve every provider's key from the keychain in one pass. +/// +/// Mirrors the git-provider loop: one read per provider that claims a key, and +/// none for the rest, so adding a provider does not multiply the cost of an +/// unrelated save. +fn resolve_ai_api_keys(settings: &AiSettings) -> BTreeMap { + let mut keys = BTreeMap::new(); + for provider in AiProvider::ALL { + if !settings.has_key_for(*provider) { + continue; + } + if let Some(secret) = read_secret(&ai_provider_account(provider.id())) { + keys.insert(provider.id().to_string(), secret); + } + } + + // Pre-v3 files, and any install whose migration could not write to the + // keychain, still resolve through the shared slot for the active provider. + if !keys.contains_key(settings.provider.id()) { + let legacy = if settings.has_api_key { + read_secret(AI_SECRET_ACCOUNT).or_else(|| settings.legacy_api_key.clone()) + } else { + settings.legacy_api_key.clone() + }; + if let Some(secret) = legacy { + keys.insert(settings.provider.id().to_string(), secret); + } + } + + keys +} + +fn resolve_git_https_token(settings: &GitSettings) -> Option { + if settings.has_https_token { + read_secret(GIT_DEFAULT_HTTPS_ACCOUNT).or_else(|| settings.legacy_https_token.clone()) + } else { + settings.legacy_https_token.clone() + } +} + +fn resolve_provider_token(provider: &GitProviderSettings) -> Option { + if provider.has_token { + read_secret(&git_provider_account(&provider.id)).or_else(|| provider.legacy_token.clone()) + } else { + provider.legacy_token.clone() + } +} + +fn secret_entry(account: &str) -> Result { + Ok(Entry::new(KEYRING_SERVICE, account)?) +} + +fn write_secret(account: &str, value: Option<&str>) -> Result { + let entry = secret_entry(account)?; + match value { + Some(secret) if !secret.trim().is_empty() => { + entry.set_password(secret)?; + Ok(true) + } + _ => { + let _ = entry.delete_credential(); + Ok(false) + } + } +} + +fn read_secret(account: &str) -> Option { + let entry = match secret_entry(account) { + Ok(e) => e, + Err(e) => { + log::warn!("Failed to create keyring entry for '{}': {}", account, e); + return None; + } + }; + match entry.get_password() { + Ok(password) => Some(password), + Err(keyring::Error::NoEntry) => None, + Err(e) => { + log::warn!("Failed to read secret for '{}': {}", account, e); + None + } + } +} + +fn delete_secret(account: &str) -> Result<()> { + let entry = secret_entry(account)?; + let _ = entry.delete_credential(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_settings_state() -> SettingsState { + SettingsState { + settings: AppSettings::default(), + config_path: PathBuf::from("/tmp/rgitui-test-settings.json"), + load_warnings: Vec::new(), + } + } + + // ── Layout ──────────────────────────────────────────────────── + + /// A layout saved before the graph could be hidden must keep showing it. + #[test] + fn layout_without_graph_hidden_shows_the_graph() { + let layout: LayoutSettings = serde_json::from_str( + r#"{"sidebar_width":300.0,"detail_panel_width":400.0,"diff_viewer_height":500.0,"commit_input_height":350.0}"#, + ) + .expect("legacy layout parses"); + assert!(!layout.graph_hidden); + assert_eq!(layout.diff_viewer_height, 500.0); + } + + #[test] + fn graph_hidden_round_trips() { + let layout = LayoutSettings { + graph_hidden: true, + ..LayoutSettings::default() + }; + let json = serde_json::to_string(&layout).expect("layout serializes"); + let restored: LayoutSettings = serde_json::from_str(&json).expect("layout parses"); + assert_eq!(restored, layout); + } + + // ── AI provider catalogue coherence ─────────────────────────── + + /// The bug this guards is not hypothetical: the shipped default was + /// `gemini-2.0-flash`, which appeared in no picker, so a fresh install + /// rendered the Model row with nothing selected at all. + #[test] + fn default_model_is_the_default_provider_model() { + assert_eq!(default_ai_model(), default_ai_provider().default_model()); + } + + #[test] + fn every_provider_has_a_distinct_id_and_a_default_model() { + let mut ids: Vec<&str> = AiProvider::ALL.iter().map(|p| p.id()).collect(); + let count = ids.len(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), count, "provider ids must be unique"); + + for provider in AiProvider::ALL { + assert!(!provider.default_model().is_empty()); + assert!(provider.key_url().starts_with("https://")); + assert!(!provider.display_name().is_empty()); + assert_eq!(AiProvider::from_id(provider.id()), Some(*provider)); + } + } + + #[test] + fn no_default_model_is_a_retired_id() { + for provider in AiProvider::ALL { + assert_eq!( + retired_model_successor(provider.default_model()), + None, + "{} ships a retired default", + provider.id() + ); + } + } + + #[test] + fn provider_id_parsing_is_lenient_about_case_and_whitespace() { + assert_eq!( + AiProvider::from_id("Anthropic"), + Some(AiProvider::Anthropic) + ); + assert_eq!(AiProvider::from_id(" OpenAI "), Some(AiProvider::OpenAi)); + assert_eq!( + AiProvider::from_id("openrouter"), + Some(AiProvider::OpenRouter) + ); + assert_eq!(AiProvider::from_id("bard"), None); + } + + #[test] + fn only_openai_compatible_providers_accept_a_base_url_override() { + assert!(AiProvider::OpenAi.is_openai_compatible()); + assert!(AiProvider::DeepSeek.is_openai_compatible()); + assert!(AiProvider::OpenRouter.is_openai_compatible()); + assert!(!AiProvider::Gemini.is_openai_compatible()); + assert!(!AiProvider::Anthropic.is_openai_compatible()); + } + + // ── AiSettings model memory ─────────────────────────────────── + + #[test] + fn switching_provider_preserves_each_providers_model_choice() { + let mut ai = AiSettings::default(); + ai.set_active_model("gemini-3.1-pro-preview"); + + ai.set_active_provider(AiProvider::OpenAi); + assert_eq!(ai.model, AiProvider::OpenAi.default_model()); + ai.set_active_model("gpt-5.4"); + + ai.set_active_provider(AiProvider::Gemini); + assert_eq!(ai.model, "gemini-3.1-pro-preview"); + + ai.set_active_provider(AiProvider::OpenAi); + assert_eq!(ai.model, "gpt-5.4"); + } + + #[test] + fn model_for_never_returns_another_providers_model() { + let mut ai = AiSettings::default(); + ai.set_active_model("gemini-3.1-pro-preview"); + assert_eq!( + ai.model_for(AiProvider::Anthropic), + AiProvider::Anthropic.default_model() + ); + } + + #[test] + fn key_flags_are_tracked_per_provider() { + let mut ai = AiSettings::default(); + ai.set_has_key_for(AiProvider::Gemini, true); + assert!(ai.has_key_for(AiProvider::Gemini)); + assert!(ai.has_api_key, "the active provider mirror must follow"); + assert!(!ai.has_key_for(AiProvider::Anthropic)); + + // Switching to a provider with no key must not keep asserting + // "connected" — that false state is what enabled the AI button for a + // provider the app had no credential for. + ai.set_active_provider(AiProvider::Anthropic); + assert!(!ai.has_api_key); + } + + // ── settings file compatibility ─────────────────────────────── + + #[test] + fn v2_settings_load_with_every_new_field_defaulted() { + let json = r#"{ + "version": 2, + "ai": { "provider": "openai", "model": "gpt-5.4", "has_api_key": true } + }"#; + let settings: AppSettings = serde_json::from_str(json).unwrap(); + assert_eq!(settings.ai.provider, AiProvider::OpenAi); + assert_eq!(settings.ai.model, "gpt-5.4"); + assert!(settings.ai.has_api_key_for.is_empty()); + assert!(settings.ai.models_by_provider.is_empty()); + assert!(settings.ai.base_url_override.is_empty()); + assert!(settings.ai.openrouter_attribution); + } + + #[test] + fn an_unknown_provider_falls_back_instead_of_failing_the_file() { + let json = r#"{ "version": 3, "ai": { "provider": "bard" } }"#; + let settings: AppSettings = serde_json::from_str(json).unwrap(); + assert_eq!(settings.ai.provider, AiProvider::default()); + // And the raw value is still recoverable, which is what lets `init` + // tell the user which id it did not understand. + assert_eq!(raw_ai_provider(json).as_deref(), Some("bard")); + } + + #[test] + fn a_miscased_provider_resolves_rather_than_falling_back() { + let json = r#"{ "ai": { "provider": "Anthropic" } }"#; + let settings: AppSettings = serde_json::from_str(json).unwrap(); + assert_eq!(settings.ai.provider, AiProvider::Anthropic); + } + + #[test] + fn the_api_key_never_reaches_the_settings_file() { + let mut settings = AppSettings::default(); + settings.ai.legacy_api_key = Some("sk-should-never-be-written".into()); + let json = serde_json::to_string(&settings).unwrap(); + assert!(!json.contains("sk-should-never-be-written")); + } + + #[test] + fn v2_to_v3_remaps_retired_model_ids() { + let mut state = test_settings_state(); + state.settings.version = 2; + state.settings.ai.model = "gemini-2.0-flash".into(); + + assert!(state.migrate_settings()); + + assert_eq!(state.settings.version, CURRENT_SETTINGS_VERSION); + assert_eq!(state.settings.ai.model, "gemini-3.1-flash-lite"); + } + + #[test] + fn v2_to_v3_leaves_a_live_model_alone() { + let mut state = test_settings_state(); + state.settings.version = 2; + state.settings.ai.model = "gemini-2.5-pro".into(); + + state.migrate_settings(); + + assert_eq!(state.settings.ai.model, "gemini-2.5-pro"); + } + + #[test] + fn retired_ids_map_to_a_successor_of_the_same_provider_family() { + assert_eq!( + retired_model_successor("claude-sonnet-4-5-20241022"), + Some("claude-haiku-4-5") + ); + assert_eq!(retired_model_successor("o4-mini"), Some("gpt-5.6-luna")); + assert_eq!(retired_model_successor("deepseek-v4-flash"), None); + } + + #[test] + fn ai_provider_accounts_are_distinct_and_namespaced() { + let accounts: Vec = AiProvider::ALL + .iter() + .map(|p| ai_provider_account(p.id())) + .collect(); + assert_eq!(accounts[0], "ai/provider/gemini"); + for account in &accounts { + assert!(account.starts_with("ai/provider/")); + assert_ne!(account, AI_SECRET_ACCOUNT); + assert!(!account.starts_with("git/")); + } + } + + #[test] + fn migrates_legacy_last_workspace_into_workspace_snapshot() { + let mut state = test_settings_state(); + state.settings.last_workspace = + vec![PathBuf::from("/tmp/repo-a"), PathBuf::from("/tmp/repo-b")]; + + state.migrate_legacy_workspace_data(); + + assert_eq!(state.settings.workspaces.len(), 1); + assert_eq!(state.settings.workspaces[0].repos.len(), 2); + assert_eq!( + state.settings.active_workspace_id, + Some(state.settings.workspaces[0].id.clone()) + ); + } + + #[test] + fn save_workspace_snapshot_updates_existing_workspace() { + let mut state = test_settings_state(); + let first_id = state + .save_workspace_snapshot( + None, + vec![PathBuf::from("/tmp/repo-a")], + 0, + LayoutSettings::default(), + ) + .expect("save_workspace_snapshot should return an id for non-empty repo lists"); + + let second_id = state + .save_workspace_snapshot( + Some(&first_id), + vec![PathBuf::from("/tmp/repo-a"), PathBuf::from("/tmp/repo-b")], + 1, + LayoutSettings::default(), + ) + .expect("save_workspace_snapshot should return an id for non-empty repo lists"); + + assert_eq!(first_id, second_id); + assert_eq!(state.settings.workspaces.len(), 1); + assert_eq!(state.settings.workspaces[0].repos.len(), 2); + assert_eq!(state.settings.workspaces[0].active_repo_index, 1); + } + + #[test] + fn recent_workspaces_are_sorted_newest_first() { + let mut state = test_settings_state(); + let now = Utc::now(); + state.settings.workspaces = vec![ + StoredWorkspace { + id: "older".into(), + name: "Older".into(), + repos: vec![PathBuf::from("/tmp/older")], + active_repo_index: 0, + layout: LayoutSettings::default(), + created_at: now, + last_opened_at: now, + }, + StoredWorkspace { + id: "newer".into(), + name: "Newer".into(), + repos: vec![PathBuf::from("/tmp/newer")], + active_repo_index: 0, + layout: LayoutSettings::default(), + created_at: now, + last_opened_at: now + chrono::TimeDelta::seconds(10), + }, + ]; + + let recent = state.recent_workspaces(10); + + assert_eq!(recent[0].id, "newer"); + assert_eq!(recent[1].id, "older"); + } + + #[test] + fn migrate_settings_updates_version() { + let mut state = test_settings_state(); + // Simulate old settings without version (defaults to 1 via serde) + state.settings.version = 0; + + let migrated = state.migrate_settings(); + + assert!(migrated); + assert_eq!(state.settings.version, CURRENT_SETTINGS_VERSION); + } + + #[test] + fn migrate_settings_no_migration_needed_for_current_version() { + let mut state = test_settings_state(); + state.settings.version = CURRENT_SETTINGS_VERSION; + + let migrated = state.migrate_settings(); + + assert!(!migrated); + assert_eq!(state.settings.version, CURRENT_SETTINGS_VERSION); + } + + // --- dedup_paths --- + + #[test] + fn dedup_paths_removes_duplicates() { + let paths = vec![ + PathBuf::from("/a"), + PathBuf::from("/b"), + PathBuf::from("/a"), + ]; + let result = dedup_paths(paths); + assert_eq!(result, vec![PathBuf::from("/a"), PathBuf::from("/b")]); + } + + #[test] + fn dedup_paths_preserves_order() { + let paths = vec![ + PathBuf::from("/c"), + PathBuf::from("/a"), + PathBuf::from("/b"), + PathBuf::from("/a"), + ]; + let result = dedup_paths(paths); + assert_eq!( + result, + vec![ + PathBuf::from("/c"), + PathBuf::from("/a"), + PathBuf::from("/b") + ] + ); + } + + #[test] + fn dedup_paths_empty_input() { + let result = dedup_paths(vec![]); + assert!(result.is_empty()); + } + + #[test] + fn dedup_paths_no_duplicates() { + let paths = vec![PathBuf::from("/x"), PathBuf::from("/y")]; + let result = dedup_paths(paths.clone()); + assert_eq!(result, paths); + } + + // --- workspace_name_from_repos --- + + #[test] + fn workspace_name_empty_repos_is_workspace() { + assert_eq!(workspace_name_from_repos(&[]), "Workspace"); + } + + #[test] + fn workspace_name_single_repo_uses_dir_name() { + let repos = vec![PathBuf::from("/home/user/my-project")]; + assert_eq!(workspace_name_from_repos(&repos), "my-project"); + } + + #[test] + fn workspace_name_two_repos_joined_with_plus() { + let repos = vec![PathBuf::from("/repos/alpha"), PathBuf::from("/repos/beta")]; + assert_eq!(workspace_name_from_repos(&repos), "alpha + beta"); + } + + #[test] + fn workspace_name_three_repos_shows_overflow() { + let repos = vec![ + PathBuf::from("/repos/alpha"), + PathBuf::from("/repos/beta"), + PathBuf::from("/repos/gamma"), + ]; + // takes first 2, then "+N" for the rest + let name = workspace_name_from_repos(&repos); + assert!(name.contains("alpha")); + assert!(name.contains("beta")); + assert!(name.contains("+1")); + } +} diff --git a/third_party/rgitui/crates/rgitui_theme/Cargo.toml b/third_party/rgitui/crates/rgitui_theme/Cargo.toml new file mode 100644 index 000000000..3ad8bd6bc --- /dev/null +++ b/third_party/rgitui/crates/rgitui_theme/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "rgitui_theme" +version = "0.4.1" +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +gpui.workspace = true +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +log.workspace = true +rgitui_settings.workspace = true diff --git a/third_party/rgitui/crates/rgitui_theme/src/colors.rs b/third_party/rgitui/crates/rgitui_theme/src/colors.rs new file mode 100644 index 000000000..5655f30c4 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_theme/src/colors.rs @@ -0,0 +1,1493 @@ +use gpui::Hsla; +use serde::{Deserialize, Serialize}; + +/// Semantic color palette for the entire application. +/// Modeled after Zed's ThemeColors but focused on Git GUI needs. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ThemeColors { + // -- Backgrounds -- + pub background: Hsla, + pub surface_background: Hsla, + pub elevated_surface_background: Hsla, + pub editor_background: Hsla, + + // -- Borders -- + pub border: Hsla, + pub border_variant: Hsla, + pub border_focused: Hsla, + pub border_selected: Hsla, + pub border_disabled: Hsla, + pub border_transparent: Hsla, + + // -- Element states -- + pub element_background: Hsla, + pub element_hover: Hsla, + pub element_active: Hsla, + pub element_selected: Hsla, + pub element_disabled: Hsla, + pub ghost_element_background: Hsla, + pub ghost_element_hover: Hsla, + pub ghost_element_active: Hsla, + pub ghost_element_selected: Hsla, + + // -- Text -- + pub text: Hsla, + pub text_muted: Hsla, + pub text_placeholder: Hsla, + pub text_disabled: Hsla, + pub text_accent: Hsla, + + // -- Icons -- + pub icon: Hsla, + pub icon_muted: Hsla, + pub icon_disabled: Hsla, + pub icon_accent: Hsla, + + // -- UI chrome -- + pub title_bar_background: Hsla, + pub toolbar_background: Hsla, + pub tab_bar_background: Hsla, + pub tab_active_background: Hsla, + pub tab_inactive_background: Hsla, + pub status_bar_background: Hsla, + pub panel_background: Hsla, + pub scrollbar_thumb_background: Hsla, + pub scrollbar_thumb_hover_background: Hsla, + + // -- Hint / badge backgrounds -- + pub hint_background: Hsla, + + // -- Version control (our specialty!) -- + pub vc_added: Hsla, + pub vc_modified: Hsla, + pub vc_deleted: Hsla, + pub vc_conflict: Hsla, + pub vc_renamed: Hsla, + pub vc_untracked: Hsla, +} + +/// Status colors for feedback states. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StatusColors { + pub error: Hsla, + pub error_background: Hsla, + pub warning: Hsla, + pub warning_background: Hsla, + pub success: Hsla, + pub success_background: Hsla, + pub info: Hsla, + pub info_background: Hsla, + pub hint: Hsla, +} + +/// Semantic color enum used by UI components. +/// Maps intent to actual theme colors at render time. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum Color { + #[default] + Default, + Muted, + Accent, + Disabled, + Placeholder, + Error, + Warning, + Success, + Info, + Added, + Modified, + Deleted, + Conflict, + Renamed, + Untracked, + Custom(Hsla), +} + +impl Color { + /// Resolve this semantic color to an actual HSLA value using the current theme. + pub fn color(&self, cx: &gpui::App) -> Hsla { + let theme = cx.global::().theme(); + match self { + Color::Default => theme.colors.text, + Color::Muted => theme.colors.text_muted, + Color::Accent => theme.colors.text_accent, + Color::Disabled => theme.colors.text_disabled, + Color::Placeholder => theme.colors.text_placeholder, + Color::Error => theme.status.error, + Color::Warning => theme.status.warning, + Color::Success => theme.status.success, + Color::Info => theme.status.info, + Color::Added => theme.colors.vc_added, + Color::Modified => theme.colors.vc_modified, + Color::Deleted => theme.colors.vc_deleted, + Color::Conflict => theme.colors.vc_conflict, + Color::Renamed => theme.colors.vc_renamed, + Color::Untracked => theme.colors.vc_untracked, + Color::Custom(c) => *c, + } + } + + /// Resolve to an icon color (same mapping but uses icon slots where appropriate). + pub fn icon_color(&self, cx: &gpui::App) -> Hsla { + let theme = cx.global::().theme(); + match self { + Color::Default => theme.colors.icon, + Color::Muted => theme.colors.icon_muted, + Color::Disabled => theme.colors.icon_disabled, + Color::Accent => theme.colors.icon_accent, + _ => self.color(cx), + } + } +} + +impl From for Color { + fn from(color: Hsla) -> Self { + Color::Custom(color) + } +} + +// -- Helper functions -- + +fn hsla(h: f32, s: f32, l: f32, a: f32) -> Hsla { + Hsla { + h: h / 360.0, + s: s / 100.0, + l: l / 100.0, + a, + } +} + +/// Convert 8-bit RGBA channels to `Hsla`. +/// +/// This is the conversion every colour path ends in. Callers that already hold +/// channel bytes — syntax highlighting produces one style per span — should use +/// this directly rather than formatting a hex string only to parse it back. +pub fn rgba_u8_to_hsla(r: u8, g: u8, b: u8, a: u8) -> Hsla { + let r = r as f32 / 255.0; + let g = g as f32 / 255.0; + let b = b as f32 / 255.0; + let a = a as f32 / 255.0; + + // RGB to HSL conversion + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let l = (max + min) / 2.0; + + if (max - min).abs() < f32::EPSILON { + return Hsla { + h: 0.0, + s: 0.0, + l, + a, + }; + } + + let d = max - min; + let s = if l > 0.5 { + d / (2.0 - max - min) + } else { + d / (max + min) + }; + + let h = if (max - r).abs() < f32::EPSILON { + let mut h = (g - b) / d; + if g < b { + h += 6.0; + } + h + } else if (max - g).abs() < f32::EPSILON { + (b - r) / d + 2.0 + } else { + (r - g) / d + 4.0 + }; + + Hsla { + h: h / 6.0, + s, + l, + a, + } +} + +/// Parse a hex color string strictly, returning None for invalid input. +/// Accepts: #RGB, #RGBA, #RRGGBB, #RRGGBBAA (case-insensitive). +pub fn hex_to_hsla_strict(hex: &str) -> Option { + let hex = hex.trim_start_matches('#'); + + if !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + + // Expand a single #RGB(A) digit to its #RRGGBB(AA) byte (e.g. A → AA). + let expand = |b: u8| -> u8 { + let digit = match b { + b @ b'0'..=b'9' => b - b'0', + b @ b'A'..=b'F' => b - b'A' + 10, + b @ b'a'..=b'f' => b - b'a' + 10, + _ => 0, + }; + digit * 17 + }; + + let bytes = hex.as_bytes(); + let (r, g, b, a) = match hex.len() { + 3 => ( + expand(bytes[0]), + expand(bytes[1]), + expand(bytes[2]), + u8::MAX, + ), + 4 => ( + expand(bytes[0]), + expand(bytes[1]), + expand(bytes[2]), + expand(bytes[3]), + ), + 6 => ( + u8::from_str_radix(&hex[0..2], 16).ok()?, + u8::from_str_radix(&hex[2..4], 16).ok()?, + u8::from_str_radix(&hex[4..6], 16).ok()?, + u8::MAX, + ), + 8 => ( + u8::from_str_radix(&hex[0..2], 16).ok()?, + u8::from_str_radix(&hex[2..4], 16).ok()?, + u8::from_str_radix(&hex[4..6], 16).ok()?, + u8::from_str_radix(&hex[6..8], 16).ok()?, + ), + _ => return None, + }; + + Some(rgba_u8_to_hsla(r, g, b, a)) +} + +/// Parse a hex color string into an Hsla value, returning opaque black for invalid input. +/// Accepts #RGB, #RGBA, #RRGGBB, #RRGGBBAA (case-insensitive). +pub fn hex_to_hsla(hex: &str) -> Hsla { + hex_to_hsla_strict(hex).unwrap_or(Hsla { + h: 0.0, + s: 0.0, + l: 0.0, + a: 1.0, + }) +} + +/// Serialize an Hsla value to a hex string (#RRGGBB or #RRGGBBAA). +/// Alpha is included only when it is not fully opaque (a < 1.0). +pub fn hsla_to_hex(color: Hsla) -> String { + // HSL to RGB + let h = color.h; + let s = color.s; + let l = color.l; + + if s.abs() < f32::EPSILON { + let r = (l * 255.0).round() as u8; + let g = (l * 255.0).round() as u8; + let b = (l * 255.0).round() as u8; + if (color.a - 1.0).abs() < f32::EPSILON { + format!("#{:02x}{:02x}{:02x}", r, g, b) + } else { + format!( + "#{:02x}{:02x}{:02x}{:02x}", + r, + g, + b, + (color.a * 255.0).round() as u8 + ) + } + } else { + let q = if l < 0.5 { + l * (1.0 + s) + } else { + l + s - l * s + }; + let p = 2.0 * l - q; + + let r = hue_to_rgb(p, q, h + 1.0 / 3.0); + let g = hue_to_rgb(p, q, h); + let b = hue_to_rgb(p, q, h - 1.0 / 3.0); + + let r = (r * 255.0).round() as u8; + let g = (g * 255.0).round() as u8; + let b = (b * 255.0).round() as u8; + + if (color.a - 1.0).abs() < f32::EPSILON { + format!("#{:02x}{:02x}{:02x}", r, g, b) + } else { + format!( + "#{:02x}{:02x}{:02x}{:02x}", + r, + g, + b, + (color.a * 255.0).round() as u8 + ) + } + } +} + +fn hue_to_rgb(p: f32, q: f32, mut t: f32) -> f32 { + if t < 0.0 { + t += 1.0; + } + if t > 1.0 { + t -= 1.0; + } + if t < 1.0 / 6.0 { + return p + (q - p) * 6.0 * t; + } + if t < 1.0 / 2.0 { + return q; + } + if t < 2.0 / 3.0 { + return p + (q - p) * (2.0 / 3.0 - t) * 6.0; + } + p +} + +/// Catppuccin Mocha dark theme colors. +pub fn catppuccin_mocha_colors() -> ThemeColors { + // Catppuccin Mocha palette + let base = hsla(240.0, 21.0, 15.0, 1.0); // #1e1e2e + let mantle = hsla(240.0, 21.0, 12.0, 1.0); // #181825 + let crust = hsla(240.0, 23.0, 9.0, 1.0); // #11111b + let surface0 = hsla(237.0, 16.0, 23.0, 1.0); // #313244 + let surface1 = hsla(234.0, 13.0, 31.0, 1.0); // #45475a + let surface2 = hsla(233.0, 12.0, 39.0, 1.0); // #585b70 + let overlay0 = hsla(231.0, 11.0, 47.0, 1.0); // #6c7086 + let overlay1 = hsla(230.0, 13.0, 55.0, 1.0); // #7f849c + let subtext0 = hsla(228.0, 17.0, 64.0, 1.0); // #a6adc8 + let text = hsla(227.0, 35.0, 80.0, 1.0); // #cdd6f4 + let mauve = hsla(267.0, 84.0, 81.0, 1.0); // #cba6f7 (accent) + let red = hsla(343.0, 81.0, 75.0, 1.0); // #f38ba8 + let green = hsla(115.0, 54.0, 76.0, 1.0); // #a6e3a1 + let yellow = hsla(41.0, 86.0, 83.0, 1.0); // #f9e2af + let blue = hsla(217.0, 92.0, 76.0, 1.0); // #89b4fa + let peach = hsla(23.0, 92.0, 75.0, 1.0); // #fab387 + let teal = hsla(170.0, 57.0, 73.0, 1.0); // #94e2d5 + + ThemeColors { + background: crust, + surface_background: mantle, + elevated_surface_background: surface0, + editor_background: base, + + border: surface1, + border_variant: surface0, + border_focused: mauve, + border_selected: mauve, + border_disabled: surface0, + border_transparent: Hsla { + h: 0.0, + s: 0.0, + l: 0.0, + a: 0.0, + }, + + element_background: surface0, + element_hover: surface1, + element_active: surface2, + element_selected: surface1, + element_disabled: surface0, + ghost_element_background: Hsla { + h: 0.0, + s: 0.0, + l: 0.0, + a: 0.0, + }, + ghost_element_hover: hsla(237.0, 16.0, 23.0, 0.5), + ghost_element_active: hsla(234.0, 13.0, 31.0, 0.7), + ghost_element_selected: hsla(234.0, 13.0, 31.0, 0.7), + + text, + text_muted: subtext0, + text_placeholder: overlay1, + text_disabled: overlay0, + text_accent: mauve, + + icon: text, + icon_muted: subtext0, + icon_disabled: overlay0, + icon_accent: mauve, + + title_bar_background: mantle, + toolbar_background: base, + tab_bar_background: mantle, + tab_active_background: base, + tab_inactive_background: mantle, + status_bar_background: mantle, + panel_background: base, + scrollbar_thumb_background: surface1, + scrollbar_thumb_hover_background: surface2, + + hint_background: hsla(170.0, 57.0, 73.0, 0.12), + + vc_added: green, + vc_modified: yellow, + vc_deleted: red, + vc_conflict: peach, + vc_renamed: blue, + vc_untracked: teal, + } +} + +pub fn catppuccin_mocha_status() -> StatusColors { + let red = hsla(343.0, 81.0, 75.0, 1.0); + let yellow = hsla(41.0, 86.0, 83.0, 1.0); + let green = hsla(115.0, 54.0, 76.0, 1.0); + let blue = hsla(217.0, 92.0, 76.0, 1.0); + let teal = hsla(170.0, 57.0, 73.0, 1.0); + + StatusColors { + error: red, + error_background: hsla(343.0, 81.0, 75.0, 0.15), + warning: yellow, + warning_background: hsla(41.0, 86.0, 83.0, 0.15), + success: green, + success_background: hsla(115.0, 54.0, 76.0, 0.15), + info: blue, + info_background: hsla(217.0, 92.0, 76.0, 0.15), + hint: teal, + } +} + +/// Catppuccin Latte light theme colors. +pub fn catppuccin_latte_colors() -> ThemeColors { + let base = hsla(220.0, 23.0, 95.0, 1.0); // #eff1f5 + let mantle = hsla(220.0, 22.0, 92.0, 1.0); // #e6e9ef + let crust = hsla(220.0, 21.0, 89.0, 1.0); // #dce0e8 + let surface0 = hsla(223.0, 16.0, 83.0, 1.0); // #ccd0da + let surface1 = hsla(225.0, 14.0, 77.0, 1.0); // #bcc0cc + let surface2 = hsla(227.0, 12.0, 71.0, 1.0); // #acb0be + let overlay0 = hsla(228.0, 11.0, 65.0, 1.0); // #9ca0b0 + let overlay1 = hsla(227.0, 12.0, 58.0, 1.0); // #8c8fa1 + let subtext0 = hsla(228.0, 14.0, 45.0, 1.0); // #6c6f85 + let text = hsla(234.0, 16.0, 35.0, 1.0); // #4c4f69 + let mauve = hsla(266.0, 85.0, 58.0, 1.0); // #8839ef + let red = hsla(347.0, 87.0, 44.0, 1.0); // #d20f39 + let green = hsla(109.0, 58.0, 40.0, 1.0); // #40a02b + let yellow = hsla(35.0, 77.0, 49.0, 1.0); // #df8e1d + let blue = hsla(220.0, 91.0, 54.0, 1.0); // #1e66f5 + let peach = hsla(22.0, 99.0, 52.0, 1.0); // #fe640b + let teal = hsla(183.0, 74.0, 35.0, 1.0); // #179299 + + ThemeColors { + background: crust, + surface_background: base, + elevated_surface_background: mantle, + editor_background: base, + + border: surface1, + border_variant: surface0, + border_focused: mauve, + border_selected: mauve, + border_disabled: surface0, + border_transparent: Hsla { + h: 0.0, + s: 0.0, + l: 0.0, + a: 0.0, + }, + + element_background: surface0, + element_hover: surface1, + element_active: surface2, + element_selected: surface1, + element_disabled: surface0, + ghost_element_background: Hsla { + h: 0.0, + s: 0.0, + l: 0.0, + a: 0.0, + }, + ghost_element_hover: hsla(223.0, 16.0, 83.0, 0.5), + ghost_element_active: hsla(225.0, 14.0, 77.0, 0.7), + ghost_element_selected: hsla(225.0, 14.0, 77.0, 0.7), + + text, + text_muted: subtext0, + text_placeholder: overlay1, + text_disabled: overlay0, + text_accent: mauve, + + icon: text, + icon_muted: subtext0, + icon_disabled: overlay0, + icon_accent: mauve, + + title_bar_background: mantle, + toolbar_background: base, + tab_bar_background: mantle, + tab_active_background: base, + tab_inactive_background: mantle, + status_bar_background: mantle, + panel_background: base, + scrollbar_thumb_background: surface1, + scrollbar_thumb_hover_background: surface2, + + hint_background: hsla(183.0, 74.0, 35.0, 0.10), + + vc_added: green, + vc_modified: yellow, + vc_deleted: red, + vc_conflict: peach, + vc_renamed: blue, + vc_untracked: teal, + } +} + +pub fn catppuccin_latte_status() -> StatusColors { + let red = hsla(347.0, 87.0, 44.0, 1.0); + let yellow = hsla(35.0, 77.0, 49.0, 1.0); + let green = hsla(109.0, 58.0, 40.0, 1.0); + let blue = hsla(220.0, 91.0, 54.0, 1.0); + let teal = hsla(183.0, 74.0, 35.0, 1.0); + + StatusColors { + error: red, + error_background: hsla(347.0, 87.0, 44.0, 0.1), + warning: yellow, + warning_background: hsla(35.0, 77.0, 49.0, 0.1), + success: green, + success_background: hsla(109.0, 58.0, 40.0, 0.1), + info: blue, + info_background: hsla(220.0, 91.0, 54.0, 0.1), + hint: teal, + } +} + +// -- One Dark theme colors -- + +/// One Dark (Atom) theme colors — GitKraken-like dark theme. +pub fn one_dark_colors() -> ThemeColors { + let bg = hsla(220.0, 13.0, 18.0, 1.0); // #282c34 + let bg_lighter = hsla(220.0, 13.0, 21.0, 1.0); // #2c313a + let gutter = hsla(220.0, 13.0, 24.0, 1.0); // #353b45 + let guide = hsla(220.0, 10.0, 30.0, 1.0); // #3b4048 + let text = hsla(219.0, 14.0, 71.0, 1.0); // #abb2bf + let text_muted = hsla(220.0, 9.0, 55.0, 1.0); // #7f848e + let text_dim = hsla(220.0, 9.0, 44.0, 1.0); // #636d83 + let accent = hsla(207.0, 82.0, 66.0, 1.0); // #61afef (blue) + let red = hsla(355.0, 65.0, 65.0, 1.0); // #e06c75 + let green = hsla(95.0, 38.0, 62.0, 1.0); // #98c379 + let yellow = hsla(39.0, 67.0, 69.0, 1.0); // #e5c07b + let magenta = hsla(286.0, 60.0, 67.0, 1.0); // #c678dd + let cyan = hsla(187.0, 47.0, 55.0, 1.0); // #56b6c2 + let orange = hsla(29.0, 54.0, 61.0, 1.0); // #d19a66 + + ThemeColors { + background: hsla(220.0, 13.0, 15.0, 1.0), + surface_background: bg, + elevated_surface_background: bg_lighter, + editor_background: bg, + + border: guide, + border_variant: gutter, + border_focused: accent, + border_selected: accent, + border_disabled: gutter, + border_transparent: Hsla { + h: 0.0, + s: 0.0, + l: 0.0, + a: 0.0, + }, + + element_background: gutter, + element_hover: guide, + element_active: hsla(220.0, 10.0, 35.0, 1.0), + element_selected: guide, + element_disabled: gutter, + ghost_element_background: Hsla { + h: 0.0, + s: 0.0, + l: 0.0, + a: 0.0, + }, + ghost_element_hover: hsla(220.0, 13.0, 24.0, 0.5), + ghost_element_active: hsla(220.0, 10.0, 30.0, 0.7), + ghost_element_selected: hsla(220.0, 10.0, 30.0, 0.7), + + text, + text_muted, + text_placeholder: text_dim, + text_disabled: text_dim, + text_accent: accent, + + icon: text, + icon_muted: text_muted, + icon_disabled: text_dim, + icon_accent: accent, + + title_bar_background: hsla(220.0, 13.0, 15.0, 1.0), + toolbar_background: bg, + tab_bar_background: hsla(220.0, 13.0, 15.0, 1.0), + tab_active_background: bg, + tab_inactive_background: hsla(220.0, 13.0, 15.0, 1.0), + status_bar_background: hsla(220.0, 13.0, 15.0, 1.0), + panel_background: bg, + scrollbar_thumb_background: guide, + scrollbar_thumb_hover_background: hsla(220.0, 10.0, 35.0, 1.0), + + hint_background: hsla(187.0, 47.0, 55.0, 0.12), + + vc_added: green, + vc_modified: yellow, + vc_deleted: red, + vc_conflict: orange, + vc_renamed: cyan, + vc_untracked: magenta, + } +} + +pub fn one_dark_status() -> StatusColors { + let red = hsla(355.0, 65.0, 65.0, 1.0); + let yellow = hsla(39.0, 67.0, 69.0, 1.0); + let green = hsla(95.0, 38.0, 62.0, 1.0); + let blue = hsla(207.0, 82.0, 66.0, 1.0); + let cyan = hsla(187.0, 47.0, 55.0, 1.0); + + StatusColors { + error: red, + error_background: hsla(355.0, 65.0, 65.0, 0.15), + warning: yellow, + warning_background: hsla(39.0, 67.0, 69.0, 0.15), + success: green, + success_background: hsla(95.0, 38.0, 62.0, 0.15), + info: blue, + info_background: hsla(207.0, 82.0, 66.0, 0.15), + hint: cyan, + } +} + +// -- GitHub Dark theme colors -- + +/// GitHub Dark theme colors. +pub fn github_dark_colors() -> ThemeColors { + let bg = hsla(215.0, 21.0, 7.0, 1.0); // #0d1117 + let surface = hsla(215.0, 19.0, 11.0, 1.0); // #161b22 + let elevated = hsla(215.0, 15.0, 14.0, 1.0); // #1c2128 + let border_main = hsla(215.0, 14.0, 21.0, 1.0); // #30363d + let border_sub = hsla(215.0, 14.0, 15.0, 1.0); // #21262d + let text = hsla(213.0, 14.0, 80.0, 1.0); // #c9d1d9 + let text_muted = hsla(212.0, 9.0, 57.0, 1.0); // #8b949e + let text_dim = hsla(213.0, 9.0, 46.0, 1.0); // #6e7681 + let text_dimmer = hsla(215.0, 10.0, 31.0, 1.0); // #484f58 + let accent = hsla(212.0, 100.0, 67.0, 1.0); // #58a6ff + let red = hsla(2.0, 92.0, 63.0, 1.0); // #f85149 + let green = hsla(139.0, 66.0, 49.0, 1.0); // #3fb950 + let yellow = hsla(39.0, 73.0, 49.0, 1.0); // #d29922 + let orange = hsla(24.0, 73.0, 50.0, 1.0); // #db6d28 + + ThemeColors { + background: bg, + surface_background: surface, + elevated_surface_background: elevated, + editor_background: bg, + + border: border_main, + border_variant: border_sub, + border_focused: accent, + border_selected: accent, + border_disabled: border_sub, + border_transparent: Hsla { + h: 0.0, + s: 0.0, + l: 0.0, + a: 0.0, + }, + + element_background: border_sub, + element_hover: border_main, + element_active: hsla(215.0, 10.0, 26.0, 1.0), + element_selected: border_main, + element_disabled: border_sub, + ghost_element_background: Hsla { + h: 0.0, + s: 0.0, + l: 0.0, + a: 0.0, + }, + ghost_element_hover: hsla(215.0, 14.0, 15.0, 0.5), + ghost_element_active: hsla(215.0, 14.0, 21.0, 0.7), + ghost_element_selected: hsla(215.0, 14.0, 21.0, 0.7), + + text, + text_muted, + text_placeholder: text_dim, + text_disabled: text_dimmer, + text_accent: accent, + + icon: text, + icon_muted: text_muted, + icon_disabled: text_dimmer, + icon_accent: accent, + + title_bar_background: surface, + toolbar_background: bg, + tab_bar_background: surface, + tab_active_background: bg, + tab_inactive_background: surface, + status_bar_background: surface, + panel_background: bg, + scrollbar_thumb_background: border_main, + scrollbar_thumb_hover_background: text_dimmer, + + hint_background: hsla(212.0, 9.0, 57.0, 0.12), + + vc_added: green, + vc_modified: yellow, + vc_deleted: red, + vc_conflict: orange, + vc_renamed: accent, + vc_untracked: text_muted, + } +} + +pub fn github_dark_status() -> StatusColors { + let red = hsla(2.0, 92.0, 63.0, 1.0); + let yellow = hsla(39.0, 73.0, 49.0, 1.0); + let green = hsla(139.0, 66.0, 49.0, 1.0); + let blue = hsla(212.0, 100.0, 67.0, 1.0); + let muted = hsla(212.0, 9.0, 57.0, 1.0); + + StatusColors { + error: red, + error_background: hsla(2.0, 92.0, 63.0, 0.15), + warning: yellow, + warning_background: hsla(39.0, 73.0, 49.0, 0.15), + success: green, + success_background: hsla(139.0, 66.0, 49.0, 0.15), + info: blue, + info_background: hsla(212.0, 100.0, 67.0, 0.15), + hint: muted, + } +} + +// -- Dracula theme colors -- + +/// Dracula theme colors. +pub fn dracula_colors() -> ThemeColors { + let bg = hsla(231.0, 15.0, 18.0, 1.0); // #282a36 + let bg_darker = hsla(232.0, 14.0, 15.0, 1.0); // #21222c + let surface = hsla(232.0, 14.0, 24.0, 1.0); // #343746 + let border_main = hsla(232.0, 14.0, 31.0, 1.0); // #44475a + let text = hsla(60.0, 30.0, 96.0, 1.0); // #f8f8f2 + let text_muted = hsla(0.0, 0.0, 75.0, 1.0); // #bfbfbf + let comment = hsla(225.0, 27.0, 51.0, 1.0); // #6272a4 + let text_dim = hsla(232.0, 14.0, 33.0, 1.0); // #545872 + let purple = hsla(265.0, 89.0, 78.0, 1.0); // #bd93f9 + let red = hsla(0.0, 100.0, 67.0, 1.0); // #ff5555 + let green = hsla(135.0, 94.0, 65.0, 1.0); // #50fa7b + let yellow = hsla(65.0, 92.0, 77.0, 1.0); // #f1fa8c + let orange = hsla(31.0, 100.0, 71.0, 1.0); // #ffb86c + let cyan = hsla(191.0, 97.0, 77.0, 1.0); // #8be9fd + + ThemeColors { + background: bg_darker, + surface_background: bg, + elevated_surface_background: surface, + editor_background: bg, + + border: border_main, + border_variant: surface, + border_focused: purple, + border_selected: purple, + border_disabled: surface, + border_transparent: Hsla { + h: 0.0, + s: 0.0, + l: 0.0, + a: 0.0, + }, + + element_background: surface, + element_hover: border_main, + element_active: hsla(232.0, 14.0, 37.0, 1.0), + element_selected: border_main, + element_disabled: surface, + ghost_element_background: Hsla { + h: 0.0, + s: 0.0, + l: 0.0, + a: 0.0, + }, + ghost_element_hover: hsla(232.0, 14.0, 31.0, 0.5), + ghost_element_active: hsla(232.0, 14.0, 37.0, 0.7), + ghost_element_selected: hsla(232.0, 14.0, 31.0, 0.7), + + text, + text_muted, + text_placeholder: comment, + text_disabled: text_dim, + text_accent: purple, + + icon: text, + icon_muted: text_muted, + icon_disabled: text_dim, + icon_accent: purple, + + title_bar_background: bg_darker, + toolbar_background: bg, + tab_bar_background: bg_darker, + tab_active_background: bg, + tab_inactive_background: bg_darker, + status_bar_background: bg_darker, + panel_background: bg, + scrollbar_thumb_background: border_main, + scrollbar_thumb_hover_background: hsla(232.0, 14.0, 37.0, 1.0), + + hint_background: hsla(225.0, 27.0, 51.0, 0.12), + + vc_added: green, + vc_modified: yellow, + vc_deleted: red, + vc_conflict: orange, + vc_renamed: cyan, + vc_untracked: comment, + } +} + +pub fn dracula_status() -> StatusColors { + let red = hsla(0.0, 100.0, 67.0, 1.0); + let yellow = hsla(65.0, 92.0, 77.0, 1.0); + let green = hsla(135.0, 94.0, 65.0, 1.0); + let cyan = hsla(191.0, 97.0, 77.0, 1.0); + let comment = hsla(225.0, 27.0, 51.0, 1.0); + + StatusColors { + error: red, + error_background: hsla(0.0, 100.0, 67.0, 0.15), + warning: yellow, + warning_background: hsla(65.0, 92.0, 77.0, 0.15), + success: green, + success_background: hsla(135.0, 94.0, 65.0, 0.15), + info: cyan, + info_background: hsla(191.0, 97.0, 77.0, 0.15), + hint: comment, + } +} + +/// High Contrast Dark theme — WCAG AAA compliant. +/// Pure black background with white text, bright saturated accents. +/// Designed for maximum readability and visual accessibility. +pub fn high_contrast_dark_colors() -> ThemeColors { + // Near-black backgrounds with clear hierarchy + let background = hsla(0.0, 0.0, 0.0, 1.0); // #000000 + let surface = hsla(0.0, 0.0, 4.0, 1.0); // #0a0a0a + let elevated = hsla(0.0, 0.0, 8.0, 1.0); // #141414 + let editor = hsla(0.0, 0.0, 2.0, 1.0); // #050505 + + // High contrast borders — visible but not harsh + let border_main = hsla(0.0, 0.0, 35.0, 1.0); // #595959 + let border_variant = hsla(0.0, 0.0, 20.0, 1.0); // #333333 + // Accent: bright cyan — maximum visibility on black + let accent = hsla(180.0, 100.0, 50.0, 1.0); // #00ffff + + // Text: pure white for maximum contrast on black + let text = hsla(0.0, 0.0, 100.0, 1.0); // #ffffff + // Muted text: light gray — still >7:1 on black (WCAG AAA) + let text_muted = hsla(0.0, 0.0, 80.0, 1.0); // #cccccc + let text_placeholder = hsla(0.0, 0.0, 64.0, 1.0); // #a3a3a3 — ~7.4:1 on the 4% surface (WCAG AAA) + let text_disabled = hsla(0.0, 0.0, 35.0, 1.0); // #595959 + + // Git status — bright saturated for unambiguous distinction + let vc_added = hsla(120.0, 100.0, 50.0, 1.0); // #00ff00 + let vc_modified = hsla(60.0, 100.0, 50.0, 1.0); // #ffff00 + let vc_deleted = hsla(0.0, 100.0, 55.0, 1.0); // #ff1a1a + let vc_conflict = hsla(30.0, 100.0, 55.0, 1.0); // #ff8c00 + let vc_renamed = hsla(210.0, 100.0, 55.0, 1.0); // #0080ff + let vc_untracked = hsla(180.0, 80.0, 55.0, 1.0); // #00e5e5 + + ThemeColors { + background, + surface_background: surface, + elevated_surface_background: elevated, + editor_background: editor, + + border: border_main, + border_variant, + border_focused: accent, + border_selected: accent, + border_disabled: border_variant, + border_transparent: Hsla { + h: 0.0, + s: 0.0, + l: 0.0, + a: 0.0, + }, + + element_background: surface, + element_hover: elevated, + element_active: hsla(0.0, 0.0, 14.0, 1.0), + element_selected: elevated, + element_disabled: surface, + ghost_element_background: Hsla { + h: 0.0, + s: 0.0, + l: 0.0, + a: 0.0, + }, + ghost_element_hover: hsla(0.0, 0.0, 14.0, 0.6), + ghost_element_active: hsla(0.0, 0.0, 20.0, 0.8), + ghost_element_selected: hsla(0.0, 0.0, 14.0, 0.8), + + text, + text_muted, + text_placeholder, + text_disabled, + text_accent: accent, + + icon: text, + icon_muted: text_muted, + icon_disabled: text_disabled, + icon_accent: accent, + + title_bar_background: background, + toolbar_background: surface, + tab_bar_background: surface, + tab_active_background: elevated, + tab_inactive_background: background, + status_bar_background: surface, + panel_background: editor, + scrollbar_thumb_background: border_main, + scrollbar_thumb_hover_background: text_placeholder, + + hint_background: hsla(180.0, 100.0, 50.0, 0.12), + + vc_added, + vc_modified, + vc_deleted, + vc_conflict, + vc_renamed, + vc_untracked, + } +} + +pub fn high_contrast_dark_status() -> StatusColors { + let red = hsla(0.0, 100.0, 55.0, 1.0); // #ff1a1a + let yellow = hsla(60.0, 100.0, 50.0, 1.0); // #ffff00 + let green = hsla(120.0, 100.0, 50.0, 1.0); // #00ff00 + let blue = hsla(210.0, 100.0, 55.0, 1.0); // #0080ff + let cyan = hsla(180.0, 100.0, 50.0, 1.0); // #00ffff + + StatusColors { + error: red, + error_background: hsla(0.0, 100.0, 55.0, 0.2), + warning: yellow, + warning_background: hsla(60.0, 100.0, 50.0, 0.2), + success: green, + success_background: hsla(120.0, 100.0, 50.0, 0.2), + info: blue, + info_background: hsla(210.0, 100.0, 55.0, 0.2), + hint: cyan, + } +} + +// -- Cream & Blue theme colors -- + +/// Cream & Blue light theme — near-paper surfaces with a Xero-style +/// cyan-blue accent. A barely-warm off-white sits beneath cool-grey +/// element layers, and the accent is a vibrant blue. Designed as a +/// more formal alternative to Catppuccin Latte. +pub fn cream_blue_colors() -> ThemeColors { + let paper = hsla(40.0, 55.0, 96.0, 1.0); + let paper_soft = hsla(40.0, 48.0, 93.0, 1.0); + let paper_softer = hsla(40.0, 40.0, 90.0, 1.0); + + let surface0 = hsla(220.0, 12.0, 92.0, 1.0); + let surface1 = hsla(220.0, 10.0, 86.0, 1.0); + let surface2 = hsla(220.0, 9.0, 78.0, 1.0); + + let overlay0 = hsla(220.0, 7.0, 65.0, 1.0); + let overlay1 = hsla(220.0, 7.0, 50.0, 1.0); + let subtext0 = hsla(220.0, 10.0, 36.0, 1.0); + let text = hsla(220.0, 25.0, 14.0, 1.0); + + let accent = hsla(200.0, 72.0, 50.0, 1.0); + let accent_deep = hsla(205.0, 72.0, 40.0, 1.0); + + let red = hsla(8.0, 65.0, 50.0, 1.0); + let green = hsla(150.0, 48.0, 38.0, 1.0); + let yellow = hsla(38.0, 78.0, 45.0, 1.0); + let orange = hsla(22.0, 80.0, 48.0, 1.0); + let teal = hsla(190.0, 55.0, 38.0, 1.0); + + ThemeColors { + background: paper_softer, + surface_background: paper, + elevated_surface_background: paper_soft, + editor_background: paper, + + border: surface1, + border_variant: surface0, + border_focused: accent_deep, + border_selected: accent_deep, + border_disabled: surface0, + border_transparent: Hsla { + h: 0.0, + s: 0.0, + l: 0.0, + a: 0.0, + }, + + element_background: surface0, + element_hover: surface1, + element_active: surface2, + element_selected: surface1, + element_disabled: surface0, + ghost_element_background: Hsla { + h: 0.0, + s: 0.0, + l: 0.0, + a: 0.0, + }, + ghost_element_hover: hsla(220.0, 12.0, 92.0, 0.5), + ghost_element_active: hsla(220.0, 10.0, 86.0, 0.7), + ghost_element_selected: hsla(220.0, 10.0, 86.0, 0.7), + + text, + text_muted: subtext0, + text_placeholder: overlay1, + text_disabled: overlay0, + text_accent: accent_deep, + + icon: text, + icon_muted: subtext0, + icon_disabled: overlay0, + icon_accent: accent_deep, + + title_bar_background: paper_soft, + toolbar_background: paper, + tab_bar_background: paper_soft, + tab_active_background: paper, + tab_inactive_background: paper_soft, + status_bar_background: paper_soft, + panel_background: paper, + scrollbar_thumb_background: surface1, + scrollbar_thumb_hover_background: surface2, + + hint_background: Hsla { a: 0.10, ..accent }, + + vc_added: green, + vc_modified: yellow, + vc_deleted: red, + vc_conflict: orange, + vc_renamed: accent_deep, + vc_untracked: teal, + } +} + +pub fn cream_blue_status() -> StatusColors { + let red = hsla(8.0, 65.0, 50.0, 1.0); + let yellow = hsla(38.0, 78.0, 45.0, 1.0); + let green = hsla(150.0, 48.0, 38.0, 1.0); + let blue = hsla(205.0, 72.0, 40.0, 1.0); + let teal = hsla(190.0, 55.0, 38.0, 1.0); + + StatusColors { + error: red, + error_background: hsla(8.0, 65.0, 50.0, 0.10), + warning: yellow, + warning_background: hsla(38.0, 78.0, 45.0, 0.10), + success: green, + success_background: hsla(150.0, 48.0, 38.0, 0.10), + info: blue, + info_background: hsla(205.0, 72.0, 40.0, 0.10), + hint: teal, + } +} + +/// Lane colors for the commit graph. Cycles through these for branch visualization. +pub const GRAPH_LANE_COLORS: &[fn() -> Hsla] = &[ + || hsla(267.0, 84.0, 75.0, 1.0), // mauve (boosted) + || hsla(217.0, 92.0, 65.0, 1.0), // blue (boosted) + || hsla(115.0, 60.0, 65.0, 1.0), // green (boosted) + || hsla(23.0, 92.0, 65.0, 1.0), // peach (boosted) + || hsla(343.0, 81.0, 65.0, 1.0), // red (boosted) + || hsla(170.0, 65.0, 60.0, 1.0), // teal (boosted) + || hsla(41.0, 86.0, 70.0, 1.0), // yellow (boosted) + || hsla(189.0, 75.0, 60.0, 1.0), // sapphire (boosted) + || hsla(316.0, 72.0, 72.0, 1.0), // pink (boosted) + || hsla(10.0, 70.0, 75.0, 1.0), // rosewater (boosted) +]; + +pub fn lane_color(index: usize) -> Hsla { + GRAPH_LANE_COLORS[index % GRAPH_LANE_COLORS.len()]() +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── hex_to_hsla ──────────────────────────────────────────────── + + fn approx_eq(a: f32, b: f32) -> bool { + (a - b).abs() < 1e-3 + } + + /// The byte path and the hex path must agree exactly — syntax highlighting + /// uses the former, JSON theme loading the latter. + #[test] + fn rgba_u8_matches_hex_parsing() { + let cases: &[(u8, u8, u8, u8)] = &[ + (0, 0, 0, 255), + (255, 255, 255, 255), + (255, 0, 0, 255), + (0, 255, 0, 255), + (0, 0, 255, 255), + (18, 52, 86, 120), + (200, 130, 65, 255), + (7, 7, 7, 1), + ]; + + for &(r, g, b, a) in cases { + let from_bytes = rgba_u8_to_hsla(r, g, b, a); + let from_hex = hex_to_hsla(&format!("#{r:02x}{g:02x}{b:02x}{a:02x}")); + assert!( + approx_eq(from_bytes.h, from_hex.h) + && approx_eq(from_bytes.s, from_hex.s) + && approx_eq(from_bytes.l, from_hex.l) + && approx_eq(from_bytes.a, from_hex.a), + "mismatch for ({r},{g},{b},{a}): {from_bytes:?} vs {from_hex:?}" + ); + } + } + + #[test] + fn hex_pure_red_rgb() { + // #FF0000 → RGB(1,0,0) → HSL(0°, 100%, 50%) + let c = hex_to_hsla("#FF0000"); + assert!(approx_eq(c.h, 0.0)); + assert!(approx_eq(c.s, 1.0)); + assert!(approx_eq(c.l, 0.5)); + assert!(approx_eq(c.a, 1.0)); + } + + #[test] + fn hex_pure_green_rgb() { + // #00FF00 → HSL(120°/360=0.333…, 100%, 50%) + let c = hex_to_hsla("#00FF00"); + assert!(approx_eq(c.h, 1.0 / 3.0)); + assert!(approx_eq(c.s, 1.0)); + assert!(approx_eq(c.l, 0.5)); + } + + #[test] + fn hex_pure_blue_rgb() { + // #0000FF → HSL(240°/360=0.666…, 100%, 50%) + let c = hex_to_hsla("#0000FF"); + assert!(approx_eq(c.h, 2.0 / 3.0)); + assert!(approx_eq(c.s, 1.0)); + assert!(approx_eq(c.l, 0.5)); + } + + #[test] + fn hex_white() { + // #FFFFFF → grey (s=0), l=1 + let c = hex_to_hsla("#FFFFFF"); + assert!(approx_eq(c.l, 1.0)); + assert!(approx_eq(c.s, 0.0)); + assert!(approx_eq(c.a, 1.0)); + } + + #[test] + fn hex_black() { + // #000000 → l=0 + let c = hex_to_hsla("#000000"); + assert!(approx_eq(c.l, 0.0)); + assert!(approx_eq(c.a, 1.0)); + } + + #[test] + fn hex_with_alpha_ff() { + // #FF0000FF → full opacity red + let c = hex_to_hsla("#FF0000FF"); + assert!(approx_eq(c.a, 1.0)); + } + + #[test] + fn hex_with_alpha_80() { + // #FFFFFF80 → ~50% opacity white + let c = hex_to_hsla("#FFFFFF80"); + assert!(approx_eq(c.a, 0x80 as f32 / 255.0)); + } + + #[test] + fn hex_lowercase_digits() { + // Should work case-insensitively (upper octets accepted since from_str_radix handles both) + let upper = hex_to_hsla("#AABBCC"); + let lower = hex_to_hsla("#aabbcc"); + assert!(approx_eq(upper.h, lower.h)); + assert!(approx_eq(upper.s, lower.s)); + assert!(approx_eq(upper.l, lower.l)); + } + + #[test] + fn hex_without_hash_prefix() { + // hex_to_hsla trims '#' if present; input without '#' should also work + let with = hex_to_hsla("#FF0000"); + let without = hex_to_hsla("FF0000"); + assert!(approx_eq(with.h, without.h)); + assert!(approx_eq(with.s, without.s)); + assert!(approx_eq(with.l, without.l)); + } + + #[test] + fn hex_invalid_length_fallback_black() { + // 5-digit hex is not a recognised length → (0, 0, 0, 1.0) fallback + let c = hex_to_hsla("#ABCDE"); + assert!(approx_eq(c.h, 0.0)); + assert!(approx_eq(c.s, 0.0)); + assert!(approx_eq(c.l, 0.0)); + assert!(approx_eq(c.a, 1.0)); + } + + #[test] + fn hex_empty_fallback_black() { + let c = hex_to_hsla(""); + assert!(approx_eq(c.a, 1.0)); + assert!(approx_eq(c.l, 0.0)); + } + + // ── hex_to_hsla_strict ───────────────────────────────────────── + + #[test] + fn hex_strict_valid_6_digit() { + let c = hex_to_hsla_strict("#FF0000").unwrap(); + assert!(approx_eq(c.h, 0.0)); + assert!(approx_eq(c.s, 1.0)); + assert!(approx_eq(c.l, 0.5)); + assert!(approx_eq(c.a, 1.0)); + } + + #[test] + fn hex_strict_valid_8_digit_with_alpha() { + let c = hex_to_hsla_strict("#FF000080").unwrap(); + assert!(approx_eq(c.h, 0.0)); + assert!(approx_eq(c.s, 1.0)); + assert!(approx_eq(c.l, 0.5)); + assert!(approx_eq(c.a, 128.0 / 255.0)); + } + + #[test] + fn hex_strict_valid_3_digit() { + // #ABC → A=10→170, B=11→187, C=12→204 (each ×17) + // max=204, min=170 → l≈0.733, s≈0.063 (near-grey, slightly blue-ish) + let c = hex_to_hsla_strict("#ABC").unwrap(); + assert!(approx_eq(c.a, 1.0)); + // Just verify it's a valid color (h,s,l in [0,1]) + assert!(c.h >= 0.0 && c.h <= 1.0); + assert!(c.s >= 0.0 && c.s <= 1.0); + assert!(c.l >= 0.0 && c.l <= 1.0); + } + + #[test] + fn hex_strict_valid_4_digit() { + let c = hex_to_hsla_strict("#ABCD").unwrap(); + // A=10→170, B=11→187, C=12→204, D=13→221 + // R=170, G=187, B=204, A=221 + assert!(approx_eq(c.a, 221.0 / 255.0)); + } + + #[test] + fn hex_strict_invalid_garbage_returns_none() { + assert!(hex_to_hsla_strict("garbage").is_none()); + assert!(hex_to_hsla_strict("xyz123").is_none()); + assert!(hex_to_hsla_strict("#XYZ").is_none()); + } + + #[test] + fn hex_strict_invalid_length_returns_none() { + assert!(hex_to_hsla_strict("#AB").is_none()); // 2 chars + assert!(hex_to_hsla_strict("#AB123").is_none()); // 5 chars + assert!(hex_to_hsla_strict("#AB1234567").is_none()); // 9 chars + } + + #[test] + fn hex_strict_empty_returns_none() { + assert!(hex_to_hsla_strict("").is_none()); + assert!(hex_to_hsla_strict("#").is_none()); + } + + #[test] + fn hex_strict_no_hash_prefix_works() { + let c = hex_to_hsla_strict("FF0000").unwrap(); + assert!(approx_eq(c.h, 0.0)); + assert!(approx_eq(c.s, 1.0)); + } + + #[test] + fn hex_strict_roundtrip_red() { + let c = hex_to_hsla_strict("#FF0000").unwrap(); + let hex = hsla_to_hex(c); + assert_eq!(&hex, "#ff0000"); + } + + #[test] + fn hex_strict_roundtrip_catppuccin_mocha_background() { + let c = hex_to_hsla_strict("#1e1e2e").unwrap(); + let reloaded = hex_to_hsla_strict(&hsla_to_hex(c)); + assert!(reloaded.is_some()); + assert!(approx_eq(reloaded.unwrap().l, c.l)); + } + + // ── lane_color ───────────────────────────────────────────────── + + #[test] + fn lane_color_index_zero_is_valid() { + let c = lane_color(0); + // Just verify it returns a fully opaque color (a == 1.0) + assert!(approx_eq(c.a, 1.0)); + } + + #[test] + fn lane_color_wraps_modulo_palette_length() { + let n = super::GRAPH_LANE_COLORS.len(); + let c0 = lane_color(0); + let cn = lane_color(n); + // Wrapping should give the same color as index 0 + assert!(approx_eq(c0.h, cn.h)); + assert!(approx_eq(c0.s, cn.s)); + assert!(approx_eq(c0.l, cn.l)); + } + + #[test] + fn lane_color_all_indices_opaque() { + let n = super::GRAPH_LANE_COLORS.len(); + for i in 0..n { + let c = lane_color(i); + assert!( + approx_eq(c.a, 1.0), + "lane_color({}) has unexpected alpha {}", + i, + c.a + ); + } + } + + // ── high_contrast_dark ──────────────────────────────────────── + + #[test] + fn high_contrast_dark_is_fully_opaque() { + let colors = super::high_contrast_dark_colors(); + assert!(approx_eq(colors.background.a, 1.0)); + assert!(approx_eq(colors.text.a, 1.0)); + assert!(approx_eq(colors.vc_added.a, 1.0)); + } + + #[test] + fn high_contrast_dark_text_is_pure_white() { + let colors = super::high_contrast_dark_colors(); + assert!(approx_eq(colors.text.h, 0.0)); + assert!(approx_eq(colors.text.s, 0.0)); + assert!(approx_eq(colors.text.l, 1.0)); + } + + #[test] + fn high_contrast_dark_background_is_black() { + let colors = super::high_contrast_dark_colors(); + assert!(approx_eq(colors.background.h, 0.0)); + assert!(approx_eq(colors.background.s, 0.0)); + assert!(approx_eq(colors.background.l, 0.0)); + } + + #[test] + fn high_contrast_dark_vc_colors_are_distinct() { + let colors = super::high_contrast_dark_colors(); + // All VC colors must be fully saturated (s=1.0) and fully opaque + assert!(approx_eq(colors.vc_added.s, 1.0)); + assert!(approx_eq(colors.vc_modified.s, 1.0)); + assert!(approx_eq(colors.vc_deleted.s, 1.0)); + assert!(approx_eq(colors.vc_added.a, 1.0)); + assert!(approx_eq(colors.vc_modified.a, 1.0)); + assert!(approx_eq(colors.vc_deleted.a, 1.0)); + // And visually distinct hues + assert_ne!(colors.vc_added.h, colors.vc_modified.h); + assert_ne!(colors.vc_modified.h, colors.vc_deleted.h); + assert_ne!(colors.vc_added.h, colors.vc_deleted.h); + } + + #[test] + fn high_contrast_dark_status_is_fully_opaque() { + let status = super::high_contrast_dark_status(); + assert!(approx_eq(status.error.a, 1.0)); + assert!(approx_eq(status.success.a, 1.0)); + assert!(approx_eq(status.warning.a, 1.0)); + assert!(approx_eq(status.info.a, 1.0)); + } + + // ── hsla_to_hex ────────────────────────────────────────────── + + fn approx_eq_f32(a: f32, b: f32) -> bool { + (a - b).abs() < 1e-3 + } + + #[test] + fn hsla_to_hex_roundtrip_red() { + // #FF0000 → hex_to_hsla → hsla_to_hex → #ff0000 (6-char, fully opaque) + let c = super::hex_to_hsla("#FF0000"); + let hex = super::hsla_to_hex(c); + assert_eq!(hex.len(), 7); // # + 6 hex chars + assert_eq!(&hex[..7], "#ff0000"); + } + + #[test] + fn hsla_to_hex_roundtrip_with_alpha() { + // #FFFFFF80 → hex_to_hsla → hsla_to_hex → #ffffff80 (8-char with alpha) + let c = super::hex_to_hsla("#FFFFFF80"); + let hex = super::hsla_to_hex(c); + assert_eq!(hex.len(), 9); // # + 8 hex chars + assert_eq!(&hex[..7], "#ffffff"); + assert!(hex.ends_with("80") || hex.ends_with("7f")); + } + + #[test] + fn hsla_to_hex_preserves_black() { + let c = super::hex_to_hsla("#000000"); + let hex = super::hsla_to_hex(c); + assert_eq!(&hex, "#000000"); + } + + #[test] + fn hsla_to_hex_preserves_white() { + let c = super::hex_to_hsla("#FFFFFF"); + let hex = super::hsla_to_hex(c); + assert_eq!(&hex, "#ffffff"); + } + + #[test] + fn hsla_to_hex_roundtrip_catppuccin_mocha() { + // HSL → hex → HSL roundtrip for non-pure colors. + // Saturation may drift slightly due to floating-point HSL↔RGB conversion. + // We only check that hue and lightness are preserved within 0.02 tolerance. + let mocha = super::catppuccin_mocha_colors(); + let bg_hex = super::hsla_to_hex(mocha.background); + let reloaded = super::hex_to_hsla(&bg_hex); + assert!( + approx_eq_f32(reloaded.l, mocha.background.l), + "lightness drifted: expected {}, got {}", + mocha.background.l, + reloaded.l + ); + // Alpha should always be 1.0 for fully-opaque colors + assert!(approx_eq_f32(reloaded.a, 1.0)); + } + + #[test] + fn hsla_to_hex_transparent_alpha_omitted() { + // Fully opaque (a=1.0) → 6-char output (no alpha suffix) + let c = super::hex_to_hsla("#123456"); + assert!(approx_eq_f32(c.a, 1.0)); + let hex = super::hsla_to_hex(c); + assert_eq!(hex.len(), 7); + } + + #[test] + fn hsla_to_hex_non_opaque_alpha_included() { + // Non-1.0 alpha → 9-char output with alpha suffix + let c = super::hex_to_hsla("#123456AB"); + assert!(!approx_eq_f32(c.a, 1.0)); + let hex = super::hsla_to_hex(c); + assert_eq!(hex.len(), 9); + } +} diff --git a/third_party/rgitui/crates/rgitui_theme/src/json_theme.rs b/third_party/rgitui/crates/rgitui_theme/src/json_theme.rs new file mode 100644 index 000000000..5871e74a6 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_theme/src/json_theme.rs @@ -0,0 +1,404 @@ +use std::collections::HashMap; + +use anyhow::{anyhow, Result}; +use gpui::Hsla; +use serde::Deserialize; + +use crate::colors::{hex_to_hsla, StatusColors, ThemeColors}; +use crate::theme::{Appearance, Theme}; + +/// Raw JSON representation of a theme file. +#[derive(Debug, Deserialize)] +struct JsonTheme { + name: String, + appearance: String, + colors: HashMap, + status: HashMap, +} + +/// Parse a hex color from the map, returning an error if missing. +fn get_color(map: &HashMap, key: &str) -> Result { + let hex = map + .get(key) + .ok_or_else(|| anyhow!("missing color key: {}", key))?; + Ok(hex_to_hsla(hex)) +} + +/// Parse a hex color from the map, falling back to a default if the key is absent. +fn get_color_or(map: &HashMap, key: &str, default: Hsla) -> Hsla { + map.get(key).map_or(default, |hex| hex_to_hsla(hex)) +} + +/// Load a complete Theme from a JSON string. +pub fn load_theme_from_json(json: &str) -> Result { + let raw: JsonTheme = serde_json::from_str(json)?; + + let appearance = match raw.appearance.as_str() { + "light" => Appearance::Light, + "dark" => Appearance::Dark, + other => return Err(anyhow!("unknown appearance: {}", other)), + }; + + let c = &raw.colors; + let s = &raw.status; + + let colors = ThemeColors { + background: get_color(c, "background")?, + surface_background: get_color(c, "surface_background")?, + elevated_surface_background: get_color(c, "elevated_surface_background")?, + editor_background: get_color(c, "editor_background")?, + + border: get_color(c, "border")?, + border_variant: get_color(c, "border_variant")?, + border_focused: get_color(c, "border_focused")?, + border_selected: get_color(c, "border_selected")?, + border_disabled: get_color(c, "border_disabled")?, + border_transparent: get_color(c, "border_transparent")?, + + element_background: get_color(c, "element_background")?, + element_hover: get_color(c, "element_hover")?, + element_active: get_color(c, "element_active")?, + element_selected: get_color(c, "element_selected")?, + element_disabled: get_color(c, "element_disabled")?, + ghost_element_background: get_color(c, "ghost_element_background")?, + ghost_element_hover: get_color(c, "ghost_element_hover")?, + ghost_element_active: get_color(c, "ghost_element_active")?, + ghost_element_selected: get_color(c, "ghost_element_selected")?, + + text: get_color(c, "text")?, + text_muted: get_color(c, "text_muted")?, + text_placeholder: get_color(c, "text_placeholder")?, + text_disabled: get_color(c, "text_disabled")?, + text_accent: get_color(c, "text_accent")?, + + icon: get_color(c, "icon")?, + icon_muted: get_color(c, "icon_muted")?, + icon_disabled: get_color(c, "icon_disabled")?, + icon_accent: get_color(c, "icon_accent")?, + + title_bar_background: get_color(c, "title_bar_background")?, + toolbar_background: get_color(c, "toolbar_background")?, + tab_bar_background: get_color(c, "tab_bar_background")?, + tab_active_background: get_color(c, "tab_active_background")?, + tab_inactive_background: get_color(c, "tab_inactive_background")?, + status_bar_background: get_color(c, "status_bar_background")?, + panel_background: get_color(c, "panel_background")?, + scrollbar_thumb_background: get_color(c, "scrollbar_thumb_background")?, + scrollbar_thumb_hover_background: get_color(c, "scrollbar_thumb_hover_background")?, + + hint_background: get_color_or( + c, + "hint_background", + Hsla { + h: 0.0, + s: 0.0, + l: 0.5, + a: 0.1, + }, + ), + + vc_added: get_color(c, "vc_added")?, + vc_modified: get_color(c, "vc_modified")?, + vc_deleted: get_color(c, "vc_deleted")?, + vc_conflict: get_color(c, "vc_conflict")?, + vc_renamed: get_color(c, "vc_renamed")?, + vc_untracked: get_color(c, "vc_untracked")?, + }; + + let status = StatusColors { + error: get_color(s, "error")?, + error_background: get_color(s, "error_background")?, + warning: get_color(s, "warning")?, + warning_background: get_color(s, "warning_background")?, + success: get_color(s, "success")?, + success_background: get_color(s, "success_background")?, + info: get_color(s, "info")?, + info_background: get_color(s, "info_background")?, + hint: get_color(s, "hint")?, + }; + + Ok(Theme { + name: raw.name, + appearance, + colors, + status, + }) +} + +fn theme_filename(name: &str) -> String { + let mut slug = String::with_capacity(name.len()); + let mut last_was_dash = false; + + for ch in name.chars() { + let mapped = if ch.is_ascii_alphanumeric() { + Some(ch.to_ascii_lowercase()) + } else if ch.is_whitespace() || ch.is_ascii_punctuation() || matches!(ch, '-' | '_') { + Some('-') + } else { + None + }; + + if let Some(ch) = mapped { + if ch == '-' { + if last_was_dash || slug.is_empty() { + continue; + } + last_was_dash = true; + } else { + last_was_dash = false; + } + slug.push(ch); + } + } + + while slug.ends_with('-') { + slug.pop(); + } + + if slug.is_empty() { + "theme.json".to_string() + } else { + format!("{slug}.json") + } +} + +/// Serialize a theme to a JSON string for saving to disk. +/// The output format is compatible with `load_theme_from_json`. +pub fn serialize_theme_to_json(theme: &Theme) -> Result { + serde_json::to_string_pretty(theme).map_err(|e| anyhow!("failed to serialize theme: {}", e)) +} + +/// Where user-defined themes are read from and written to. +/// +/// Resolved through `rgitui_settings` rather than from `dirs` directly, so a +/// run with its state redirected — a measured run, which must not read the +/// user's themes or write into their config — reads and writes themes under +/// that root like every other piece of persisted state. +pub fn themes_dir() -> std::path::PathBuf { + rgitui_settings::config_dir().join("themes") +} + +/// Save a theme to the user's themes directory (`/rgitui/themes/`). +/// Creates the directory if it does not exist. +pub fn save_theme_to_file(theme: &Theme) -> Result { + let json = serialize_theme_to_json(theme)?; + let themes_dir = themes_dir(); + std::fs::create_dir_all(&themes_dir)?; + let path = themes_dir.join(theme_filename(&theme.name)); + std::fs::write(&path, json)?; + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── helpers ──────────────────────────────────────────────────── + + /// Build a minimal valid JSON theme string with every required key. + fn minimal_dark_theme_json(name: &str) -> String { + let color = "#1e1e2e"; + let status = "#ff0000"; + format!( + r#"{{ + "name": "{name}", + "appearance": "dark", + "colors": {{ + "background": "{c}", + "surface_background": "{c}", + "elevated_surface_background": "{c}", + "editor_background": "{c}", + "border": "{c}", + "border_variant": "{c}", + "border_focused": "{c}", + "border_selected": "{c}", + "border_disabled": "{c}", + "border_transparent": "{c}", + "element_background": "{c}", + "element_hover": "{c}", + "element_active": "{c}", + "element_selected": "{c}", + "element_disabled": "{c}", + "ghost_element_background": "{c}", + "ghost_element_hover": "{c}", + "ghost_element_active": "{c}", + "ghost_element_selected": "{c}", + "text": "{c}", + "text_muted": "{c}", + "text_placeholder": "{c}", + "text_disabled": "{c}", + "text_accent": "{c}", + "icon": "{c}", + "icon_muted": "{c}", + "icon_disabled": "{c}", + "icon_accent": "{c}", + "title_bar_background": "{c}", + "toolbar_background": "{c}", + "tab_bar_background": "{c}", + "tab_active_background": "{c}", + "tab_inactive_background": "{c}", + "status_bar_background": "{c}", + "panel_background": "{c}", + "scrollbar_thumb_background": "{c}", + "scrollbar_thumb_hover_background": "{c}", + "vc_added": "{c}", + "vc_modified": "{c}", + "vc_deleted": "{c}", + "vc_conflict": "{c}", + "vc_renamed": "{c}", + "vc_untracked": "{c}" + }}, + "status": {{ + "error": "{s}", + "error_background": "{s}", + "warning": "{s}", + "warning_background": "{s}", + "success": "{s}", + "success_background": "{s}", + "info": "{s}", + "info_background": "{s}", + "hint": "{s}" + }} + }}"#, + name = name, + c = color, + s = status, + ) + } + + // ── load_theme_from_json ─────────────────────────────────────── + + #[test] + fn load_valid_dark_theme() { + let json = minimal_dark_theme_json("Test Dark"); + let theme = load_theme_from_json(&json).expect("should parse"); + assert_eq!(theme.name, "Test Dark"); + assert_eq!(theme.appearance, crate::theme::Appearance::Dark); + } + + #[test] + fn load_valid_light_theme() { + // Reuse helper, then patch appearance field + let json = minimal_dark_theme_json("Test Light").replace("\"dark\"", "\"light\""); + let theme = load_theme_from_json(&json).expect("should parse"); + assert_eq!(theme.appearance, crate::theme::Appearance::Light); + } + + #[test] + fn load_unknown_appearance_errors() { + let json = minimal_dark_theme_json("Bad").replace("\"dark\"", "\"rainbow\""); + let err = load_theme_from_json(&json).unwrap_err(); + assert!(err.to_string().contains("unknown appearance")); + } + + #[test] + fn load_missing_required_color_key_errors() { + // Remove one required key + let json = + minimal_dark_theme_json("Incomplete").replace("\"background\":", "\"_background\":"); + let err = load_theme_from_json(&json).unwrap_err(); + assert!(err.to_string().contains("missing color key")); + } + + #[test] + fn load_invalid_json_errors() { + let err = load_theme_from_json("{not valid json").unwrap_err(); + // anyhow wraps serde_json error + assert!(!err.to_string().is_empty()); + } + + #[test] + fn load_optional_hint_background_absent_uses_default() { + // hint_background has a default — it's OK to omit it + let json = minimal_dark_theme_json("No Hint"); + // hint_background is not in the minimal template — verify it parses fine + let theme = load_theme_from_json(&json).expect("should parse without hint_background"); + assert_eq!(theme.name, "No Hint"); + } + + // ── serialize / roundtrip ────────────────────────────────────── + + #[test] + fn serialize_theme_to_json_produces_valid_json() { + let theme = + load_theme_from_json(&minimal_dark_theme_json("Roundtrip Test")).expect("should parse"); + let json = serialize_theme_to_json(&theme).expect("should serialize"); + // Should be parseable as JSON + let reparsed: serde_json::Value = + serde_json::from_str(&json).expect("should be valid JSON"); + assert_eq!(reparsed["name"], "Roundtrip Test"); + assert_eq!(reparsed["appearance"], "dark"); + // Colors should be present as hex strings + let colors = &reparsed["colors"]; + assert!(colors["background"].as_str().unwrap().starts_with('#')); + // Status should be present + let status = &reparsed["status"]; + assert!(status["error"].as_str().unwrap().starts_with('#')); + } + + #[test] + fn serialize_deserialize_roundtrip() { + let theme = + load_theme_from_json(&minimal_dark_theme_json("Roundtrip Test")).expect("should parse"); + let json = serialize_theme_to_json(&theme).expect("should serialize"); + let loaded = load_theme_from_json(&json).expect("should deserialize"); + assert_eq!(loaded.name, theme.name); + assert_eq!(loaded.appearance, theme.appearance); + // Verify colors roundtrip (Hsla → #rrggbbaa → Hsla via hex_to_hsla) + assert_eq!(loaded.colors.background, theme.colors.background); + assert_eq!(loaded.status.error, theme.status.error); + } + + #[test] + fn serialize_deserialize_roundtrip_light_appearance() { + // A Light theme — ensure Light appearance and all colors roundtrip correctly + let theme = load_theme_from_json( + &minimal_dark_theme_json("Light Test").replace("\"dark\"", "\"light\""), + ) + .expect("should parse"); + assert_eq!(theme.appearance, crate::theme::Appearance::Light); + let json = serialize_theme_to_json(&theme).expect("should serialize"); + let loaded = load_theme_from_json(&json).expect("should deserialize"); + assert_eq!(loaded.name, theme.name); + assert_eq!(loaded.appearance, theme.appearance); + // Surface and elevated surface colors should roundtrip identically + assert_eq!( + loaded.colors.surface_background, + theme.colors.surface_background + ); + assert_eq!( + loaded.colors.elevated_surface_background, + theme.colors.elevated_surface_background + ); + } + + #[test] + fn serialize_roundtrip_preserves_alpha_channel() { + // Build a theme with explicit alpha != 1.0 and verify it roundtrips. + // Hsla serializes as #RRGGBBAA (8-char with alpha suffix). + // load_theme_from_json -> hex_to_hsla parses the 8-char form back to Hsla. + let theme = + load_theme_from_json(&minimal_dark_theme_json("Roundtrip Test")).expect("should parse"); + let json = serialize_theme_to_json(&theme).expect("should serialize"); + // Verify 8-char hex appears in the serialized JSON (alpha suffix) + assert!( + json.contains("#1e1e2e"), + "serialized JSON should contain background color hex" + ); + let loaded = load_theme_from_json(&json).expect("should deserialize with alpha"); + // Alpha channel must be preserved through the roundtrip + assert_eq!(loaded.colors.background, theme.colors.background); + assert_eq!(loaded.colors.text, theme.colors.text); + } + + #[test] + fn theme_filename_slugifies_for_cross_platform_paths() { + assert_eq!(theme_filename("My Theme"), "my-theme.json"); + assert_eq!(theme_filename(" Theme:/Name? *v2* "), "theme-name-v2.json"); + } + + #[test] + fn theme_filename_falls_back_when_name_has_no_safe_characters() { + assert_eq!(theme_filename(":/\\*?"), "theme.json"); + } +} diff --git a/third_party/rgitui/crates/rgitui_theme/src/lib.rs b/third_party/rgitui/crates/rgitui_theme/src/lib.rs new file mode 100644 index 000000000..d7235af28 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_theme/src/lib.rs @@ -0,0 +1,23 @@ +mod colors; +pub mod json_theme; +mod theme; + +pub use colors::*; +pub use theme::*; + +/// Set the active theme by name. Convenience wrapper around ThemeState. +pub fn set_theme(name: &str, cx: &mut gpui::App) { + use gpui::BorrowAppContext; + cx.update_global::(|state: &mut ThemeState, _cx| { + state.set_theme_by_name(name); + }); +} + +/// Return the names of all available built-in themes. +pub fn available_themes(cx: &gpui::App) -> Vec { + cx.global::() + .available_themes() + .iter() + .map(|t| t.name.clone()) + .collect() +} diff --git a/third_party/rgitui/crates/rgitui_theme/src/theme.rs b/third_party/rgitui/crates/rgitui_theme/src/theme.rs new file mode 100644 index 000000000..6604745e0 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_theme/src/theme.rs @@ -0,0 +1,402 @@ +use std::sync::Arc; + +use gpui::{point, px, App, BoxShadow, Global, Hsla, Styled}; +use rgitui_settings::AppearanceMode; +use serde::Serialize; + +use crate::colors::{ + catppuccin_latte_colors, catppuccin_latte_status, catppuccin_mocha_colors, + catppuccin_mocha_status, cream_blue_colors, cream_blue_status, dracula_colors, dracula_status, + github_dark_colors, github_dark_status, high_contrast_dark_colors, high_contrast_dark_status, + one_dark_colors, one_dark_status, StatusColors, ThemeColors, +}; + +/// Visual appearance mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Appearance { + Light, + Dark, +} + +/// A complete theme definition. +#[derive(Debug, Clone, Serialize)] +pub struct Theme { + pub name: String, + pub appearance: Appearance, + pub colors: ThemeColors, + pub status: StatusColors, +} + +/// Global theme state stored in GPUI's global system. +pub struct ThemeState { + active: Arc, + available: Vec>, +} + +impl Global for ThemeState {} + +fn upsert_theme_by_name(available: &mut Vec>, theme: Arc) { + if let Some(existing) = available.iter_mut().find(|t| t.name == theme.name) { + *existing = theme; + } else { + available.push(theme); + } +} + +impl ThemeState { + pub fn theme(&self) -> &Arc { + &self.active + } + + pub fn appearance(&self) -> Appearance { + self.active.appearance + } + + pub fn available_themes(&self) -> &[Arc] { + &self.available + } + + /// Returns themes filtered by the given appearance mode. + pub fn themes_for_appearance(&self, mode: AppearanceMode) -> Vec> { + match mode { + AppearanceMode::Auto => self.available.clone(), + AppearanceMode::Light => self + .available + .iter() + .filter(|t| t.appearance == Appearance::Light) + .cloned() + .collect(), + AppearanceMode::Dark => self + .available + .iter() + .filter(|t| t.appearance == Appearance::Dark) + .cloned() + .collect(), + } + } + + pub fn set_theme_by_name(&mut self, name: &str) { + if let Some(theme) = self.available.iter().find(|t| t.name == name) { + self.active = theme.clone(); + } + } + + /// Set the active theme directly (used for custom themes). + pub fn set_active_theme(&mut self, theme: Arc) { + self.active = theme; + } + + /// Insert a custom theme into the available list and activate it. + /// If a theme with the same name already exists, it is replaced. + pub fn insert_theme(&mut self, theme: Arc) { + upsert_theme_by_name(&mut self.available, theme.clone()); + self.active = theme; + } +} + +/// Convenience trait to access the theme from App context. +pub trait ActiveTheme { + fn theme(&self) -> &Arc; + fn colors(&self) -> &ThemeColors; + fn status(&self) -> &StatusColors; +} + +impl ActiveTheme for App { + fn theme(&self) -> &Arc { + self.global::().theme() + } + + fn colors(&self) -> &ThemeColors { + &self.global::().theme().colors + } + + fn status(&self) -> &StatusColors { + &self.global::().theme().status + } +} + +/// Build all built-in themes. +/// +/// This is the source of truth for the built-in themes loaded at runtime by +/// [`init`]. They are defined in Rust so they are always available and cannot +/// drift from on-disk assets. JSON is used at runtime only for user-defined +/// themes loaded from the config directory and for exporting/importing themes. +pub fn builtin_themes() -> Vec> { + vec![ + Arc::new(Theme { + name: "Catppuccin Mocha".into(), + appearance: Appearance::Dark, + colors: catppuccin_mocha_colors(), + status: catppuccin_mocha_status(), + }), + Arc::new(Theme { + name: "Catppuccin Latte".into(), + appearance: Appearance::Light, + colors: catppuccin_latte_colors(), + status: catppuccin_latte_status(), + }), + Arc::new(Theme { + name: "Cream & Blue".into(), + appearance: Appearance::Light, + colors: cream_blue_colors(), + status: cream_blue_status(), + }), + Arc::new(Theme { + name: "One Dark".into(), + appearance: Appearance::Dark, + colors: one_dark_colors(), + status: one_dark_status(), + }), + Arc::new(Theme { + name: "GitHub Dark".into(), + appearance: Appearance::Dark, + colors: github_dark_colors(), + status: github_dark_status(), + }), + Arc::new(Theme { + name: "Dracula".into(), + appearance: Appearance::Dark, + colors: dracula_colors(), + status: dracula_status(), + }), + Arc::new(Theme { + name: "High Contrast Dark".into(), + appearance: Appearance::Dark, + colors: high_contrast_dark_colors(), + status: high_contrast_dark_status(), + }), + ] +} + +/// Initialize the theme system. Must be called during app init. +/// +/// Built-in themes come from [`builtin_themes`]; any user-defined JSON themes +/// in the config directory are loaded on top, replacing a built-in only when +/// their names match. +pub fn init(cx: &mut App) { + let mut available = builtin_themes(); + + let themes_dir = crate::json_theme::themes_dir(); + if let Ok(entries) = std::fs::read_dir(&themes_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|e| e == "json") { + match std::fs::read_to_string(&path) { + Ok(json) => match crate::json_theme::load_theme_from_json(&json) { + Ok(theme) => { + log::info!("Loaded custom theme: {}", theme.name); + upsert_theme_by_name(&mut available, Arc::new(theme)); + } + Err(e) => log::warn!("Failed to parse theme {}: {}", path.display(), e), + }, + Err(e) => log::warn!("Failed to read theme {}: {}", path.display(), e), + } + } + } + } + + let active = available[0].clone(); // Catppuccin Mocha default + cx.set_global(ThemeState { active, available }); +} + +// -- Elevation helpers -- + +/// Elevation levels for layered surfaces. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ElevationIndex { + Background, + Surface, + ElevatedSurface, + ModalSurface, +} + +impl ElevationIndex { + pub fn bg(&self, cx: &App) -> Hsla { + let colors = cx.colors(); + match self { + ElevationIndex::Background => colors.background, + ElevationIndex::Surface => colors.surface_background, + ElevationIndex::ElevatedSurface => colors.elevated_surface_background, + ElevationIndex::ModalSurface => colors.elevated_surface_background, + } + } + + pub fn shadow(&self) -> Vec { + let black = |a: f32| Hsla { + h: 0.0, + s: 0.0, + l: 0.0, + a, + }; + match self { + ElevationIndex::Background => vec![], + ElevationIndex::Surface => vec![BoxShadow { + color: black(0.05), + offset: point(px(0.), px(1.)), + blur_radius: px(2.), + spread_radius: px(0.), + inset: false, + }], + ElevationIndex::ElevatedSurface => vec![ + BoxShadow { + color: black(0.08), + offset: point(px(0.), px(2.)), + blur_radius: px(4.), + spread_radius: px(0.), + inset: false, + }, + BoxShadow { + color: black(0.04), + offset: point(px(0.), px(1.)), + blur_radius: px(2.), + spread_radius: px(0.), + inset: false, + }, + ], + ElevationIndex::ModalSurface => vec![ + BoxShadow { + color: black(0.12), + offset: point(px(0.), px(4.)), + blur_radius: px(8.), + spread_radius: px(0.), + inset: false, + }, + BoxShadow { + color: black(0.08), + offset: point(px(0.), px(2.)), + blur_radius: px(4.), + spread_radius: px(0.), + inset: false, + }, + BoxShadow { + color: black(0.06), + offset: point(px(0.), px(8.)), + blur_radius: px(16.), + spread_radius: px(0.), + inset: false, + }, + ], + } + } +} + +/// Extension trait providing Zed-like styling helpers on any Styled element. +pub trait StyledExt: Styled + Sized { + /// Horizontal flex layout with centered items. + fn h_flex(self) -> Self { + self.flex().flex_row().items_center() + } + + /// Vertical flex layout. + fn v_flex(self) -> Self { + self.flex().flex_col() + } + + /// Surface elevation — panels, tab bars. + fn elevation_1(self, cx: &App) -> Self { + let colors = cx.colors(); + let shadows = ElevationIndex::Surface.shadow(); + self.bg(colors.surface_background) + .rounded_lg() + .border_1() + .border_color(colors.border) + .shadow(shadows) + } + + /// Elevated surface — dropdowns, menus, floating panels. + fn elevation_2(self, cx: &App) -> Self { + let colors = cx.colors(); + let shadows = ElevationIndex::ElevatedSurface.shadow(); + self.bg(colors.elevated_surface_background) + .rounded_lg() + .border_1() + .border_color(colors.border) + .shadow(shadows) + } + + /// Modal surface — dialogs, modals. + fn elevation_3(self, cx: &App) -> Self { + let colors = cx.colors(); + let shadows = ElevationIndex::ModalSurface.shadow(); + self.bg(colors.elevated_surface_background) + .rounded_lg() + .border_1() + .border_color(colors.border) + .shadow(shadows) + } + + /// Apply a primary border using the theme's main border color. + fn border_primary(self, cx: &App) -> Self { + let colors = cx.colors(); + self.border_1().border_color(colors.border) + } + + /// Apply a muted/subtle border using the theme's variant border color. + fn border_muted(self, cx: &App) -> Self { + let colors = cx.colors(); + self.border_1().border_color(colors.border_variant) + } + + /// Apply a focused border using the theme's focused border color. + fn border_focused_style(self, cx: &App) -> Self { + let colors = cx.colors(); + self.border_1().border_color(colors.border_focused) + } + + /// Apply the muted text color from the active theme. + fn text_color_muted(self, cx: &App) -> Self { + let colors = cx.colors(); + self.text_color(colors.text_muted) + } +} + +impl StyledExt for E {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn upsert_theme_by_name_replaces_existing_theme() { + let mut available = builtin_themes(); + let replacement = Arc::new(Theme { + name: "Catppuccin Mocha".into(), + appearance: Appearance::Light, + colors: catppuccin_latte_colors(), + status: catppuccin_latte_status(), + }); + + upsert_theme_by_name(&mut available, replacement.clone()); + + assert_eq!(available.len(), builtin_themes().len()); + let stored = available + .iter() + .find(|theme| theme.name == "Catppuccin Mocha") + .expect("replacement theme should be present"); + assert_eq!(stored.appearance, Appearance::Light); + } + + #[test] + fn upsert_theme_by_name_inserts_new_theme() { + let mut available = builtin_themes(); + let initial_len = available.len(); + let new_theme = Arc::new(Theme { + name: "My Custom Theme".into(), + appearance: Appearance::Dark, + colors: catppuccin_mocha_colors(), + status: catppuccin_mocha_status(), + }); + + upsert_theme_by_name(&mut available, new_theme.clone()); + + assert_eq!(available.len(), initial_len + 1); + let stored = available + .iter() + .find(|theme| theme.name == "My Custom Theme") + .expect("new theme should be present"); + assert_eq!(stored.appearance, Appearance::Dark); + // Existing themes should be unaffected + assert!(available.iter().any(|t| t.name == "Catppuccin Mocha")); + } +} diff --git a/third_party/rgitui/crates/rgitui_ui/Cargo.toml b/third_party/rgitui/crates/rgitui_ui/Cargo.toml new file mode 100644 index 000000000..d36a00542 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_ui/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "rgitui_ui" +version = "0.4.1" +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[features] +perf = [] +[dependencies] +gpui.workspace = true +rgitui_theme.workspace = true +rgitui_settings.workspace = true +smallvec.workspace = true +log.workspace = true diff --git a/third_party/rgitui/crates/rgitui_ui/src/avatar_cache.rs b/third_party/rgitui/crates/rgitui_ui/src/avatar_cache.rs new file mode 100644 index 000000000..02bd63239 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_ui/src/avatar_cache.rs @@ -0,0 +1,472 @@ +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fs; +use std::path; +use std::sync::Mutex; + +use gpui::Global; + +static DISK_CACHE_WRITE_LOCK: Mutex<()> = Mutex::new(()); + +/// Resolved avatar state for an email address. +#[derive(Clone, Debug)] +pub enum AvatarState { + /// Successfully resolved to a URL. + Resolved(String), + /// Tried all sources, no avatar found. Tracks retry count. + NotFound(u8), +} + +/// Global cache mapping email addresses to avatar image URLs. +/// +/// Render code reads from this via `cx.try_global::()`. +/// Resolution tasks (spawned by the workspace) write to it and notify views. +/// +/// Optionally persisted to disk at `$CONFIG_DIR/rgitui/avatar_cache.txt` +/// (one line per resolved entry: `email=url`). NotFound entries are not +/// persisted — they are re-attempted on next startup up to 3 times. +pub struct AvatarCache { + cache: HashMap, + pending: HashSet, + order: VecDeque, +} + +impl Global for AvatarCache {} + +impl Default for AvatarCache { + fn default() -> Self { + Self::new() + } +} + +impl AvatarCache { + const MAX_ENTRIES: usize = 2000; + + /// Returns the path where the avatar disk cache is stored. + fn disk_cache_path() -> path::PathBuf { + rgitui_settings::config_dir() + .join("rgitui") + .join("avatar_cache.txt") + } + + /// Load resolved avatar entries from the disk cache. + /// Returns a HashMap of email → url for entries that were successfully resolved. + /// Silently returns an empty map if the file does not exist or is unreadable. + pub fn load_from_disk() -> HashMap { + let path = Self::disk_cache_path(); + let Ok(content) = fs::read_to_string(&path) else { + return HashMap::new(); + }; + Self::parse_entries(&content) + } + + /// Parse email=url entries from cache file content. + fn parse_entries(content: &str) -> HashMap { + let mut entries = HashMap::new(); + for line in content.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Some(eq) = line.find('=') { + let email = line[..eq].to_string(); + let url = line[eq + 1..].to_string(); + if !email.is_empty() && !url.is_empty() { + entries.insert(email, url); + } + } + } + entries + } + + fn merge_entries(content: &str, new_entries: &[(String, String)]) -> String { + let mut entries = Self::parse_entries(content); + for (email, url) in new_entries { + entries.insert(email.clone(), url.clone()); + } + let mut entries: Vec<_> = entries.into_iter().collect(); + entries.sort_unstable_by(|left, right| left.0.cmp(&right.0)); + entries + .into_iter() + .map(|(email, url)| format!("{email}={url}")) + .collect::>() + .join("\n") + } + + /// Save a resolved email→url mapping to the disk cache. + /// Reads the existing file, merges the new entry, writes to a temp file, + /// then atomically renames it over the target — safe against concurrent calls. + /// Silently does nothing if the file cannot be read or written. + pub fn save_entry_to_disk(email: &str, url: &str) { + Self::save_entries_to_disk(&[(email.to_owned(), url.to_owned())]); + } + + /// Persist a batch with one read/merge/atomic-write cycle. Writers are + /// serialized so overlapping resolution waves cannot lose entries. + pub fn save_entries_to_disk(new_entries: &[(String, String)]) { + if new_entries.is_empty() { + return; + } + let Ok(_write_guard) = DISK_CACHE_WRITE_LOCK.lock() else { + return; + }; + let path = Self::disk_cache_path(); + // Create parent dir if needed + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + // Read existing entries (may be empty or absent) + let existing_content = fs::read_to_string(&path).unwrap_or_default(); + let content = Self::merge_entries(&existing_content, new_entries); + + // Write to a unique temp file in the same directory, then rename + // atomically. Each call gets its own temp file to avoid races when + // multiple avatar fetches complete concurrently. + let tmp_path = path.with_extension(format!( + "tmp.{}.{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + if let Err(e) = fs::write(&tmp_path, &content) { + eprintln!("rgitui: failed to write avatar disk cache temp file: {e}"); + return; + } + match fs::rename(&tmp_path, &path) { + Ok(()) => {} + Err(e) => { + // rename failed (e.g. cross-filesystem on some platforms). + // Fall back to direct write — accept potential partial corruption + // only in this edge case. + eprintln!( + "rgitui: avatar disk cache rename failed ({e}), falling back to direct write" + ); + if let Err(e2) = fs::write(&path, &content) { + eprintln!("rgitui: failed to write avatar disk cache: {e2}"); + } + // Clean up the temp file if rename partially succeeded + let _ = fs::remove_file(&tmp_path); + } + } + } + + pub fn new() -> Self { + Self { + cache: HashMap::new(), + pending: HashSet::new(), + order: VecDeque::new(), + } + } + + /// Get the cached avatar state for an email, if resolved. + pub fn get(&self, email: &str) -> Option<&AvatarState> { + self.cache.get(email) + } + + /// Check if a fetch is already in flight for this email. + pub fn is_pending(&self, email: &str) -> bool { + self.pending.contains(email) + } + + /// Returns true if we should start a fetch (not cached, not pending, or retry limit not hit). + /// Allows up to 5 total attempts (1 initial + 4 retries) before giving up on an email. + pub fn needs_fetch(&self, email: &str) -> bool { + if self.pending.contains(email) { + return false; + } + match self.cache.get(email) { + None => true, + Some(AvatarState::NotFound(retries)) => *retries < 5, + Some(AvatarState::Resolved(_)) => false, + } + } + + /// Mark an email as having an in-flight fetch. + pub fn mark_pending(&mut self, email: &str) { + self.pending.insert(email.to_string()); + } + + /// Store a resolved avatar URL. + pub fn set_resolved(&mut self, email: String, url: String) { + log::debug!( + "AvatarCache::set_resolved: email={} cache_size={}", + email, + self.cache.len() + ); + self.pending.remove(&email); + if self.cache.len() >= Self::MAX_ENTRIES { + log::debug!( + "AvatarCache: evicting NotFound entries at capacity {}", + Self::MAX_ENTRIES + ); + // Evict NotFound entries to make room for resolved ones. + self.cache + .retain(|_, v| matches!(v, AvatarState::Resolved(_))); + self.order.retain(|k| self.cache.contains_key(k)); + + while self.cache.len() >= Self::MAX_ENTRIES { + if let Some(oldest) = self.order.pop_front() { + self.cache.remove(&oldest); + } else { + break; + } + } + } + if let Some(pos) = self.order.iter().position(|k| k == &email) { + self.order.remove(pos); + } + self.order.push_back(email.clone()); + self.cache.insert(email, AvatarState::Resolved(url)); + } + + /// Mark an email as having no avatar, incrementing the retry count. + pub fn set_not_found(&mut self, email: String) { + log::debug!("AvatarCache::set_not_found: email={}", email); + self.pending.remove(&email); + // Don't waste capacity on NotFound entries when the cache is full. + if self.cache.len() >= Self::MAX_ENTRIES && !self.cache.contains_key(&email) { + log::debug!( + "AvatarCache::set_not_found: skipped (at capacity), email={}", + email + ); + return; + } + let retries = match self.cache.get(&email) { + Some(AvatarState::NotFound(n)) => n + 1, + _ => 1, + }; + if let Some(pos) = self.order.iter().position(|k| k == &email) { + self.order.remove(pos); + } + self.order.push_back(email.clone()); + self.cache.insert(email, AvatarState::NotFound(retries)); + } + + /// Get the avatar URL for an email if resolved, None otherwise. + pub fn avatar_url(&self, email: &str) -> Option<&str> { + match self.cache.get(email)? { + AvatarState::Resolved(url) => Some(url.as_str()), + AvatarState::NotFound(_) => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Test helper: write content to a unique temp file and return its path. + fn temp_file(content: &str) -> std::path::PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let id = COUNTER.fetch_add(1, Ordering::Relaxed); + let tmp = + std::env::temp_dir().join(format!("rgitui_avatar_test_{}_{}", std::process::id(), id,)); + std::fs::write(&tmp, content).unwrap(); + tmp + } + + /// Parse entries from a given path (used to test the parser in isolation + /// without hitting the real disk cache path). + fn parse_from_path(path: &std::path::Path) -> HashMap { + let content = std::fs::read_to_string(path).expect("temp test file should exist"); + AvatarCache::parse_entries(&content) + } + + #[test] + fn test_parse_entries_single() { + let path = temp_file("alice@example.com=https://avatars.githubusercontent.com/u/1\n"); + let entries = parse_from_path(&path); + std::fs::remove_file(&path).ok(); + assert_eq!(entries.len(), 1); + assert_eq!( + entries.get("alice@example.com"), + Some(&"https://avatars.githubusercontent.com/u/1".to_string()) + ); + } + + #[test] + fn test_parse_entries_multiple() { + let path = temp_file( + "alice@example.com=https://a.com\n\ + bob@example.com=https://b.com\n", + ); + let entries = parse_from_path(&path); + std::fs::remove_file(&path).ok(); + assert_eq!(entries.len(), 2); + assert_eq!( + entries.get("alice@example.com"), + Some(&"https://a.com".to_string()) + ); + assert_eq!( + entries.get("bob@example.com"), + Some(&"https://b.com".to_string()) + ); + } + + #[test] + fn test_parse_entries_empty_file() { + let path = temp_file(""); + let entries = parse_from_path(&path); + std::fs::remove_file(&path).ok(); + assert!(entries.is_empty()); + } + + #[test] + fn test_parse_entries_skips_empty_lines() { + let path = + temp_file("alice@example.com=https://a.com\n\n \nbob@example.com=https://b.com\n"); + let entries = parse_from_path(&path); + std::fs::remove_file(&path).ok(); + assert_eq!(entries.len(), 2); + } + + #[test] + fn test_parse_entries_skips_lines_without_equals() { + let path = temp_file("not-valid\nalice@example.com=https://a.com\nalso-invalid\n"); + let entries = parse_from_path(&path); + std::fs::remove_file(&path).ok(); + assert_eq!(entries.len(), 1); + assert!(entries.contains_key("alice@example.com")); + } + + #[test] + fn test_parse_entries_skips_empty_email_or_url() { + let path = + temp_file("=https://a.com\nalice@example.com=\nvalid@example.com=https://b.com\n"); + let entries = parse_from_path(&path); + std::fs::remove_file(&path).ok(); + assert_eq!(entries.len(), 1); + assert!(entries.contains_key("valid@example.com")); + } + + #[test] + fn test_merge_entries_batches_and_overwrites_deterministically() { + let merged = AvatarCache::merge_entries( + "bob@example.com=https://old.example/bob", + &[ + ("alice@example.com".into(), "https://example/alice".into()), + ("bob@example.com".into(), "https://example/bob".into()), + ], + ); + assert_eq!( + merged, + concat!( + "alice@example.com=https://example/alice\n", + "bob@example.com=https://example/bob" + ) + ); + } + + // --- AvatarCache in-memory behaviour tests --- + + #[test] + fn test_cache_set_and_get_resolved() { + let mut cache = AvatarCache::new(); + cache.set_resolved("alice@example.com".to_string(), "https://a.com".to_string()); + assert_eq!(cache.avatar_url("alice@example.com"), Some("https://a.com")); + assert_eq!(cache.avatar_url("unknown@example.com"), None); + } + + #[test] + fn test_needs_fetch_false_for_resolved() { + let mut cache = AvatarCache::new(); + cache.set_resolved("alice@example.com".to_string(), "https://a.com".to_string()); + assert!(!cache.needs_fetch("alice@example.com")); + } + + #[test] + fn test_not_found_retry_limit() { + let mut cache = AvatarCache::new(); + // 1st-4th failure → still retry (initial + up to 4 retries = 5 total attempts) + for i in 1..=4 { + cache.set_not_found("alice@example.com".to_string()); + assert!( + cache.needs_fetch("alice@example.com"), + "should retry on failure #{i}" + ); + } + // 5th failure → give up + cache.set_not_found("alice@example.com".to_string()); + assert!(!cache.needs_fetch("alice@example.com")); + } + + #[test] + fn test_pending_blocks_fetch() { + let mut cache = AvatarCache::new(); + cache.mark_pending("alice@example.com"); + assert!(!cache.needs_fetch("alice@example.com")); + assert!(cache.is_pending("alice@example.com")); + } + + #[test] + fn test_resolve_clears_pending() { + let mut cache = AvatarCache::new(); + cache.mark_pending("alice@example.com"); + assert!(cache.is_pending("alice@example.com")); + cache.set_resolved("alice@example.com".to_string(), "https://a.com".to_string()); + assert!(!cache.is_pending("alice@example.com")); + assert_eq!(cache.avatar_url("alice@example.com"), Some("https://a.com")); + } + + #[test] + fn test_resolve_overwrites_not_found() { + let mut cache = AvatarCache::new(); + cache.set_not_found("alice@example.com".to_string()); + cache.set_resolved("alice@example.com".to_string(), "https://a.com".to_string()); + assert!(!cache.needs_fetch("alice@example.com")); + assert_eq!(cache.avatar_url("alice@example.com"), Some("https://a.com")); + } + + #[test] + fn test_lru_eviction() { + let mut cache = AvatarCache::new(); + // Force it to fill up artificially if we could, but MAX_ENTRIES is 2000. + // We can just verify the order works for standard insertions. + cache.set_resolved("1@example.com".to_string(), "url1".to_string()); + cache.set_resolved("2@example.com".to_string(), "url2".to_string()); + assert_eq!(cache.order.len(), 2); + assert_eq!(cache.order[0], "1@example.com"); + assert_eq!(cache.order[1], "2@example.com"); + + // Update 1@example.com should move it to the back + cache.set_resolved("1@example.com".to_string(), "url1_new".to_string()); + assert_eq!(cache.order.len(), 2); + assert_eq!(cache.order[0], "2@example.com"); + assert_eq!(cache.order[1], "1@example.com"); + } +} + +/// The avatar cache's share of the heap census. +/// +/// Process-wide rather than per-tab, and reachable from no other crate, so +/// without this walk it is memory the report simply does not see. It is bounded +/// at [`AvatarCache::MAX_ENTRIES`], but each entry is an email and a resolved +/// URL held twice — once in the map, once in the recency queue — which is what +/// the split below makes visible. +#[cfg(feature = "perf")] +mod census { + use rgitui_perf::{Census, HeapSize}; + + use super::{AvatarCache, AvatarState}; + + impl HeapSize for AvatarState { + fn heap_size(&self, census: &mut Census) -> usize { + match self { + AvatarState::Resolved(url) => url.heap_size(census), + AvatarState::NotFound(_) => 0, + } + } + } + + impl AvatarCache { + /// Records the resolved entries, the in-flight set and the recency + /// queue's second copy of every key. + pub fn census(&self, census: &mut Census) -> usize { + census.enter("entries", &self.cache) + + census.enter("pending", &self.pending) + + census.enter("recency", &self.order) + } + } +} diff --git a/third_party/rgitui/crates/rgitui_ui/src/badge.rs b/third_party/rgitui/crates/rgitui_ui/src/badge.rs new file mode 100644 index 000000000..71315b15b --- /dev/null +++ b/third_party/rgitui/crates/rgitui_ui/src/badge.rs @@ -0,0 +1,110 @@ +use gpui::prelude::*; +use gpui::{div, px, App, Hsla, SharedString, Window}; +use rgitui_theme::{Color, StyledExt}; + +use crate::{Label, LabelSize}; + +/// A small badge/chip for displaying tags, status labels, branch names, etc. +#[derive(IntoElement)] +pub struct Badge { + text: SharedString, + color: Color, + italic: bool, + bold: bool, + prefix: Option, + compact: bool, +} + +impl Badge { + pub fn new(text: impl Into) -> Self { + Self { + text: text.into(), + color: Color::Accent, + italic: false, + bold: false, + prefix: None, + compact: false, + } + } + + pub fn color(mut self, color: Color) -> Self { + self.color = color; + self + } + + pub fn italic(mut self) -> Self { + self.italic = true; + self + } + + pub fn bold(mut self) -> Self { + self.bold = true; + self + } + + pub fn prefix(mut self, prefix: impl Into) -> Self { + self.prefix = Some(prefix.into()); + self + } + + /// Reduce chip height and horizontal padding for dense ref-label rows. + pub fn compact(mut self) -> Self { + self.compact = true; + self + } +} + +impl RenderOnce for Badge { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let text_color = self.color.color(_cx); + let bg = Hsla { + a: 0.15, + ..text_color + }; + let border = Hsla { + a: 0.3, + ..text_color + }; + + let weight = if self.bold { + gpui::FontWeight::BOLD + } else { + gpui::FontWeight::SEMIBOLD + }; + + let mut label = Label::new(self.text) + .size(LabelSize::XSmall) + .weight(weight) + .color(self.color) + .truncate(); + + if self.italic { + label = label.italic(); + } + + let container = div() + .h_flex() + .gap(px(2.)) + .px(if self.compact { px(5.) } else { px(6.) }) + .py(px(1.)) + .h(if self.compact { px(18.) } else { px(20.) }) + .items_center() + .rounded(if self.compact { px(9.) } else { px(10.) }) + .bg(bg) + .border_1() + .border_color(border) + .overflow_x_hidden(); + + let container = if let Some(prefix_text) = self.prefix { + container.child( + Label::new(prefix_text) + .size(LabelSize::XSmall) + .color(self.color), + ) + } else { + container + }; + + container.child(label) + } +} diff --git a/third_party/rgitui/crates/rgitui_ui/src/button.rs b/third_party/rgitui/crates/rgitui_ui/src/button.rs new file mode 100644 index 000000000..20d204805 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_ui/src/button.rs @@ -0,0 +1,589 @@ +use gpui::prelude::*; +use gpui::{ + div, px, AnyElement, AnyView, App, ClickEvent, CursorStyle, ElementId, Hsla, MouseButton, + SharedString, StyleRefinement, Window, +}; +use rgitui_theme::{ActiveTheme, Color, StyledExt}; + +use crate::{Icon, IconName, IconSize, Label, LabelSize, Tooltip}; + +/// A tooltip factory closure matching GPUI's native `.tooltip()` signature. +type TooltipBuilder = Box AnyView + 'static>; + +/// Button visual styles. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ButtonStyle { + /// Solid background color. + Filled, + /// Default: transparent, shows bg on hover. + #[default] + Subtle, + /// Border + semi-transparent background. + Outlined, + /// Semantic tinted button. + Tinted(TintColor), + /// Fully transparent, only text color changes. + Transparent, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TintColor { + Accent, + Error, + Warning, + Success, +} + +/// Button sizes controlling height. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ButtonSize { + Large, + #[default] + Default, + Compact, + None, +} + +impl ButtonSize { + pub fn height(&self) -> gpui::Rems { + match self { + ButtonSize::Large => gpui::rems(2.0), // 32px + ButtonSize::Default => gpui::rems(1.75), // 28px + ButtonSize::Compact => gpui::rems(1.375), // 22px + ButtonSize::None => gpui::rems(1.0), // 16px + } + } + + fn icon_size(&self) -> IconSize { + match self { + ButtonSize::Large => IconSize::Medium, + ButtonSize::Default => IconSize::Small, + ButtonSize::Compact => IconSize::XSmall, + ButtonSize::None => IconSize::XSmall, + } + } + + fn label_size(&self) -> LabelSize { + match self { + ButtonSize::Large => LabelSize::Default, + ButtonSize::Default => LabelSize::Small, + ButtonSize::Compact => LabelSize::XSmall, + ButtonSize::None => LabelSize::XSmall, + } + } +} + +/// Resolved background, hover, active, and border colors for a button style. +struct ResolvedColors { + background: Option, + hover: Option, + active: Option, + border: Option, +} + +impl ButtonStyle { + fn colors(self, cx: &App) -> ResolvedColors { + let colors = cx.colors(); + match self { + ButtonStyle::Filled => ResolvedColors { + background: Some(colors.element_background), + hover: Some(colors.element_hover), + active: Some(colors.element_active), + border: None, + }, + ButtonStyle::Subtle => ResolvedColors { + background: None, + hover: Some(colors.ghost_element_hover), + active: Some(colors.ghost_element_active), + border: None, + }, + ButtonStyle::Outlined => ResolvedColors { + background: Some(colors.element_background), + hover: Some(colors.element_hover), + active: Some(colors.element_active), + border: Some(colors.border), + }, + ButtonStyle::Tinted(tint) => { + let status = cx.status(); + let bg_color = match tint { + TintColor::Accent => status.info_background, + TintColor::Error => status.error_background, + TintColor::Warning => status.warning_background, + TintColor::Success => status.success_background, + }; + ResolvedColors { + background: Some(bg_color), + hover: Some(Hsla { + a: (bg_color.a + 0.1).min(1.0), + ..bg_color + }), + active: Some(Hsla { + a: (bg_color.a + 0.2).min(1.0), + ..bg_color + }), + border: None, + } + } + ButtonStyle::Transparent => ResolvedColors { + background: None, + hover: Some(colors.ghost_element_hover), + active: Some(colors.ghost_element_active), + border: None, + }, + } + } + + /// Whether this style paints a solid (non-ghost) surface. Solid styles swap to + /// dedicated disabled colors; ghost styles dim with reduced opacity instead. + fn is_solid(self) -> bool { + matches!( + self, + ButtonStyle::Filled | ButtonStyle::Outlined | ButtonStyle::Tinted(_) + ) + } +} + +/// Shared base for [`Button`] and [`IconButton`]. +/// +/// Owns the single style, state, cursor, focus, and click-handling path so both +/// public components resolve appearance and behavior identically. Callers build a +/// `ButtonLike`, push their content as children, and render it. +#[derive(IntoElement)] +struct ButtonLike { + id: ElementId, + style: ButtonStyle, + size: ButtonSize, + disabled: bool, + full_width: bool, + fixed_size: bool, + tab_index: isize, + tooltip: Option, + on_click: Option, + children: Vec, +} + +impl ButtonLike { + fn new(id: impl Into) -> Self { + Self { + id: id.into(), + style: ButtonStyle::default(), + size: ButtonSize::default(), + disabled: false, + full_width: false, + fixed_size: false, + tab_index: 0, + tooltip: None, + on_click: None, + children: Vec::new(), + } + } + + fn style(mut self, style: ButtonStyle) -> Self { + self.style = style; + self + } + + fn size(mut self, size: ButtonSize) -> Self { + self.size = size; + self + } + + fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + fn full_width(mut self, full_width: bool) -> Self { + self.full_width = full_width; + self + } + + /// Render as a square button sized to [`ButtonSize::height`] on both axes. + fn fixed_size(mut self, fixed_size: bool) -> Self { + self.fixed_size = fixed_size; + self + } + + fn tab_index(mut self, tab_index: isize) -> Self { + self.tab_index = tab_index; + self + } + + fn tooltip(mut self, tooltip: TooltipBuilder) -> Self { + self.tooltip = Some(tooltip); + self + } + + fn on_click(mut self, on_click: Option) -> Self { + self.on_click = on_click; + self + } +} + +impl ParentElement for ButtonLike { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for ButtonLike { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let colors = cx.colors(); + let resolved = self.style.colors(cx); + + let height = self.size.height(); + let padding = match self.size { + ButtonSize::Large => px(12.), + ButtonSize::Default => px(8.), + ButtonSize::Compact => px(6.), + ButtonSize::None => px(4.), + }; + + let mut container = div().id(self.id).h_flex().h(height).rounded_md(); + + // Disabled controls are conventionally removed from the keyboard tab order. + if !self.disabled { + container = container.tab_index(self.tab_index); + } + + if self.fixed_size { + container = container.w(height).justify_center(); + } else { + container = container.px(padding).gap_1(); + } + + if self.full_width { + container = container.w_full().justify_center(); + } + + if self.disabled { + // Disabled affordance: dim or swap to dedicated disabled surface colors, + // and show the not-allowed cursor instead of the pointing hand. + if self.style.is_solid() { + container = container + .bg(colors.element_disabled) + .text_color(colors.text_disabled); + if resolved.border.is_some() { + container = container.border_1().border_color(colors.border_disabled); + } + } else { + container = container.text_color(colors.text_disabled).opacity(0.5); + } + container = container.cursor_not_allowed(); + } else { + if let Some(background) = resolved.background { + container = container.bg(background); + } + if let Some(border) = resolved.border { + container = container.border_1().border_color(border); + } + + container = container.cursor(CursorStyle::PointingHand); + + // Keyboard-focus ring: recolor the border when the style draws one, + // otherwise fall back to the hover background so the ring is visible. + if resolved.border.is_some() { + let focus_color = colors.border_focused; + container = + container.focus_visible(move |s: StyleRefinement| s.border_color(focus_color)); + } else if let Some(hover) = resolved.hover { + container = container.focus_visible(move |s: StyleRefinement| s.bg(hover)); + } + + container = match (resolved.hover, resolved.active) { + (Some(hover), Some(active)) => container + .hover(move |s| s.bg(hover)) + .active(move |s| s.bg(active)), + (Some(hover), None) => container.hover(move |s| s.bg(hover)), + (None, Some(active)) => container.active(move |s| s.bg(active)), + (None, None) => container, + }; + + if let Some(on_click) = self.on_click { + container = container + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(on_click); + } + } + + container = container.children(self.children); + + if let Some(tooltip) = self.tooltip { + container = container.tooltip(move |window, cx| tooltip(window, cx)); + } + + container + } +} + +/// A clickable button with optional icon and label. +#[derive(IntoElement)] +pub struct Button { + id: ElementId, + label: SharedString, + label_color: Option, + icon: Option, + icon_position: IconPosition, + style: ButtonStyle, + size: ButtonSize, + disabled: bool, + selected: bool, + full_width: bool, + tab_index: isize, + tooltip: Option, + on_click: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum IconPosition { + #[default] + Start, + End, +} + +impl Button { + pub fn new(id: impl Into, label: impl Into) -> Self { + Self { + id: id.into(), + label: label.into(), + label_color: None, + icon: None, + icon_position: IconPosition::Start, + style: ButtonStyle::default(), + size: ButtonSize::default(), + disabled: false, + selected: false, + full_width: false, + tab_index: 0, + tooltip: None, + on_click: None, + } + } + + /// Set a plain-text tooltip. + pub fn tooltip(mut self, tooltip: impl Into) -> Self { + self.tooltip = Some(Box::new(Tooltip::text(tooltip.into()))); + self + } + + /// Set a tooltip from a factory closure matching GPUI's native `.tooltip()` + /// signature, allowing richer tooltips (for example with a shortcut hint). + pub fn tooltip_fn( + mut self, + tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static, + ) -> Self { + self.tooltip = Some(Box::new(tooltip)); + self + } + + pub fn style(mut self, style: ButtonStyle) -> Self { + self.style = style; + self + } + + pub fn size(mut self, size: ButtonSize) -> Self { + self.size = size; + self + } + + pub fn icon(mut self, icon: IconName) -> Self { + self.icon = Some(icon); + self + } + + pub fn icon_position(mut self, pos: IconPosition) -> Self { + self.icon_position = pos; + self + } + + pub fn color(mut self, color: Color) -> Self { + self.label_color = Some(color); + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + pub fn full_width(mut self) -> Self { + self.full_width = true; + self + } + + /// Set the keyboard tab-navigation index. Defaults to `0`, which makes the + /// button focusable and reachable via Tab. + pub fn tab_index(mut self, tab_index: isize) -> Self { + self.tab_index = tab_index; + 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 + } +} + +impl RenderOnce for Button { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let label_color = if self.disabled { + Color::Disabled + } else if self.selected { + Color::Accent + } else { + self.label_color.unwrap_or(Color::Default) + }; + + let icon_el = self.icon.map(|name| { + Icon::new(name) + .size(self.size.icon_size()) + .color(label_color) + }); + let label_el = Label::new(self.label) + .size(self.size.label_size()) + .color(label_color); + + let mut base = ButtonLike::new(self.id) + .style(self.style) + .size(self.size) + .disabled(self.disabled) + .full_width(self.full_width) + .tab_index(self.tab_index) + .on_click(self.on_click); + + if let Some(tooltip) = self.tooltip { + base = base.tooltip(tooltip); + } + + match self.icon_position { + IconPosition::Start => base.children(icon_el).child(label_el), + IconPosition::End => base.child(label_el).children(icon_el), + } + } +} + +/// An icon-only button. +#[derive(IntoElement)] +pub struct IconButton { + id: ElementId, + icon: IconName, + color: Color, + style: ButtonStyle, + size: ButtonSize, + disabled: bool, + selected: bool, + tab_index: isize, + tooltip: Option, + on_click: Option, +} + +impl IconButton { + pub fn new(id: impl Into, icon: IconName) -> Self { + Self { + id: id.into(), + icon, + color: Color::Default, + style: ButtonStyle::Subtle, + size: ButtonSize::Default, + disabled: false, + selected: false, + tab_index: 0, + tooltip: None, + on_click: None, + } + } + + /// Set a plain-text tooltip. + pub fn tooltip(mut self, tooltip: impl Into) -> Self { + self.tooltip = Some(Box::new(Tooltip::text(tooltip.into()))); + self + } + + /// Set a tooltip from a factory closure matching GPUI's native `.tooltip()` + /// signature, allowing richer tooltips (for example with a shortcut hint). + pub fn tooltip_fn( + mut self, + tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static, + ) -> Self { + self.tooltip = Some(Box::new(tooltip)); + self + } + + pub fn color(mut self, color: Color) -> Self { + self.color = color; + self + } + + pub fn style(mut self, style: ButtonStyle) -> Self { + self.style = style; + self + } + + pub fn size(mut self, size: ButtonSize) -> Self { + self.size = size; + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + /// Set the keyboard tab-navigation index. Defaults to `0`, which makes the + /// button focusable and reachable via Tab. + pub fn tab_index(mut self, tab_index: isize) -> Self { + self.tab_index = tab_index; + 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 + } +} + +impl RenderOnce for IconButton { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let icon_color = if self.disabled { + Color::Disabled + } else if self.selected { + Color::Accent + } else { + self.color + }; + + let icon_el = Icon::new(self.icon) + .size(self.size.icon_size()) + .color(icon_color); + + let mut base = ButtonLike::new(self.id) + .style(self.style) + .size(self.size) + .disabled(self.disabled) + .fixed_size(true) + .tab_index(self.tab_index) + .on_click(self.on_click); + + if let Some(tooltip) = self.tooltip { + base = base.tooltip(tooltip); + } + + base.child(icon_el) + } +} diff --git a/third_party/rgitui/crates/rgitui_ui/src/checkbox.rs b/third_party/rgitui/crates/rgitui_ui/src/checkbox.rs new file mode 100644 index 000000000..6f1fa3453 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_ui/src/checkbox.rs @@ -0,0 +1,185 @@ +use std::rc::Rc; + +use gpui::prelude::*; +use gpui::{ + div, px, App, ClickEvent, CursorStyle, ElementId, FocusHandle, KeyDownEvent, SharedString, + Window, +}; +use rgitui_theme::ActiveTheme; + +use crate::{Icon, IconName, IconSize, Label, LabelSize}; + +/// A checkbox with checked/unchecked/indeterminate states. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CheckState { + Unchecked, + Checked, + Indeterminate, +} + +/// Event-agnostic toggle callback shared between pointer and keyboard activation. +type ToggleHandler = Rc; + +#[derive(IntoElement)] +pub struct Checkbox { + id: ElementId, + state: CheckState, + disabled: bool, + label: Option, + focus_handle: Option, + on_click: Option, + on_toggle: Option, +} + +impl Checkbox { + pub fn new(id: impl Into, state: CheckState) -> Self { + Self { + id: id.into(), + state, + disabled: false, + label: None, + focus_handle: None, + on_click: None, + on_toggle: None, + } + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + /// Render an accessible label beside the box. The full row becomes the + /// clickable hit target with padding to exceed the minimum touch size. + pub fn label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + + /// Provide a focus handle so the checkbox can receive keyboard focus and + /// toggle on Space/Enter. The handle is owned by the parent view; supply it + /// via `cx.focus_handle()` and pair it with [`Checkbox::on_toggle`]. + pub fn focus_handle(mut self, handle: FocusHandle) -> Self { + self.focus_handle = Some(handle); + 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 + } + + /// Set an event-agnostic toggle handler invoked by both pointer clicks on + /// the labeled row and keyboard activation (Space/Enter) while focused. + pub fn on_toggle(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_toggle = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for Checkbox { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let colors = cx.colors(); + + let (bg, border, icon) = match self.state { + CheckState::Checked => ( + colors.text_accent, + colors.text_accent, + Some(IconName::Check), + ), + CheckState::Indeterminate => ( + colors.text_accent, + colors.text_accent, + Some(IconName::Minus), + ), + CheckState::Unchecked => (colors.element_background, colors.border, None), + }; + + let hover_bg = colors.ghost_element_hover; + let active_bg = colors.ghost_element_active; + + let mut box_el = div() + .flex() + .items_center() + .justify_center() + .w(px(16.)) + .h(px(16.)) + .rounded(px(3.)) + .bg(bg) + .border_1() + .border_color(border); + + if let Some(icon_name) = icon { + box_el = box_el.child(Icon::new(icon_name).size(IconSize::XSmall).color( + rgitui_theme::Color::Custom(colors.elevated_surface_background), + )); + } + + // Labeled variant: the whole row is the hit target, padded to exceed the + // ~24px accessible minimum, and keyboard-toggleable when focused. + if let Some(label) = self.label { + let mut row = div() + .id(self.id) + .flex() + .items_center() + .gap_2() + .px(px(6.)) + .py(px(4.)) + .rounded(px(4.)) + .child(box_el) + .child(Label::new(label).size(LabelSize::Small)); + + if self.disabled { + row = row.opacity(0.5); + } else { + row = row + .cursor(CursorStyle::PointingHand) + .hover(move |s| s.bg(hover_bg)) + .active(move |s| s.bg(active_bg)); + + let on_toggle = self.on_toggle; + + if let Some(handle) = self.focus_handle { + row = row.track_focus(&handle); + if let Some(toggle) = on_toggle.clone() { + row = row.on_key_down(move |event: &KeyDownEvent, window, cx| { + if matches!(event.keystroke.key.as_str(), "space" | "enter") { + toggle(window, cx); + } + }); + } + } + + if let Some(toggle) = on_toggle { + row = row.on_click(move |_event, window, cx| toggle(window, cx)); + } else if let Some(on_click) = self.on_click { + row = row.on_click(on_click); + } + } + + return row.into_any_element(); + } + + // Bare variant: kept visually identical (16x16, no padding) so existing + // inline usages retain their layout. + let mut container = box_el.id(self.id); + + if self.disabled { + container = container.opacity(0.5); + } else { + container = container + .cursor(CursorStyle::PointingHand) + .hover(move |s| s.bg(hover_bg)) + .active(move |s| s.bg(active_bg)); + + if let Some(on_click) = self.on_click { + container = container.on_click(on_click); + } + } + + container.into_any_element() + } +} diff --git a/third_party/rgitui/crates/rgitui_ui/src/context_menu.rs b/third_party/rgitui/crates/rgitui_ui/src/context_menu.rs new file mode 100644 index 000000000..75d1c8cd9 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_ui/src/context_menu.rs @@ -0,0 +1,182 @@ +use gpui::prelude::*; +use gpui::{div, px, Animation, AnimationExt, App, ClickEvent, SharedString, Window}; +use rgitui_theme::{ActiveTheme, StyledExt}; +use std::time::Duration; + +use crate::{Label, LabelSize}; + +pub struct ContextMenuItem { + label: SharedString, + disabled: bool, + is_separator: bool, + shortcut: Option, + on_click: Option, +} + +impl ContextMenuItem { + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + disabled: false, + is_separator: false, + shortcut: None, + on_click: None, + } + } + + pub fn separator() -> Self { + Self { + label: "".into(), + disabled: false, + is_separator: true, + shortcut: None, + on_click: None, + } + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn shortcut(mut self, shortcut: impl Into) -> Self { + self.shortcut = Some(shortcut.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 + } +} + +// TODO(audit): UX-13 — add keyboard navigation + focus handling (FocusHandle, +// Up/Down to move a highlighted index, Enter to activate, Esc to dismiss, +// type-ahead, focus transfer on open). Deferred: large framework migration to a +// stateful component; ref Zed crates/ui/src/components/context_menu.rs. +#[derive(IntoElement)] +pub struct ContextMenu { + items: Vec, + min_width: f32, +} + +impl Default for ContextMenu { + fn default() -> Self { + Self::new() + } +} + +impl ContextMenu { + pub fn new() -> Self { + Self { + items: Vec::new(), + min_width: 180.0, + } + } + + pub fn item(mut self, item: ContextMenuItem) -> Self { + self.items.push(item); + self + } + + pub fn separator(mut self) -> Self { + self.items.push(ContextMenuItem::separator()); + self + } + + pub fn min_width(mut self, width: f32) -> Self { + self.min_width = width; + self + } +} + +fn ease_out_quint(t: f32) -> f32 { + 1.0 - (1.0 - t).powi(5) +} + +impl RenderOnce for ContextMenu { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let colors = cx.colors(); + + let mut menu = div() + .v_flex() + .min_w(px(self.min_width)) + .py(px(4.)) + .bg(colors.elevated_surface_background) + .border_1() + .border_color(colors.border) + .rounded_md() + .elevation_3(cx); + + for (i, item) in self.items.into_iter().enumerate() { + if item.is_separator { + menu = menu.child( + div() + .w_full() + .h(px(1.)) + .my(px(4.)) + .bg(colors.border_variant), + ); + continue; + } + + let label = item.label.clone(); + let disabled = item.disabled; + + let hover_bg = colors.ghost_element_hover; + let active_bg = colors.ghost_element_active; + let accent = colors.text_accent; + + let mut row = div() + .id(gpui::ElementId::NamedInteger("ctx-item".into(), i as u64)) + .h_flex() + .w_full() + .h(px(28.)) + .mx(px(4.)) + .px(px(8.)) + .gap_2() + .items_center() + .rounded(px(4.)); + + if !disabled { + row = row + .hover(move |s| s.bg(hover_bg).border_l_2().border_color(accent)) + .active(move |s| s.bg(active_bg)) + .cursor_pointer(); + } + + if let Some(on_click) = item.on_click { + if !disabled { + row = row.on_click(on_click); + } + } + + let text_color = if disabled { + crate::Color::Disabled + } else { + crate::Color::Default + }; + + row = row.child(Label::new(label).size(LabelSize::Small).color(text_color)); + + if let Some(shortcut) = item.shortcut { + row = row.child(div().flex_1()).child( + Label::new(shortcut) + .size(LabelSize::XSmall) + .color(crate::Color::Muted), + ); + } + + menu = menu.child(row); + } + + menu.with_animation( + "context-menu-entrance", + Animation::new(Duration::from_millis(100)).with_easing(ease_out_quint), + |el, delta| el.opacity(delta), + ) + } +} diff --git a/third_party/rgitui/crates/rgitui_ui/src/diff_stat.rs b/third_party/rgitui/crates/rgitui_ui/src/diff_stat.rs new file mode 100644 index 000000000..e7aa0603f --- /dev/null +++ b/third_party/rgitui/crates/rgitui_ui/src/diff_stat.rs @@ -0,0 +1,77 @@ +use gpui::prelude::*; +use gpui::{div, px, App, SharedString, Window}; +use rgitui_theme::{ActiveTheme, Color, StyledExt}; + +use crate::{Label, LabelSize}; + +const TOTAL_BLOCKS: usize = 5; + +/// A compact diff stat display showing additions/deletions with a colored bar. +/// +/// Renders as: `+3 -2 [=====]` where the bar uses green for additions and red +/// for deletions, proportional to their share of total changes. +#[derive(IntoElement)] +pub struct DiffStat { + added: usize, + removed: usize, +} + +impl DiffStat { + pub fn new(added: usize, removed: usize) -> Self { + Self { added, removed } + } +} + +impl RenderOnce for DiffStat { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let added_color = Color::Added.color(cx); + let deleted_color = Color::Deleted.color(cx); + let neutral_color = cx.colors().text_disabled; + + let total = self.added + self.removed; + + let (green_blocks, red_blocks) = if total == 0 { + (0, 0) + } else { + let green = (self.added * TOTAL_BLOCKS).div_ceil(total); + let green = green.min(TOTAL_BLOCKS); + let red = TOTAL_BLOCKS - green; + (green, red) + }; + + let additions_str: SharedString = format!("+{}", self.added).into(); + let deletions_str: SharedString = format!("-{}", self.removed).into(); + + let mut bar = div().h_flex().gap(px(1.)); + + for i in 0..TOTAL_BLOCKS { + let color = if total == 0 { + neutral_color + } else if i < green_blocks { + added_color + } else if i < green_blocks + red_blocks { + deleted_color + } else { + neutral_color + }; + bar = bar.child(div().w(px(4.)).h(px(10.)).rounded(px(1.)).bg(color)); + } + + div() + .h_flex() + .gap(px(4.)) + .flex_shrink_0() + .items_center() + .child( + Label::new(additions_str) + .size(LabelSize::XSmall) + .color(Color::Added), + ) + .child( + Label::new(deletions_str) + .size(LabelSize::XSmall) + .color(Color::Deleted), + ) + .child(bar) + } +} diff --git a/third_party/rgitui/crates/rgitui_ui/src/disclosure.rs b/third_party/rgitui/crates/rgitui_ui/src/disclosure.rs new file mode 100644 index 000000000..76a77c51f --- /dev/null +++ b/third_party/rgitui/crates/rgitui_ui/src/disclosure.rs @@ -0,0 +1,97 @@ +use gpui::prelude::*; +use gpui::{div, px, App, ElementId, MouseButton, SharedString, StyleRefinement, Window}; +use rgitui_theme::{ActiveTheme, Color, StyledExt}; + +use crate::{ClickHandler, Icon, IconName, IconSize, Label, LabelSize}; + +/// A reusable disclosure (collapsible section header) component. +/// +/// Shows a chevron icon indicating expanded/collapsed state, a label, +/// and triggers a callback when clicked. The parent manages the open/closed state. +/// +/// The header is a tab stop: it can be focused with the keyboard and toggled +/// with Enter or Space, and shows a focus ring when reached via the keyboard. +#[derive(IntoElement)] +pub struct Disclosure { + id: ElementId, + is_open: bool, + label: SharedString, + tab_index: isize, + on_toggle: Option, +} + +impl Disclosure { + pub fn new(id: impl Into, label: impl Into, is_open: bool) -> Self { + Self { + id: id.into(), + is_open, + label: label.into(), + tab_index: 0, + on_toggle: None, + } + } + + /// Sets the keyboard tab order for the header. Defaults to `0`. + pub fn tab_index(mut self, tab_index: isize) -> Self { + self.tab_index = tab_index; + self + } + + pub fn on_toggle( + mut self, + handler: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_toggle = Some(Box::new(handler)); + self + } +} + +impl RenderOnce for Disclosure { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let colors = cx.colors(); + let hover_bg = colors.ghost_element_hover; + let active_bg = colors.ghost_element_active; + let focus_color = colors.border_focused; + + let chevron = if self.is_open { + IconName::ChevronDown + } else { + IconName::ChevronRight + }; + + let mut row = div() + .id(self.id) + .h_flex() + .gap(px(4.)) + .items_center() + .cursor_pointer() + .tab_index(self.tab_index) + .rounded_sm() + .border_1() + .border_color(gpui::transparent_black()) + .hover(move |s| s.bg(hover_bg)) + .active(move |s| s.bg(active_bg)) + .focus_visible(move |s: StyleRefinement| s.border_color(focus_color)) + .child( + Icon::new(chevron) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child( + Label::new(self.label) + .size(LabelSize::XSmall) + .weight(gpui::FontWeight::SEMIBOLD) + .color(Color::Muted), + ); + + if let Some(handler) = self.on_toggle { + // GPUI dispatches `on_click` for Enter/Space while the element is focused, + // so this also serves as the keyboard toggle. + row = row + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |event, window, cx| handler(event, window, cx)); + } + + row + } +} diff --git a/third_party/rgitui/crates/rgitui_ui/src/divider.rs b/third_party/rgitui/crates/rgitui_ui/src/divider.rs new file mode 100644 index 000000000..1e3188d41 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_ui/src/divider.rs @@ -0,0 +1,54 @@ +use gpui::prelude::*; +use gpui::{div, px, App, Window}; +use rgitui_theme::ActiveTheme; + +/// A horizontal divider line. +#[derive(IntoElement)] +pub struct Divider; + +impl Default for Divider { + fn default() -> Self { + Self::new() + } +} + +impl Divider { + pub fn new() -> Self { + Self + } +} + +impl RenderOnce for Divider { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let border_color = cx.colors().border_variant; + div().w_full().h(px(1.)).bg(border_color) + } +} + +/// A vertical divider line. +#[derive(IntoElement)] +pub struct VerticalDivider; + +impl Default for VerticalDivider { + fn default() -> Self { + Self::new() + } +} + +impl VerticalDivider { + pub fn new() -> Self { + Self + } +} + +impl RenderOnce for VerticalDivider { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let border_color = cx.colors().border_variant; + div() + .h(px(16.)) + .w(px(1.)) + .mx(px(4.)) + .bg(border_color) + .flex_shrink_0() + } +} diff --git a/third_party/rgitui/crates/rgitui_ui/src/fuzzy.rs b/third_party/rgitui/crates/rgitui_ui/src/fuzzy.rs new file mode 100644 index 000000000..b0dd3e45d --- /dev/null +++ b/third_party/rgitui/crates/rgitui_ui/src/fuzzy.rs @@ -0,0 +1,100 @@ +//! Fuzzy subsequence matching for the app's search boxes. +//! +//! The command palette and the file filter rank by it directly. The model +//! picker uses it as its last tier, below whole-word and substring matches, so +//! a scattered-letter hit can surface a similar name without outranking a row +//! that contains the query outright. + +/// Score `query` against `target`, or `None` when the query's characters do +/// not all appear in `target` in order. +/// +/// Higher is better. Matches nearer the start of the target score higher, so +/// typing a prefix surfaces the obvious candidate first. +pub fn fuzzy_score(query: &str, target: &str) -> Option { + if query.is_empty() { + return Some(0); + } + let target_len = target.len(); + let mut score: usize = 0; + let mut target_chars = target.char_indices(); + // Callers usually lowercase both sides already; doing it here as well keeps + // direct calls correct. + for query_char in query.to_lowercase().chars() { + loop { + match target_chars.next() { + Some((pos, target_char)) => { + if target_char.to_ascii_lowercase() == query_char { + score += target_len.saturating_sub(pos); + break; + } + } + None => return None, + } + } + } + Some(score) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_exact_match_scores() { + assert!(fuzzy_score("commit", "commit").is_some()); + } + + #[test] + fn matching_is_case_insensitive() { + assert!(fuzzy_score("COMMIT", "commit").is_some()); + assert!(fuzzy_score("commit", "COMMIT").is_some()); + } + + #[test] + fn every_query_character_must_appear_in_order() { + assert!(fuzzy_score("abc", "axbxc").is_some()); + assert!(fuzzy_score("cba", "axbxc").is_none()); + assert!(fuzzy_score("xyz", "commit").is_none()); + } + + #[test] + fn an_empty_query_matches_anything_with_no_preference() { + assert_eq!(fuzzy_score("", "anything"), Some(0)); + assert_eq!(fuzzy_score("", ""), Some(0)); + } + + #[test] + fn an_empty_target_matches_only_an_empty_query() { + assert_eq!(fuzzy_score("a", ""), None); + } + + #[test] + fn an_earlier_match_scores_higher() { + let early = fuzzy_score("g", "gemini").unwrap(); + let late = fuzzy_score("g", "openai/g").unwrap(); + assert!(early > late); + } + + #[test] + fn a_query_longer_than_its_target_cannot_match() { + assert_eq!(fuzzy_score("commit-message", "commit"), None); + } + + #[test] + fn repeated_characters_consume_distinct_positions() { + assert!(fuzzy_score("aa", "aa").is_some()); + assert!(fuzzy_score("aa", "a").is_none()); + } + + #[test] + fn digits_punctuation_and_unicode_all_match() { + assert!(fuzzy_score("gpt5", "gpt-5.6-luna").is_some()); + assert!(fuzzy_score("a/b", "a/b/c").is_some()); + assert!(fuzzy_score("é", "café").is_some()); + } + + #[test] + fn a_multibyte_target_does_not_panic_on_a_missing_character() { + assert_eq!(fuzzy_score("z", "日本語"), None); + } +} diff --git a/third_party/rgitui/crates/rgitui_ui/src/icon.rs b/third_party/rgitui/crates/rgitui_ui/src/icon.rs new file mode 100644 index 000000000..a9373daea --- /dev/null +++ b/third_party/rgitui/crates/rgitui_ui/src/icon.rs @@ -0,0 +1,211 @@ +use gpui::prelude::*; +use gpui::{rems, svg, App, Rems, Window}; +use rgitui_theme::Color; + +/// Icon names mapping to SVG assets loaded by path from the `assets/icons/` directory. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IconName { + // Git + GitBranch, + GitCommit, + GitMerge, + GitPullRequest, + GitCompare, + + // File states + FileAdded, + FileModified, + FileDeleted, + FileRenamed, + FileConflict, + + // Navigation + ChevronRight, + ChevronDown, + ChevronLeft, + ChevronUp, + ArrowUp, + ArrowDown, + + // Actions + Plus, + Minus, + Check, + X, + Search, + Refresh, + Copy, + Trash, + Edit, + Eye, + EyeOff, + ExternalLink, + Settings, + Undo, + Redo, + + // Objects + Star, + Pin, + Clock, + User, + Folder, + File, + Tag, + Stash, + Terminal, + + // AI + Sparkle, + + // Status + Info, + CheckCircle, + HalfCircle, + XCircle, + AlertTriangle, + Lock, + + // Misc + Dot, + DotOutline, + Menu, + + // Window controls + Maximize, + Minimize, + Close, + Restore, + MoreHorizontal, + GripVertical, +} + +impl IconName { + /// SVG asset path for this icon, relative to the `assets/icons/` directory. + pub fn path(&self) -> &'static str { + match self { + IconName::GitBranch => "icons/git-branch.svg", + IconName::GitCommit => "icons/git-commit.svg", + IconName::GitMerge => "icons/git-merge.svg", + IconName::GitPullRequest => "icons/git-pull-request.svg", + IconName::GitCompare => "icons/git-compare.svg", + IconName::FileAdded => "icons/file-plus.svg", + IconName::FileModified => "icons/file-diff.svg", + IconName::FileDeleted => "icons/file-minus.svg", + IconName::FileRenamed => "icons/file-symlink.svg", + IconName::FileConflict => "icons/alert-triangle.svg", + IconName::ChevronRight => "icons/chevron-right.svg", + IconName::ChevronDown => "icons/chevron-down.svg", + IconName::ChevronLeft => "icons/chevron-left.svg", + IconName::ChevronUp => "icons/chevron-up.svg", + IconName::ArrowUp => "icons/arrow-up.svg", + IconName::ArrowDown => "icons/arrow-down.svg", + IconName::Plus => "icons/plus.svg", + IconName::Minus => "icons/minus.svg", + IconName::Check => "icons/check.svg", + IconName::X => "icons/x.svg", + IconName::Search => "icons/search.svg", + IconName::Refresh => "icons/refresh-cw.svg", + IconName::Copy => "icons/copy.svg", + IconName::Trash => "icons/trash.svg", + IconName::Edit => "icons/edit.svg", + IconName::Eye => "icons/eye.svg", + IconName::EyeOff => "icons/eye-off.svg", + IconName::ExternalLink => "icons/external-link.svg", + IconName::Settings => "icons/settings.svg", + IconName::Undo => "icons/undo.svg", + IconName::Redo => "icons/redo.svg", + IconName::Star => "icons/star.svg", + IconName::Pin => "icons/pin.svg", + IconName::Clock => "icons/clock.svg", + IconName::User => "icons/user.svg", + IconName::Folder => "icons/folder.svg", + IconName::File => "icons/file.svg", + IconName::Tag => "icons/tag.svg", + IconName::Stash => "icons/archive.svg", + IconName::Terminal => "icons/terminal.svg", + IconName::Sparkle => "icons/sparkle.svg", + IconName::Info => "icons/info.svg", + IconName::CheckCircle => "icons/check-circle.svg", + IconName::HalfCircle => "icons/half-circle.svg", + IconName::XCircle => "icons/x-circle.svg", + IconName::AlertTriangle => "icons/alert-triangle.svg", + IconName::Lock => "icons/lock.svg", + IconName::Dot => "icons/dot.svg", + IconName::DotOutline => "icons/dot-outline.svg", + IconName::Menu => "icons/menu.svg", + IconName::MoreHorizontal => "icons/more-horizontal.svg", + IconName::Maximize => "icons/maximize.svg", + IconName::Minimize => "icons/minimize.svg", + IconName::Close => "icons/close.svg", + IconName::Restore => "icons/restore.svg", + IconName::GripVertical => "icons/grip-vertical.svg", + } + } +} + +/// Icon sizes. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum IconSize { + XSmall, + Small, + #[default] + Medium, + Large, + Custom(Rems), +} + +impl IconSize { + pub fn rems(&self) -> Rems { + match self { + IconSize::XSmall => rems(0.75), // 12px + IconSize::Small => rems(0.875), // 14px + IconSize::Medium => rems(1.0), // 16px + IconSize::Large => rems(1.5), // 24px + IconSize::Custom(r) => *r, + } + } +} + +/// An SVG icon with semantic color and size. +#[derive(IntoElement)] +pub struct Icon { + name: IconName, + color: Color, + size: IconSize, +} + +impl Icon { + pub fn new(name: IconName) -> Self { + Self { + name, + color: Color::Default, + size: IconSize::Medium, + } + } + + pub fn color(mut self, color: Color) -> Self { + self.color = color; + self + } + + pub fn size(mut self, size: IconSize) -> Self { + self.size = size; + self + } +} + +impl RenderOnce for Icon { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let icon_color = self.color.icon_color(cx); + let size = self.size.rems(); + + svg() + .path(self.name.path()) + .text_color(icon_color) + .size(size) + .flex_shrink_0() + .min_w(size) + .min_h(size) + } +} diff --git a/third_party/rgitui/crates/rgitui_ui/src/indicator.rs b/third_party/rgitui/crates/rgitui_ui/src/indicator.rs new file mode 100644 index 000000000..050aabd92 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_ui/src/indicator.rs @@ -0,0 +1,35 @@ +use gpui::prelude::*; +use gpui::{div, px, App, Window}; +use rgitui_theme::{ActiveTheme, Color}; + +/// A small colored dot indicator for showing status. +#[derive(IntoElement)] +pub struct Indicator { + color: Color, +} + +impl Indicator { + pub fn new(color: Color) -> Self { + Self { color } + } + + pub fn dot(color: Color) -> Self { + Self::new(color) + } +} + +impl RenderOnce for Indicator { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let resolved = self.color.color(cx); + let halo = cx.colors().surface_background; + + div() + .w(px(7.)) + .h(px(7.)) + .rounded_full() + .bg(resolved) + .border_1() + .border_color(halo) + .flex_shrink_0() + } +} diff --git a/third_party/rgitui/crates/rgitui_ui/src/label.rs b/third_party/rgitui/crates/rgitui_ui/src/label.rs new file mode 100644 index 000000000..6a8a8b72f --- /dev/null +++ b/third_party/rgitui/crates/rgitui_ui/src/label.rs @@ -0,0 +1,106 @@ +use gpui::prelude::*; +use gpui::{div, App, SharedString, Window}; +use rgitui_theme::Color; + +/// Label sizes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LabelSize { + XSmall, + Small, + #[default] + Default, + Large, +} + +/// A text label with semantic color and size support. +#[derive(IntoElement)] +pub struct Label { + text: SharedString, + color: Color, + size: LabelSize, + weight: Option, + strikethrough: bool, + italic: bool, + truncate: bool, +} + +impl Label { + pub fn new(text: impl Into) -> Self { + Self { + text: text.into(), + color: Color::Default, + size: LabelSize::Default, + weight: None, + strikethrough: false, + italic: false, + truncate: false, + } + } + + pub fn color(mut self, color: Color) -> Self { + self.color = color; + self + } + + pub fn size(mut self, size: LabelSize) -> Self { + self.size = size; + self + } + + pub fn weight(mut self, weight: gpui::FontWeight) -> Self { + self.weight = Some(weight); + self + } + + pub fn strikethrough(mut self) -> Self { + self.strikethrough = true; + self + } + + pub fn italic(mut self) -> Self { + self.italic = true; + self + } + + pub fn truncate(mut self) -> Self { + self.truncate = true; + self + } +} + +impl RenderOnce for Label { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let text_color = self.color.color(cx); + + let mut el = div().text_color(text_color); + + el = match self.size { + LabelSize::XSmall => el.text_xs(), + LabelSize::Small => el.text_sm(), + LabelSize::Default => el, + LabelSize::Large => el.text_lg(), + }; + + if let Some(weight) = self.weight { + el = el.font_weight(weight); + } + + if self.italic { + el = el.italic(); + } + + if self.strikethrough { + el = el.line_through(); + } + + if self.truncate { + el = el + .min_w_0() + .overflow_x_hidden() + .whitespace_nowrap() + .text_ellipsis(); + } + + el.child(self.text) + } +} diff --git a/third_party/rgitui/crates/rgitui_ui/src/lib.rs b/third_party/rgitui/crates/rgitui_ui/src/lib.rs new file mode 100644 index 000000000..a595345b9 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_ui/src/lib.rs @@ -0,0 +1,59 @@ +mod avatar_cache; +mod badge; +mod button; +mod checkbox; +mod context_menu; +mod diff_stat; +mod disclosure; +mod divider; +mod fuzzy; +mod icon; +mod indicator; +mod label; +mod list_item; +mod modal; +mod picker; +mod scroll_list; +mod scrollbar; +mod select; +mod spinner; +mod stack; +mod status_pill; +mod tab_bar; +mod text_input; +mod toast; +mod tooltip; +mod window_controls; + +pub use avatar_cache::*; +pub use badge::*; +pub use button::*; +pub use checkbox::*; +pub use context_menu::*; +pub use diff_stat::*; +pub use disclosure::*; +pub use divider::*; +pub use fuzzy::*; +pub use icon::*; +pub use indicator::*; +pub use label::*; +pub use list_item::*; +pub use modal::*; +pub use picker::*; +pub use scroll_list::*; +pub use scrollbar::*; +pub use select::*; +pub use spinner::*; +pub use stack::*; +pub use status_pill::*; +pub use tab_bar::*; +pub use text_input::*; +pub use toast::*; +pub use tooltip::*; +pub use window_controls::*; + +// Re-export theme helpers that UI components need +pub use rgitui_theme::{ActiveTheme, Color, StyledExt}; + +/// Common click handler type used across UI components. +pub type ClickHandler = Box; diff --git a/third_party/rgitui/crates/rgitui_ui/src/list_item.rs b/third_party/rgitui/crates/rgitui_ui/src/list_item.rs new file mode 100644 index 000000000..21093f839 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_ui/src/list_item.rs @@ -0,0 +1,197 @@ +use gpui::prelude::*; +use gpui::{div, px, rems, AnyElement, App, ClickEvent, CursorStyle, ElementId, Window}; +use rgitui_theme::{ActiveTheme, StyledExt}; +use smallvec::SmallVec; + +/// List item spacing modes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ListItemSpacing { + ExtraDense, + Dense, + #[default] + Default, + Sparse, +} + +impl ListItemSpacing { + pub fn height(&self) -> gpui::Rems { + match self { + ListItemSpacing::ExtraDense => gpui::rems(1.25), + ListItemSpacing::Dense => gpui::rems(1.5), + ListItemSpacing::Default => gpui::rems(1.75), + ListItemSpacing::Sparse => gpui::rems(2.0), + } + } +} + +/// A rich list row with start/end slots, indentation, and selection state. +#[derive(IntoElement)] +pub struct ListItem { + id: ElementId, + disabled: bool, + selected: bool, + spacing: ListItemSpacing, + density: f32, + indent_level: usize, + indent_step: f32, + start_slot: Option, + end_slot: Option, + end_hover_slot: Option, + children: SmallVec<[AnyElement; 2]>, + on_click: Option, +} + +impl ListItem { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + disabled: false, + selected: false, + spacing: ListItemSpacing::Default, + density: 1.0, + indent_level: 0, + indent_step: 12.0, + start_slot: None, + end_slot: None, + end_hover_slot: None, + children: SmallVec::new(), + on_click: None, + } + } + + pub fn spacing(mut self, spacing: ListItemSpacing) -> Self { + self.spacing = spacing; + self + } + + /// Scales the row height by a density multiplier (e.g. a compactness + /// setting resolved by the caller). Defaults to `1.0`. + pub fn density(mut self, density: f32) -> Self { + self.density = density; + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + pub fn indent_level(mut self, level: usize) -> Self { + self.indent_level = level; + self + } + + pub fn start_slot(mut self, slot: impl IntoElement) -> Self { + self.start_slot = Some(slot.into_any_element()); + self + } + + pub fn end_slot(mut self, slot: impl IntoElement) -> Self { + self.end_slot = Some(slot.into_any_element()); + self + } + + pub fn end_hover_slot(mut self, slot: impl IntoElement) -> Self { + self.end_hover_slot = Some(slot.into_any_element()); + self + } + + pub fn child(mut self, child: impl IntoElement) -> Self { + self.children.push(child.into_any_element()); + 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 + } +} + +impl RenderOnce for ListItem { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let colors = cx.colors(); + let base_height = self.spacing.height(); + let height = rems(base_height.0 * self.density); + let indent_px = self.indent_level as f32 * self.indent_step; + + let bg = if self.selected { + colors.element_selected + } else { + colors.ghost_element_background + }; + + let border_color = if self.selected { + colors.border_focused + } else { + colors.border_transparent + }; + + let mut row = div() + .id(self.id) + .group("list-item") + .h_flex() + .h(height) + .w_full() + .px_2() + .gap_1() + .rounded_md() + .bg(bg) + .border_1() + .border_color(border_color); + + if indent_px > 0.0 { + row = row.pl(px(indent_px + 8.0)); + } + + if !self.disabled { + row = row + .cursor(CursorStyle::PointingHand) + .hover(|s| s.bg(colors.ghost_element_hover)) + .active(|s| s.bg(colors.ghost_element_active)); + } + + if let Some(on_click) = self.on_click { + if !self.disabled { + row = row.on_click(on_click); + } + } + + // Start slot + if let Some(start) = self.start_slot { + row = row.child(start); + } + + // Main content (takes remaining space) + let mut content = div().h_flex().flex_1().min_w_0().gap_1(); + for child in self.children { + content = content.child(child); + } + row = row.child(content); + + // End slot (always visible) + if let Some(end) = self.end_slot { + row = row.child(div().h_flex().child(end)); + } + + // End hover slot (visible only on hover) + if let Some(end_hover) = self.end_hover_slot { + row = row.child( + div() + .h_flex() + .invisible() + .group_hover("list-item", |s| s.visible()) + .child(end_hover), + ); + } + + row + } +} diff --git a/third_party/rgitui/crates/rgitui_ui/src/modal.rs b/third_party/rgitui/crates/rgitui_ui/src/modal.rs new file mode 100644 index 000000000..715d75f26 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_ui/src/modal.rs @@ -0,0 +1,146 @@ +use gpui::prelude::*; +use gpui::{div, px, Animation, AnimationExt, AnyElement, App, Hsla, Window}; +use rgitui_theme::{ActiveTheme, StyledExt}; +use smallvec::SmallVec; +use std::time::Duration; + +use crate::{Divider, Label, LabelSize}; + +/// A content-only modal layout: an absolutely-positioned backdrop centering a +/// titled, scrollable card with an optional footer and an entrance animation. +/// +/// `Modal` deliberately owns no interaction state. As a stateless +/// [`RenderOnce`] element it provides the backdrop, elevation, centering and +/// animation, but it does not trap focus, handle keyboard input, or dismiss +/// itself — those require a stateful host that owns a focus handle and a +/// dismiss/confirm callback. Callers that need a fully interactive dialog must +/// supply that plumbing themselves. +// TODO(audit): UX-14, QUAL-17 — consolidate dialog scaffolding here. Today the +// 14 workspace dialogs (e.g. confirm_dialog.rs) each hand-roll an identical +// backdrop + elevation + animation with inconsistent parameters +// (a:0.4 / a:0.5 / hard-coded black) and Modal is exported but never used. +// Plan: extend Modal with a focus-handle slot and a dismiss callback, add +// `on_key_down` (Esc/Enter) and backdrop `on_click`-to-dismiss handling driven +// by that callback, standardize on the theme-tinted backdrop below, then +// migrate the dialogs onto it so this scaffolding lives in one place. +#[derive(IntoElement)] +pub struct Modal { + title: Option, + body: SmallVec<[AnyElement; 2]>, + footer: Option, + width: f32, +} + +impl Default for Modal { + fn default() -> Self { + Self::new() + } +} + +impl Modal { + pub fn new() -> Self { + Self { + title: None, + body: SmallVec::new(), + footer: None, + width: 480.0, + } + } + + pub fn title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + pub fn width(mut self, width: f32) -> Self { + self.width = width; + self + } + + pub fn child(mut self, child: impl IntoElement) -> Self { + self.body.push(child.into_any_element()); + self + } + + pub fn footer(mut self, footer: impl IntoElement) -> Self { + self.footer = Some(footer.into_any_element()); + self + } +} + +fn ease_out_quint(t: f32) -> f32 { + 1.0 - (1.0 - t).powi(5) +} + +impl RenderOnce for Modal { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let colors = cx.colors(); + + let backdrop_color = Hsla { + a: 0.5, + ..colors.background + }; + + let mut container = div() + .v_flex() + .w(px(self.width)) + .max_h(px(600.)) + .elevation_3(cx) + .rounded_lg() + .overflow_hidden(); + + if let Some(title) = self.title { + container = container + .child( + div().h_flex().px_4().py_3().child( + Label::new(title) + .size(LabelSize::Large) + .weight(gpui::FontWeight::SEMIBOLD), + ), + ) + .child(Divider::new()); + } + + let mut body = div() + .id("modal-body") + .v_flex() + .flex_1() + .p_4() + .gap_3() + .overflow_y_scroll(); + for child in self.body { + body = body.child(child); + } + container = container.child(body); + + if let Some(footer) = self.footer { + container = container.child(Divider::new()).child( + div() + .h_flex() + .px_4() + .py_3() + .justify_end() + .gap_2() + .child(footer), + ); + } + + div() + .id("modal-backdrop") + .occlude() + .absolute() + .top_0() + .left_0() + .size_full() + .flex() + .items_center() + .justify_center() + .bg(backdrop_color) + .child(container) + .with_animation( + "modal-entrance", + Animation::new(Duration::from_millis(150)).with_easing(ease_out_quint), + |el, delta| el.opacity(0.5 + delta * 0.5), + ) + } +} diff --git a/third_party/rgitui/crates/rgitui_ui/src/picker.rs b/third_party/rgitui/crates/rgitui_ui/src/picker.rs new file mode 100644 index 000000000..6d7ee5df5 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_ui/src/picker.rs @@ -0,0 +1,1270 @@ +//! A searchable, virtualized picker. +//! +//! Used for the model list, where a closed pill row cannot work: OpenRouter +//! alone offers hundreds of models, and the two questions that actually decide +//! the choice — "is it cheap" and "does it take tools" — are facets, not +//! substrings. +//! +//! Filtering and ranking are pure functions over `[PickerRow]` so they are +//! testable without a display, per the convention in `CLAUDE.md`. + +use gpui::prelude::*; +use gpui::{ + div, px, uniform_list, App, ClickEvent, Context, Div, ElementId, Entity, EventEmitter, + FocusHandle, Focusable, FontWeight, Hsla, KeyDownEvent, Render, ScrollStrategy, SharedString, + Stateful, UniformListScrollHandle, Window, +}; +use rgitui_theme::{ActiveTheme, Color, StyledExt}; + +use crate::{ + fuzzy_score, Button, ButtonSize, ButtonStyle, Icon, IconButton, IconName, IconSize, Label, + LabelSize, TextInput, TextInputEvent, +}; + +/// One selectable row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PickerRow { + pub id: SharedString, + pub primary: SharedString, + pub secondary: Option, + /// Right-hand column, e.g. `1M $0.30/$2.50`. + pub trailing: Option, + pub badges: Vec, + /// Which filter chips this row belongs to. + pub facets: Vec, +} + +impl PickerRow { + pub fn new(id: impl Into, primary: impl Into) -> Self { + Self { + id: id.into(), + primary: primary.into(), + secondary: None, + trailing: None, + badges: Vec::new(), + facets: Vec::new(), + } + } + + pub fn secondary(mut self, secondary: impl Into) -> Self { + self.secondary = Some(secondary.into()); + self + } + + pub fn trailing(mut self, trailing: impl Into) -> Self { + self.trailing = Some(trailing.into()); + self + } + + pub fn badge(mut self, badge: impl Into) -> Self { + self.badges.push(badge.into()); + self + } + + pub fn facet(mut self, facet: impl Into) -> Self { + self.facets.push(facet.into()); + self + } +} + +/// A filter chip above the list. An empty id means "every row". +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PickerChip { + pub id: SharedString, + pub label: SharedString, +} + +impl PickerChip { + pub fn new(id: impl Into, label: impl Into) -> Self { + Self { + id: id.into(), + label: label.into(), + } + } +} + +#[derive(Debug, Clone)] +pub enum PickerEvent { + Selected(SharedString), + Dismissed, + RefreshRequested, +} + +/// Row height, in pixels. Two lines of text plus padding. +const ROW_HEIGHT: f32 = 44.0; + +/// Rows shown before the list scrolls. +/// +/// The list is given an explicit height from this. A `uniform_list` sized +/// with `flex_1` inside a column that has no fixed height resolves to zero +/// pixels: the search box and chips draw, and the rows never do. +const MAX_VISIBLE_ROWS: usize = 8; + +// Match tiers, best first. Each query word scores the best tier it reaches in +// either the id or the display name, and a row's score is the sum over words. +const TIER_EXACT: u32 = 10_000; +const TIER_PREFIX: u32 = 800; +const TIER_WORD_PREFIX: u32 = 600; +const TIER_SUBSTRING: u32 = 400; +const TIER_IGNORING_PUNCTUATION: u32 = 300; +const TIER_SCATTERED: u32 = 100; + +/// Score one lowercase query word against one lowercase haystack. +fn word_score(word: &str, haystack: &str) -> Option { + if haystack.starts_with(word) { + return Some(TIER_PREFIX); + } + let starts_a_word = haystack.match_indices(word).any(|(index, _)| { + haystack[..index] + .chars() + .next_back() + .is_none_or(|previous| !previous.is_alphanumeric()) + }); + if starts_a_word { + return Some(TIER_WORD_PREFIX); + } + if haystack.contains(word) { + return Some(TIER_SUBSTRING); + } + let compact_word = alphanumeric(word); + if !compact_word.is_empty() && alphanumeric(haystack).contains(&compact_word) { + return Some(TIER_IGNORING_PUNCTUATION); + } + fuzzy_score(word, haystack).map(|_| TIER_SCATTERED) +} + +fn alphanumeric(text: &str) -> String { + text.chars().filter(|c| c.is_alphanumeric()).collect() +} + +/// Score a row against a query, or `None` when some word of the query matches +/// neither the id nor the display name. Pure. +/// +/// Words match independently and in any order, so `sonnet claude` finds +/// `anthropic/claude-sonnet-4.6`, and punctuation is optional, so `gpt5` finds +/// `gpt-5`. Scattered-letter matches still count — that is what surfaces a +/// similar name for a misremembered one — but rank below any row that contains +/// the words outright. +pub fn row_score(row: &PickerRow, query: &str) -> Option { + let query = query.trim().to_lowercase(); + if query.is_empty() { + return Some(0); + } + let id = row.id.to_lowercase(); + let primary = row.primary.to_lowercase(); + let mut score = 0; + for word in query.split_whitespace() { + score += word_score(word, &id).max(word_score(word, &primary))?; + } + if id == query || primary == query { + score += TIER_EXACT; + } + Some(score) +} + +fn row_in_facet(row: &PickerRow, facet: &str) -> bool { + facet.is_empty() || row.facets.iter().any(|value| value == facet) +} + +/// Indices of the rows carrying `facet` that match `query`, best match first. +/// Pure. +/// +/// Equal scores keep the caller's order, which for a server-sorted catalogue +/// is already the most useful ranking there is; an empty query keeps it +/// outright. +pub fn filter_rows(rows: &[PickerRow], query: &str, facet: &str) -> Vec { + let mut scored: Vec<(u32, usize)> = rows + .iter() + .enumerate() + .filter(|(_, row)| row_in_facet(row, facet)) + .filter_map(|(index, row)| Some((row_score(row, query)?, index))) + .collect(); + // Stable, so ties keep the caller's order. + scored.sort_by_key(|(score, _)| std::cmp::Reverse(*score)); + scored.into_iter().map(|(_, index)| index).collect() +} + +/// How many rows each chip holds, ignoring the query. Pure. +/// +/// A chip holding nothing is not offered: a provider that reports no prices +/// has no cheap models, and a chip that can only ever answer "no matches" +/// reads as a broken filter. +pub fn chip_counts(rows: &[PickerRow], chips: &[PickerChip]) -> Vec { + chips + .iter() + .map(|chip| { + rows.iter() + .filter(|row| row_in_facet(row, &chip.id)) + .count() + }) + .collect() +} + +/// The typed id offered with the matches, when the query could name something +/// that is not in the list. Pure. +/// +/// An id never contains whitespace, so a query with a space is a search rather +/// than an id, and a query that already names a row needs no second entry. +pub fn custom_entry(rows: &[PickerRow], query: &str) -> Option { + let query = query.trim(); + if query.is_empty() + || query.contains(char::is_whitespace) + || rows.iter().any(|row| row.id == query) + { + return None; + } + Some(SharedString::from(query.to_string())) +} + +/// One entry in the list: a row, by its index into the rows, or the typed id. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PickerEntry { + Row(usize), + Custom(SharedString), +} + +/// The entries the list shows, in order: the rows carrying `facet` that match +/// `query`, best match first, and the typed id when `allow_custom` is set and +/// no row has it. Pure. +/// +/// The order decides what Enter picks, because every change to the query +/// highlights the first entry. A search puts the typed id after the matches, +/// so `sonnet` picks the best match rather than saving `sonnet` as a model. An +/// id — a vendor and a model either side of a slash, which display names never +/// contain — puts it first instead: behind the matches, `vendor/model` would +/// lose Enter to `vendor/model-mini` and save a model the user never named. A +/// row matching the query exactly, in any case, still comes first. +pub fn picker_entries( + rows: &[PickerRow], + query: &str, + facet: &str, + allow_custom: bool, +) -> Vec { + let matches = filter_rows(rows, query, facet); + let Some(custom) = allow_custom.then(|| custom_entry(rows, query)).flatten() else { + return matches.into_iter().map(PickerEntry::Row).collect(); + }; + let names_an_id = custom + .split_once('/') + .is_some_and(|(vendor, model)| !vendor.is_empty() && !model.is_empty()); + let exact_match = matches + .first() + .and_then(|&row| row_score(&rows[row], query)) + .is_some_and(|score| score >= TIER_EXACT); + let matching = matches.into_iter().map(PickerEntry::Row); + let custom = std::iter::once(PickerEntry::Custom(custom)); + if names_an_id && !exact_match { + custom.chain(matching).collect() + } else { + matching.chain(custom).collect() + } +} + +pub struct Picker { + rows: Vec, + chips: Vec, + /// Per chip, how many rows it holds regardless of the query. + chip_counts: Vec, + active_chip: SharedString, + selected_id: Option, + /// What the list shows for the current rows, chip and query. + entries: Vec, + allow_custom: bool, + /// An index into `entries`. + highlighted: usize, + query_editor: Entity, + scroll_handle: UniformListScrollHandle, + focus_handle: FocusHandle, + footer_note: Option, + status_note: Option, +} + +impl EventEmitter for Picker {} + +impl Focusable for Picker { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Picker { + pub fn new(cx: &mut Context) -> Self { + let query_editor = cx.new(|cx| { + let mut input = TextInput::new(cx); + input.set_placeholder("Search by name or id…"); + input + }); + + // Only `Changed` is handled. Enter also bubbles to `on_key_down`, so + // committing on `Submit` as well would select every choice twice. + cx.subscribe( + &query_editor, + |this: &mut Self, _, event: &TextInputEvent, cx| { + if let TextInputEvent::Changed(_) = event { + this.refresh_matches(cx); + this.highlight(0, ScrollStrategy::Top, cx); + } + }, + ) + .detach(); + + Self { + rows: Vec::new(), + chips: Vec::new(), + chip_counts: Vec::new(), + active_chip: SharedString::default(), + selected_id: None, + entries: Vec::new(), + allow_custom: false, + highlighted: 0, + query_editor, + scroll_handle: UniformListScrollHandle::new(), + focus_handle: cx.focus_handle(), + footer_note: None, + status_note: None, + } + } + + pub fn set_rows(&mut self, rows: Vec, cx: &mut Context) { + self.rows = rows; + self.refresh_matches(cx); + } + + pub fn set_chips(&mut self, chips: Vec, cx: &mut Context) { + self.chips = chips; + self.refresh_matches(cx); + } + + pub fn set_selected(&mut self, id: Option, cx: &mut Context) { + self.selected_id = id; + cx.notify(); + } + + /// Offer the typed query as a choice when no row has that id. + pub fn set_allow_custom(&mut self, allow_custom: bool, cx: &mut Context) { + self.allow_custom = allow_custom; + self.refresh_matches(cx); + } + + /// A short line in the footer, e.g. `updated 3 h ago`. + pub fn set_footer_note(&mut self, note: Option, cx: &mut Context) { + self.footer_note = note; + cx.notify(); + } + + /// A warning shown above the list — a stale catalogue, or a fetch that + /// failed. Never blanks the list. + pub fn set_status_note(&mut self, note: Option, cx: &mut Context) { + self.status_note = note; + cx.notify(); + } + + pub fn focus(&self, window: &mut Window, cx: &mut Context) { + self.query_editor + .update(cx, |input, cx| input.focus(window, cx)); + } + + /// Clear the search and the chip, highlight the current selection, and + /// focus the search box. + /// + /// Call each time the picker is shown, so it opens on the model in use + /// rather than on whatever was typed into it last time. + pub fn reset(&mut self, window: &mut Window, cx: &mut Context) { + self.query_editor.update(cx, |input, cx| input.clear(cx)); + self.active_chip = SharedString::default(); + self.refresh_matches(cx); + let selected = self.selected_id.as_ref().and_then(|selected| { + self.entries.iter().position( + |entry| matches!(entry, PickerEntry::Row(row) if &self.rows[*row].id == selected), + ) + }); + self.highlight(selected.unwrap_or(0), ScrollStrategy::Center, cx); + self.focus(window, cx); + } + + /// Recompute the chip counts and the entries from the current rows, chip + /// and query. + fn refresh_matches(&mut self, cx: &mut Context) { + let query = self.query_editor.read(cx).text().to_string(); + self.chip_counts = chip_counts(&self.rows, &self.chips); + // New rows (another provider, a toggled setting) can empty the active + // chip, which then disappears; left active, it would strand the list + // on "no matches" behind a chip the user can no longer see. + let chip_available = self.active_chip.is_empty() + || self + .chips + .iter() + .zip(&self.chip_counts) + .any(|(chip, count)| chip.id == self.active_chip && *count > 0); + if !chip_available { + self.active_chip = SharedString::default(); + } + self.entries = picker_entries(&self.rows, &query, &self.active_chip, self.allow_custom); + self.highlighted = self.highlighted.min(self.entries.len().saturating_sub(1)); + cx.notify(); + } + + /// The id behind entry `index`: a matching row's, or the typed one. + fn entry_id(&self, index: usize) -> Option { + match self.entries.get(index)? { + PickerEntry::Row(row) => Some(self.rows[*row].id.clone()), + PickerEntry::Custom(id) => Some(id.clone()), + } + } + + fn highlight(&mut self, index: usize, strategy: ScrollStrategy, cx: &mut Context) { + let count = self.entries.len(); + self.highlighted = index.min(count.saturating_sub(1)); + if count > 0 { + self.scroll_handle + .scroll_to_item(self.highlighted, strategy); + } + cx.notify(); + } + + fn move_highlight(&mut self, delta: isize, cx: &mut Context) { + let count = self.entries.len(); + if count == 0 { + return; + } + let next = (self.highlighted as isize + delta).clamp(0, count as isize - 1) as usize; + self.highlight(next, ScrollStrategy::Nearest, cx); + } + + fn select_chip(&mut self, chip: SharedString, cx: &mut Context) { + self.active_chip = chip; + self.refresh_matches(cx); + self.highlight(0, ScrollStrategy::Top, cx); + } + + fn commit(&mut self, index: usize, cx: &mut Context) { + let Some(id) = self.entry_id(index) else { + return; + }; + self.selected_id = Some(id.clone()); + cx.emit(PickerEvent::Selected(id)); + cx.notify(); + } + + fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context) { + let page = MAX_VISIBLE_ROWS as isize; + match event.keystroke.key.as_str() { + "escape" => cx.emit(PickerEvent::Dismissed), + "down" => self.move_highlight(1, cx), + "up" => self.move_highlight(-1, cx), + "pagedown" => self.move_highlight(page, cx), + "pageup" => self.move_highlight(-page, cx), + "enter" => self.commit(self.highlighted, cx), + _ => return, + } + cx.stop_propagation(); + } +} + +/// The check column shared by every entry, so names line up whether or not a +/// row is the current choice. +fn selection_mark(is_selected: bool) -> Div { + div().w(px(14.)).flex_shrink_0().when(is_selected, |el| { + el.child( + Icon::new(IconName::Check) + .size(IconSize::XSmall) + .color(Color::Accent), + ) + }) +} + +fn row_contents( + entry: Stateful
, + row: &PickerRow, + is_selected: bool, + badge_background: Hsla, +) -> Stateful
{ + let mut badges = div().flex().flex_row().flex_shrink_0().gap(px(4.)); + for badge in &row.badges { + badges = badges.child( + div().px(px(5.)).rounded(px(3.)).bg(badge_background).child( + Label::new(badge.clone()) + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ); + } + + entry + .child(selection_mark(is_selected)) + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .child( + Label::new(row.primary.clone()) + .size(LabelSize::Small) + .truncate(), + ) + .when_some(row.secondary.clone(), |el, secondary| { + el.child( + Label::new(secondary) + .size(LabelSize::XSmall) + .color(Color::Muted) + .truncate(), + ) + }), + ) + .child(badges) + .when_some(row.trailing.clone(), |el, trailing| { + el.child( + div().flex_shrink_0().child( + Label::new(trailing) + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ) + }) +} + +fn custom_contents(entry: Stateful
, id: &str) -> Stateful
{ + entry.child(selection_mark(false)).child( + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .child( + Label::new(format!("Use “{id}”")) + .size(LabelSize::Small) + .truncate(), + ) + .child( + Label::new("Not in this list — sent to the provider exactly as typed") + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ) +} + +impl Render for Picker { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.colors().clone(); + let entry_count = self.entries.len(); + let shown = self + .entries + .iter() + .filter(|entry| matches!(entry, PickerEntry::Row(_))) + .count(); + let total = self.rows.len(); + + // "All" always shows; any other chip only when it holds something. + let offered_chips: Vec<(&PickerChip, usize)> = self + .chips + .iter() + .zip(self.chip_counts.iter().copied()) + .filter(|(chip, count)| chip.id.is_empty() || *count > 0) + .collect(); + + let mut chip_row = div() + .flex() + .flex_row() + // Wrapping is not optional here: a fixed row of chips is exactly + // what made the old model pills overflow the window. + .flex_wrap() + .items_start() + .gap(px(4.)) + .px(px(10.)) + .py(px(6.)); + for (chip, count) in &offered_chips { + let is_active = chip.id == self.active_chip; + let chip_id = chip.id.clone(); + chip_row = chip_row.child( + div() + .id(ElementId::Name(format!("picker-chip-{}", chip.id).into())) + .flex() + .flex_row() + .items_center() + .gap(px(5.)) + .h(px(24.)) + .px(px(10.)) + .rounded(px(12.)) + .cursor_pointer() + .bg(if is_active { + colors.element_selected + } else { + colors.element_background + }) + .hover(|style| style.bg(colors.ghost_element_hover)) + .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| { + cx.stop_propagation(); + this.select_chip(chip_id.clone(), cx); + // A click lands focus on the picker itself; hand it + // back so typing keeps refining the search. + this.focus(window, cx); + })) + .child( + Label::new(chip.label.clone()) + .size(LabelSize::XSmall) + .color(if is_active { + Color::Default + } else { + Color::Muted + }) + .weight(if is_active { + FontWeight::SEMIBOLD + } else { + FontWeight::NORMAL + }), + ) + .child( + Label::new(count.to_string()) + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ); + } + + let body = if entry_count > 0 { + let visible_rows = entry_count.min(MAX_VISIBLE_ROWS); + uniform_list( + "picker-rows", + entry_count, + cx.processor(|this, range: std::ops::Range, _window, cx| { + let colors = cx.colors().clone(); + range + .map(|index| { + // The list lays each row out on its own, so a row + // with no width is only as wide as its text, and + // its highlight and click area stop there. + let entry = div() + .id(ElementId::NamedInteger("picker-row".into(), index as u64)) + .w_full() + .flex() + .flex_row() + .items_center() + .gap(px(8.)) + .h(px(ROW_HEIGHT)) + .px(px(10.)) + .cursor_pointer() + .when(index == this.highlighted, |el| { + el.bg(colors.element_selected) + }) + .hover(|style| style.bg(colors.ghost_element_hover)) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + cx.stop_propagation(); + this.commit(index, cx); + })); + match this.entries.get(index) { + Some(PickerEntry::Row(row)) => { + let row = &this.rows[*row]; + let is_selected = this.selected_id.as_ref() == Some(&row.id); + row_contents(entry, row, is_selected, colors.element_background) + } + Some(PickerEntry::Custom(id)) => custom_contents(entry, id), + None => entry, + } + .into_any_element() + }) + .collect::>() + }), + ) + .track_scroll(&self.scroll_handle) + .h(px(visible_rows as f32 * ROW_HEIGHT)) + // GPUI gives a wheel event to every scroll container under the + // pointer, so the page scrolled along with the list. The list's + // own scrolling runs before this listener; stopping the event here + // keeps it from the page. A list with nothing to scroll lets the + // wheel through. + .when(entry_count > MAX_VISIBLE_ROWS, |list| { + list.on_scroll_wheel(|_, _, cx| cx.stop_propagation()) + }) + .into_any_element() + } else { + let query = self.query_editor.read(cx).text().trim().to_string(); + let message = if total == 0 { + "No models loaded yet".to_string() + } else if query.is_empty() { + "No models in this filter".to_string() + } else { + format!("No models match “{query}”") + }; + div() + .flex() + .flex_col() + .items_center() + .justify_center() + .gap(px(4.)) + .h(px(80.)) + .child( + Label::new(message) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .when(!self.active_chip.is_empty(), |el| { + el.child( + Button::new("picker-clear-chip", "Search all models") + .style(ButtonStyle::Subtle) + .size(ButtonSize::Compact) + .color(Color::Accent) + .on_click(cx.listener(|this, _: &ClickEvent, window, cx| { + cx.stop_propagation(); + this.select_chip(SharedString::default(), cx); + this.focus(window, cx); + })), + ) + }) + .into_any_element() + }; + + div() + .id("model-picker") + .track_focus(&self.focus_handle) + .flex() + .flex_col() + .w_full() + .rounded(px(8.)) + .border_1() + .border_color(colors.border) + .bg(colors.elevated_surface_background) + .elevation_2(cx) + .on_key_down(cx.listener(Self::on_key_down)) + .child( + div() + .flex() + .flex_row() + .items_center() + .gap(px(6.)) + .px(px(10.)) + .py(px(8.)) + .border_b_1() + .border_color(colors.border_variant) + .child( + Icon::new(IconName::Search) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child(div().flex_1().min_w_0().child(self.query_editor.clone())) + .child( + IconButton::new("picker-refresh", IconName::Refresh) + .size(ButtonSize::Compact) + .color(Color::Muted) + .tooltip("Refresh the model list") + .on_click(cx.listener(|_this, _: &ClickEvent, _, cx| { + cx.stop_propagation(); + cx.emit(PickerEvent::RefreshRequested); + })), + ), + ) + .when(offered_chips.len() > 1, |el| el.child(chip_row)) + // A failed refresh shows a note; it never blanks the list, because + // a stale list is far more use than an empty one. + .when_some(self.status_note.clone(), |el, note| { + el.child( + div().px(px(10.)).pb(px(4.)).child( + Label::new(note) + .size(LabelSize::XSmall) + .color(Color::Warning), + ), + ) + }) + .child(body) + .child( + div() + .flex() + .flex_row() + .items_center() + .gap(px(10.)) + .px(px(10.)) + .py(px(6.)) + .border_t_1() + .border_color(colors.border_variant) + .child( + Label::new("↑↓ navigate ⏎ select esc close") + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .child(div().flex_1()) + .child( + Label::new(match &self.footer_note { + Some(note) => format!("{shown} of {total} · {note}"), + None => format!("{shown} of {total}"), + }) + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rows() -> Vec { + vec![ + PickerRow::new("google/gemini-3.1-flash-lite", "Gemini 3.1 Flash Lite") + .facet("cheap") + .facet("tools"), + PickerRow::new("openai/gpt-5.6-luna", "GPT-5.6 Luna") + .facet("cheap") + .facet("tools"), + PickerRow::new("anthropic/claude-opus-4-6", "Claude Opus 4.6").facet("tools"), + ] + } + + fn ids(rows: &[PickerRow], indices: &[usize]) -> Vec { + indices + .iter() + .map(|&index| rows[index].id.to_string()) + .collect() + } + + #[test] + fn an_empty_query_preserves_the_supplied_order() { + assert_eq!(filter_rows(&rows(), "", ""), vec![0, 1, 2]); + assert_eq!(filter_rows(&rows(), " ", ""), vec![0, 1, 2]); + } + + #[test] + fn a_query_ranks_matches_and_drops_non_matches() { + let rows = rows(); + assert_eq!( + ids(&rows, &filter_rows(&rows, "flash", "")), + ["google/gemini-3.1-flash-lite"] + ); + } + + #[test] + fn a_query_matches_the_display_name_as_well_as_the_id() { + let rows = vec![PickerRow::new("vendor/m-2026", "Moonbeam")]; + assert_eq!(filter_rows(&rows, "moonbeam", ""), vec![0]); + } + + #[test] + fn a_query_that_matches_nothing_yields_an_empty_list_not_everything() { + assert!(filter_rows(&rows(), "zzzzzz", "").is_empty()); + } + + #[test] + fn ranking_is_case_insensitive() { + assert_eq!(filter_rows(&rows(), "FLASH", "").len(), 1); + } + + #[test] + fn words_match_in_any_order_across_the_id_and_the_name() { + let rows = vec![ + PickerRow::new("anthropic/claude-opus-5", "Anthropic: Claude Opus 5"), + PickerRow::new( + "anthropic/claude-sonnet-4.6", + "Anthropic: Claude Sonnet 4.6", + ), + ]; + assert_eq!( + ids(&rows, &filter_rows(&rows, "sonnet claude", "")), + ["anthropic/claude-sonnet-4.6"] + ); + } + + #[test] + fn punctuation_in_the_query_is_optional() { + let rows = rows(); + assert_eq!( + ids(&rows, &filter_rows(&rows, "gpt56", "")), + ["openai/gpt-5.6-luna"] + ); + } + + /// Scattered-letter matches are how a similar name surfaces, but a row + /// containing the word outright must come first. + #[test] + fn a_whole_word_outranks_a_scattered_letter_match_and_both_are_kept() { + let rows = vec![ + PickerRow::new("vendor/lighthouse", "Lighthouse"), + PickerRow::new("google/gemini-flash-lite", "Gemini Flash Lite"), + ]; + assert_eq!( + ids(&rows, &filter_rows(&rows, "lite", "")), + ["google/gemini-flash-lite", "vendor/lighthouse"] + ); + } + + #[test] + fn an_exact_id_outranks_longer_ids_that_start_with_it() { + let rows = vec![ + PickerRow::new("openai/gpt-5-mini", "GPT-5 mini"), + PickerRow::new("openai/gpt-5", "GPT-5"), + ]; + assert_eq!( + ids(&rows, &filter_rows(&rows, "openai/gpt-5", "")), + ["openai/gpt-5", "openai/gpt-5-mini"] + ); + } + + #[test] + fn equal_scores_keep_the_supplied_order() { + let rows = vec![ + PickerRow::new("anthropic/claude-opus-5", "Claude Opus 5"), + PickerRow::new("anthropic/claude-sonnet-4.6", "Claude Sonnet 4.6"), + ]; + assert_eq!(filter_rows(&rows, "claude", ""), vec![0, 1]); + } + + #[test] + fn an_empty_chip_means_all_rows() { + assert_eq!(filter_rows(&rows(), "", "").len(), 3); + } + + #[test] + fn a_chip_keeps_only_rows_carrying_that_facet() { + let rows = rows(); + assert_eq!(filter_rows(&rows, "", "cheap").len(), 2); + assert_eq!(filter_rows(&rows, "", "tools").len(), 3); + assert_eq!(filter_rows(&rows, "", "free").len(), 0); + } + + #[test] + fn chip_and_query_filtering_compose() { + let rows = rows(); + assert_eq!( + ids(&rows, &filter_rows(&rows, "gpt", "cheap")), + ["openai/gpt-5.6-luna"] + ); + } + + #[test] + fn chip_counts_ignore_the_query_and_count_empty_chips_as_zero() { + let chips = vec![ + PickerChip::new("", "All"), + PickerChip::new("cheap", "Cheap"), + PickerChip::new("tools", "Tools"), + PickerChip::new("free", "Free"), + ]; + assert_eq!(chip_counts(&rows(), &chips), vec![3, 2, 3, 0]); + } + + #[test] + fn an_unlisted_id_is_offered_as_a_custom_entry() { + assert_eq!( + custom_entry(&rows(), " vendor/new-model ").as_deref(), + Some("vendor/new-model") + ); + } + + #[test] + fn no_custom_entry_for_a_listed_id_a_phrase_or_nothing() { + assert_eq!(custom_entry(&rows(), "openai/gpt-5.6-luna"), None); + assert_eq!(custom_entry(&rows(), "claude opus"), None); + assert_eq!(custom_entry(&rows(), " "), None); + } + + #[test] + fn a_search_keeps_its_best_match_ahead_of_the_typed_id() { + assert_eq!( + picker_entries(&rows(), "flash", "", true), + [PickerEntry::Row(0), PickerEntry::Custom("flash".into())] + ); + } + + /// Enter picks the first entry, so an id typed in full must not lose it to + /// a longer listed id that starts the same way. + #[test] + fn a_typed_id_leads_the_longer_ids_it_starts() { + let rows = vec![PickerRow::new("vendor/model-mini", "Vendor: Model Mini")]; + assert_eq!( + picker_entries(&rows, "vendor/model", "", true), + [ + PickerEntry::Custom("vendor/model".into()), + PickerEntry::Row(0) + ] + ); + } + + #[test] + fn a_vendor_alone_is_a_search() { + assert_eq!( + picker_entries(&rows(), "google/", "", true).first(), + Some(&PickerEntry::Row(0)) + ); + } + + #[test] + fn a_listed_id_typed_in_another_case_still_comes_first() { + assert_eq!( + picker_entries(&rows(), "OpenAI/GPT-5.6-Luna", "", true).first(), + Some(&PickerEntry::Row(1)) + ); + } + + #[test] + fn no_typed_id_is_offered_unless_allowed() { + assert_eq!( + picker_entries(&rows(), "flash", "", false), + [PickerEntry::Row(0)] + ); + } + + #[test] + fn row_builders_compose() { + let row = PickerRow::new("id", "Primary") + .secondary("vendor/id") + .trailing("1M $0.25/$1.50") + .badge("Tools") + .facet("cheap"); + assert_eq!(row.secondary.as_deref(), Some("vendor/id")); + assert_eq!(row.badges, vec![SharedString::from("Tools")]); + assert_eq!(row.facets, vec![SharedString::from("cheap")]); + } + + /// Drives a real picker in a headless window, hosted the way the settings + /// page hosts it: inside a scroll container with more of the page below + /// it, in a column with no fixed height. + mod headless { + use std::cell::Cell; + use std::rc::Rc; + + use gpui::{canvas, point, Bounds, MouseButton, Pixels, Point, ScrollHandle}; + use rgitui_test_support::ViewTest; + + use super::*; + + struct Host { + picker: Entity, + selected: Vec, + painted_bounds: Rc>>, + page_scroll: ScrollHandle, + } + + impl Host { + fn new(_window: &mut Window, cx: &mut Context) -> Self { + let picker = cx.new(Picker::new); + cx.subscribe(&picker, |host: &mut Self, _, event: &PickerEvent, _| { + if let PickerEvent::Selected(id) = event { + host.selected.push(id.clone()); + } + }) + .detach(); + Self { + picker, + selected: Vec::new(), + painted_bounds: Rc::new(Cell::new(Bounds::default())), + page_scroll: ScrollHandle::new(), + } + } + } + + impl Render for Host { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let painted_bounds = self.painted_bounds.clone(); + div().size_full().flex().flex_col().child( + div() + .id("scroll") + .flex() + .flex_col() + .flex_1() + .min_h_0() + .overflow_y_scroll() + .track_scroll(&self.page_scroll) + .child( + div() + .relative() + .flex() + .flex_col() + .w_full() + .child(self.picker.clone()) + .child( + canvas( + move |bounds, _, _| painted_bounds.set(bounds), + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ), + ) + .child(div().flex_shrink_0().h(px(4000.))), + ) + } + } + + fn catalogue() -> Vec { + vec![ + PickerRow::new("anthropic/claude-opus-5", "Anthropic: Claude Opus 5") + .facet("tools"), + PickerRow::new( + "anthropic/claude-sonnet-4.6", + "Anthropic: Claude Sonnet 4.6", + ) + .facet("tools"), + PickerRow::new( + "google/gemini-3.1-flash-lite", + "Google: Gemini 3.1 Flash Lite", + ) + .facet("cheap") + .facet("tools"), + ] + } + + fn open(rows: Vec) -> ViewTest { + let mut view = ViewTest::open(Host::new); + view.update(|host, window, cx| { + host.picker.update(cx, |picker, cx| { + picker.set_allow_custom(true, cx); + picker.set_rows(rows, cx); + picker.reset(window, cx); + }); + }); + view.draw(); + view + } + + fn selected(view: &ViewTest) -> Vec { + view.read(|host, _| host.selected.iter().map(|id| id.to_string()).collect()) + } + + fn numbered_rows(count: usize) -> Vec { + (0..count) + .map(|index| { + PickerRow::new(format!("vendor/model-{index}"), format!("Model {index}")) + }) + .collect() + } + + /// Halfway down the picker, which lands on a row: the rows are most of + /// its height. + fn picker_middle(view: &ViewTest) -> Point { + let bounds = view.read(|host, _| host.painted_bounds.get()); + point( + bounds.origin.x + bounds.size.width / 2., + bounds.origin.y + bounds.size.height / 2., + ) + } + + fn page_offset(view: &ViewTest) -> f32 { + view.read(|host, _| f32::from(host.page_scroll.offset().y)) + } + + fn list_offset(view: &ViewTest) -> f32 { + view.read(|host, cx| { + let picker = host.picker.read(cx); + let offset = picker.scroll_handle.0.borrow().base_handle.offset(); + f32::from(offset.y) + }) + } + + #[test] + fn a_click_at_the_far_edge_of_a_row_picks_it() { + let mut view = open(numbered_rows(60)); + let bounds = view.read(|host, _| host.painted_bounds.get()); + let far_edge = point( + bounds.origin.x + bounds.size.width - px(24.), + bounds.origin.y + bounds.size.height / 2., + ); + view.simulate_click(far_edge, MouseButton::Left); + assert_eq!( + selected(&view).len(), + 1, + "a row should reach the edge of the list, not stop where its text does" + ); + } + + #[test] + fn the_wheel_over_a_long_list_scrolls_the_list_and_not_the_page() { + let mut view = open(numbered_rows(60)); + let middle = picker_middle(&view); + view.simulate_scroll(middle, point(px(0.), px(-100.))); + assert!(list_offset(&view) < 0., "the list should have scrolled"); + assert_eq!(page_offset(&view), 0., "the page should not have moved"); + } + + #[test] + fn the_wheel_over_a_list_with_nothing_to_scroll_moves_the_page() { + let mut view = open(numbered_rows(3)); + let middle = picker_middle(&view); + view.simulate_scroll(middle, point(px(0.), px(-100.))); + assert!(page_offset(&view) < 0., "the page should have scrolled"); + } + + #[test] + fn rows_take_up_real_height_in_a_column_with_no_fixed_height() { + let painted_height = |count: usize| { + open(numbered_rows(count)) + .read(|host, _| f32::from(host.painted_bounds.get().size.height)) + }; + let three = painted_height(3); + let sixty = painted_height(60); + let expected = (MAX_VISIBLE_ROWS - 3) as f32 * ROW_HEIGHT; + assert!( + (sixty - three - expected).abs() < 0.5, + "three rows painted {three}px and sixty painted {sixty}px; \ + the list should grow by {expected}px before it scrolls" + ); + } + + #[test] + fn typing_narrows_the_list_and_enter_picks_the_best_match_once() { + let mut view = open(catalogue()); + view.simulate_input("sonnet"); + view.simulate_keystroke("enter"); + assert_eq!(selected(&view), ["anthropic/claude-sonnet-4.6"]); + } + + #[test] + fn arrow_keys_move_through_similar_matches_before_enter() { + let mut view = open(catalogue()); + view.simulate_input("claude"); + view.simulate_keystroke("down"); + view.simulate_keystroke("enter"); + assert_eq!(selected(&view), ["anthropic/claude-sonnet-4.6"]); + } + + #[test] + fn an_id_missing_from_the_list_can_be_typed_and_chosen() { + let mut view = open(catalogue()); + view.simulate_input("vendor/unlisted"); + view.simulate_keystroke("enter"); + assert_eq!(selected(&view), ["vendor/unlisted"]); + } + + #[test] + fn enter_saves_a_typed_id_rather_than_a_longer_listed_one() { + let mut view = open(vec![PickerRow::new( + "vendor/model-mini", + "Vendor: Model Mini", + )]); + view.simulate_input("vendor/model"); + view.simulate_keystroke("enter"); + assert_eq!(selected(&view), ["vendor/model"]); + } + + #[test] + fn a_chip_limits_the_list_to_its_facet() { + let mut view = open(catalogue()); + view.update(|host, _, cx| { + host.picker.update(cx, |picker, cx| { + picker.set_chips( + vec![ + PickerChip::new("", "All"), + PickerChip::new("cheap", "Cheap"), + ], + cx, + ); + picker.select_chip("cheap".into(), cx); + }); + }); + view.simulate_keystroke("enter"); + assert_eq!(selected(&view), ["google/gemini-3.1-flash-lite"]); + } + + #[test] + fn opening_again_starts_from_a_clear_search_on_the_current_choice() { + let mut view = open(catalogue()); + view.simulate_input("flash"); + view.update(|host, window, cx| { + host.picker.update(cx, |picker, cx| { + picker.set_selected(Some("anthropic/claude-sonnet-4.6".into()), cx); + picker.reset(window, cx); + }); + }); + view.draw(); + view.simulate_keystroke("enter"); + assert_eq!(selected(&view), ["anthropic/claude-sonnet-4.6"]); + } + } +} diff --git a/third_party/rgitui/crates/rgitui_ui/src/scroll_list.rs b/third_party/rgitui/crates/rgitui_ui/src/scroll_list.rs new file mode 100644 index 000000000..5f49506df --- /dev/null +++ b/third_party/rgitui/crates/rgitui_ui/src/scroll_list.rs @@ -0,0 +1,5 @@ +// Scroll list — thin wrapper around gpui::uniform_list for virtualized rendering. +// We export the GPUI types directly and provide a helper for common patterns. + +pub use gpui::uniform_list; +pub use gpui::UniformListScrollHandle; diff --git a/third_party/rgitui/crates/rgitui_ui/src/scrollbar.rs b/third_party/rgitui/crates/rgitui_ui/src/scrollbar.rs new file mode 100644 index 000000000..95826c057 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_ui/src/scrollbar.rs @@ -0,0 +1,506 @@ +use std::cell::Cell; +use std::rc::Rc; + +use gpui::{ + fill, hsla, point, px, size, Along, App, Axis, Bounds, DispatchPhase, Element, ElementId, + GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, LayoutId, ListOffset, ListState, + MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollHandle, Style, + UniformListScrollHandle, Window, +}; +use rgitui_theme::ActiveTheme; + +const THICKNESS: Pixels = px(10.); +const MIN_THUMB_LEN: Pixels = px(24.); + +/// Persisted thumb-drag state for a [`Scrollbar`] element. +/// +/// Stored via [`Window::with_element_state`] so the grab offset survives the +/// re-renders that can occur mid-drag. The inner [`Cell`] is shared (via +/// [`Rc`]) with the mouse-event listeners registered each paint, which read and +/// write the active drag's thumb-local offset. +#[derive(Default)] +struct ThumbDragState { + /// `Some(thumb_local)` while a drag is active, where `thumb_local` is the + /// distance between the grab point and the thumb's leading edge along the + /// scrollbar axis. `None` when no drag is in progress. + offset: Rc>>, +} + +/// Abstraction over scrollable containers so [`Scrollbar`] can drive either a +/// flex container (`ScrollHandle`) or a virtualized list (`ListState`). +/// +/// Implementations are expected to be cheap to clone — the scrollbar grabs a +/// handle during prepaint and retains it inside mouse event callbacks. +pub trait ScrollableHandle: 'static + Clone { + /// Maximum scroll offset along both axes. A zero value means "no scroll". + fn max_offset(&self) -> Point; + /// Current scroll offset. Negative values indicate content scrolled past + /// the top/left edge. + fn offset(&self) -> Point; + /// Apply a new scroll offset. + fn set_offset(&self, point: Point); + /// Current viewport bounds in window coordinates. + fn viewport(&self) -> Bounds; + /// Called when the user begins a thumb drag. Implementations that cache + /// content height during a drag (to keep the thumb stable) hook here. + fn drag_started(&self) {} + /// Called when the user ends a thumb drag. + fn drag_ended(&self) {} +} + +impl ScrollableHandle for ScrollHandle { + fn max_offset(&self) -> Point { + ScrollHandle::max_offset(self) + } + + fn offset(&self) -> Point { + ScrollHandle::offset(self) + } + + fn set_offset(&self, point: Point) { + ScrollHandle::set_offset(self, point); + } + + fn viewport(&self) -> Bounds { + ScrollHandle::bounds(self) + } +} + +impl ScrollableHandle for UniformListScrollHandle { + fn max_offset(&self) -> Point { + self.0.borrow().base_handle.max_offset() + } + + fn offset(&self) -> Point { + self.0.borrow().base_handle.offset() + } + + fn set_offset(&self, point: Point) { + self.0.borrow().base_handle.set_offset(point); + } + + fn viewport(&self) -> Bounds { + self.0.borrow().base_handle.bounds() + } +} + +impl ScrollableHandle for ListState { + fn max_offset(&self) -> Point { + self.max_offset_for_scrollbar() + } + + fn offset(&self) -> Point { + self.scroll_px_offset_for_scrollbar() + } + + fn set_offset(&self, point: Point) { + self.set_offset_from_scrollbar(point); + } + + fn viewport(&self) -> Bounds { + self.viewport_bounds() + } + + fn drag_started(&self) { + self.scrollbar_drag_started(); + } + + fn drag_ended(&self) { + self.scrollbar_drag_ended(); + } +} + +/// A [`ScrollableHandle`] for a [`ListState`] that derives scrollbar geometry from +/// a fixed per-row height estimate rather than `ListState`'s measured content +/// height. +/// +/// `ListState` measures item heights lazily as rows scroll into view, so its +/// reported content height grows while you scroll — a scrollbar driven directly +/// off it resizes its thumb mid-scroll. Modelling content as +/// `row_count * row_height` keeps the thumb a constant size. The mapped position +/// is exact for rows that occupy a single line and approximate for rows that wrap +/// to several visual lines, which is an acceptable trade for a stable, grabbable +/// thumb (line wrapping is opt-in). +#[derive(Clone)] +pub struct EstimatedListScroll { + list: ListState, + row_count: usize, + row_height: Pixels, +} + +impl EstimatedListScroll { + pub fn new(list: ListState, row_count: usize, row_height: Pixels) -> Self { + Self { + list, + row_count, + row_height, + } + } + + fn content_height(&self) -> Pixels { + self.row_height * self.row_count as f32 + } +} + +impl ScrollableHandle for EstimatedListScroll { + fn max_offset(&self) -> Point { + let viewport = self.list.viewport_bounds().size.height; + point( + Pixels::ZERO, + (self.content_height() - viewport).max(Pixels::ZERO), + ) + } + + fn offset(&self) -> Point { + let top = self.list.logical_scroll_top(); + // Clamp the intra-item pixel offset to one estimated row: a wrapped row + // (taller than the estimate) at the viewport top must not push the + // modelled offset past the next row boundary and overshoot max_offset. + let intra = self.row_height.min(top.offset_in_item); + let y = self.row_height * top.item_ix as f32 + intra; + point(Pixels::ZERO, -y) + } + + fn set_offset(&self, offset: Point) { + if self.row_count == 0 || self.row_height <= Pixels::ZERO { + return; + } + let target = (-offset.y).max(Pixels::ZERO); + let item_ix = ((target / self.row_height).floor() as usize).min(self.row_count - 1); + let offset_in_item = target - self.row_height * item_ix as f32; + self.list.scroll_to(ListOffset { + item_ix, + offset_in_item, + }); + } + + fn viewport(&self) -> Bounds { + self.list.viewport_bounds() + } +} + +/// A minimal always-visible scrollbar bound to a [`ScrollableHandle`]. +/// +/// Place it as a sibling of the scrollable content — its layout reserves a +/// 10px-thick track along `axis`. The thumb size and position are derived +/// from the handle's viewport bounds and max offset each frame; drag on the +/// thumb or click on the track to move the bound content. +pub struct Scrollbar { + id: ElementId, + axis: Axis, + handle: H, +} + +impl Scrollbar { + pub fn horizontal(id: impl Into, handle: H) -> Self { + Self { + id: id.into(), + axis: Axis::Horizontal, + handle, + } + } + + pub fn vertical(id: impl Into, handle: H) -> Self { + Self { + id: id.into(), + axis: Axis::Vertical, + handle, + } + } +} + +pub struct ScrollbarPrepaint { + track: Bounds, + thumb: Bounds, + hitbox: Hitbox, + max_offset: Pixels, + thumb_travel: Pixels, + /// True when viewport covers all content — no thumb should paint. + empty: bool, +} + +impl gpui::IntoElement for Scrollbar { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for Scrollbar { + type RequestLayoutState = (); + type PrepaintState = Option; + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let (width, height) = match self.axis { + Axis::Horizontal => (gpui::relative(1.).into(), THICKNESS.into()), + Axis::Vertical => (THICKNESS.into(), gpui::relative(1.).into()), + }; + let style = Style { + flex_shrink: 0., + flex_grow: 0., + size: gpui::Size { width, height }, + ..Default::default() + }; + (window.request_layout(style, None, cx), ()) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + _cx: &mut App, + ) -> Self::PrepaintState { + let viewport = self.handle.viewport(); + let max_offset = self.handle.max_offset(); + let axis = self.axis; + + let viewport_size = viewport.size.along(axis); + let max = max_offset.along(axis); + let content_size = viewport_size + max; + + log::trace!( + target: "rgitui::scrollbar", + "prepaint axis={:?} track_bounds={:?} viewport={:?} viewport_size={:?} max_offset={:?} content_size={:?} offset={:?}", + axis, + bounds, + viewport, + viewport_size, + max, + content_size, + self.handle.offset(), + ); + + if viewport_size <= Pixels::ZERO || content_size <= viewport_size { + log::trace!( + target: "rgitui::scrollbar", + " -> empty (content fits viewport, nothing to scroll)" + ); + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); + return Some(ScrollbarPrepaint { + track: bounds, + thumb: Bounds::default(), + hitbox, + max_offset: Pixels::ZERO, + thumb_travel: Pixels::ZERO, + empty: true, + }); + } + + let track_len = bounds.size.along(axis); + let raw_thumb_len = track_len * (viewport_size / content_size); + let thumb_len = raw_thumb_len.max(MIN_THUMB_LEN).min(track_len); + let thumb_travel = (track_len - thumb_len).max(Pixels::ZERO); + + // offset() is negative when scrolled; normalize to [0, max]. + let scrolled = (-self.handle.offset().along(axis)) + .max(Pixels::ZERO) + .min(max); + let ratio = if max > Pixels::ZERO { + scrolled / max + } else { + 0. + }; + let thumb_start = thumb_travel * ratio; + + let thumb = match axis { + Axis::Horizontal => Bounds::new( + point(bounds.origin.x + thumb_start, bounds.origin.y + px(2.)), + size(thumb_len, bounds.size.height - px(4.)), + ), + Axis::Vertical => Bounds::new( + point(bounds.origin.x + px(2.), bounds.origin.y + thumb_start), + size(bounds.size.width - px(4.), thumb_len), + ), + }; + + log::trace!( + target: "rgitui::scrollbar", + " -> track_len={:?} thumb_len={:?} thumb_travel={:?} scrolled={:?} ratio={:.3} thumb={:?}", + track_len, + thumb_len, + thumb_travel, + scrolled, + ratio, + thumb, + ); + + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); + + Some(ScrollbarPrepaint { + track: bounds, + thumb, + hitbox, + max_offset: max, + thumb_travel, + empty: false, + }) + } + + fn paint( + &mut self, + id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let Some(state) = prepaint.as_ref() else { + return; + }; + + let colors = cx.colors(); + let track_bg = hsla(0., 0., 0., 0.); + let thumb_bg = colors.scrollbar_thumb_background; + let thumb_hover = colors.scrollbar_thumb_hover_background; + + window.paint_quad(fill(state.track, track_bg)); + if state.empty { + return; + } + + let hovering = state.hitbox.is_hovered(window); + let thumb_color = if hovering { thumb_hover } else { thumb_bg }; + window.paint_quad(fill(state.thumb, thumb_color).corner_radii(gpui::Corners::all(px(3.)))); + + // Persist the thumb-local grab offset in GPUI element state so it survives + // re-renders that occur mid-drag (hover events, watcher polls, cursor + // blink). A per-paint cell would be reset to `None` by any such repaint — + // GPUI carries over `element_states` across frames but not mouse listeners, + // so the drag offset must live in element state to keep the thumb tracking. + let drag_state = id + .map(|id| { + window.with_element_state::(id, |existing, _window| { + let state = existing.unwrap_or_default(); + (state.offset.clone(), state) + }) + }) + .unwrap_or_default(); + let axis = self.axis; + let handle = self.handle.clone(); + let thumb_bounds = state.thumb; + let track_bounds = state.track; + let track_origin = track_bounds.origin.along(axis); + let thumb_len = thumb_bounds.size.along(axis); + let thumb_travel = state.thumb_travel; + let max_offset = state.max_offset; + + let offset_for_thumb_start = { + let handle = handle.clone(); + move |thumb_start: Pixels| -> Point { + let ratio = if thumb_travel > Pixels::ZERO { + (thumb_start / thumb_travel).clamp(0., 1.) + } else { + 0. + }; + let scrolled = max_offset * ratio; + let mut offset = handle.offset(); + match axis { + Axis::Horizontal => offset.x = -scrolled, + Axis::Vertical => offset.y = -scrolled, + } + offset + } + }; + + // Mouse down on thumb → start drag, recording thumb-local offset. + // Mouse down on track (outside thumb) → jump so thumb centers on click. + { + let drag_state = drag_state.clone(); + let hitbox = state.hitbox.clone(); + let handle = handle.clone(); + let offset_for_thumb_start = offset_for_thumb_start.clone(); + window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { + if phase != DispatchPhase::Bubble { + return; + } + if event.button != MouseButton::Left { + return; + } + if !hitbox.is_hovered(window) { + return; + } + let pos = event.position.along(axis); + handle.drag_started(); + if thumb_bounds.contains(&event.position) { + drag_state.set(Some(pos - thumb_bounds.origin.along(axis))); + } else { + let thumb_start = + (pos - track_origin - thumb_len / 2.).clamp(px(0.), thumb_travel); + let new_offset = offset_for_thumb_start(thumb_start); + handle.set_offset(new_offset); + drag_state.set(Some(thumb_len / 2.)); + } + window.refresh(); + cx.stop_propagation(); + }); + } + + { + let drag_state = drag_state.clone(); + let handle = handle.clone(); + let offset_for_thumb_start = offset_for_thumb_start.clone(); + let drag_phase = if drag_state.get().is_some() { + DispatchPhase::Capture + } else { + DispatchPhase::Bubble + }; + window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, cx| { + if phase != drag_phase { + return; + } + let Some(local) = drag_state.get() else { + return; + }; + let pos = event.position.along(axis); + let thumb_start = (pos - track_origin - local).clamp(px(0.), thumb_travel); + let new_offset = offset_for_thumb_start(thumb_start); + handle.set_offset(new_offset); + window.refresh(); + cx.stop_propagation(); + }); + } + + { + let handle = handle.clone(); + let drag_state = drag_state.clone(); + let drag_phase = if drag_state.get().is_some() { + DispatchPhase::Capture + } else { + DispatchPhase::Bubble + }; + window.on_mouse_event(move |event: &MouseUpEvent, phase, window, cx| { + if phase != drag_phase { + return; + } + if event.button != MouseButton::Left { + return; + } + if drag_state.take().is_some() { + handle.drag_ended(); + window.refresh(); + cx.stop_propagation(); + } + }); + } + } +} diff --git a/third_party/rgitui/crates/rgitui_ui/src/select.rs b/third_party/rgitui/crates/rgitui_ui/src/select.rs new file mode 100644 index 000000000..efa33ed22 --- /dev/null +++ b/third_party/rgitui/crates/rgitui_ui/src/select.rs @@ -0,0 +1,570 @@ +//! A dropdown select. +//! +//! The component library had no Select, no Combobox and no searchable picker, +//! so every closed choice in settings was rendered as a row of pills — which +//! is why a four-item model list overflowed the default window. +//! +//! State (open/closed, highlighted row) is owned here, because a stateless +//! popover cannot support keyboard navigation, and the settings page had no +//! keyboard support at all. + +use gpui::prelude::*; +use gpui::{ + anchored, deferred, div, px, App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, + Focusable, KeyDownEvent, Render, SharedString, StyleRefinement, Window, +}; +use rgitui_theme::{ActiveTheme, Color, StyledExt}; + +use crate::{Icon, IconName, IconSize, Label, LabelSize}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectOption { + pub id: SharedString, + pub label: SharedString, + /// Second line, rendered muted. + pub detail: Option, + pub icon: Option, + pub disabled: bool, +} + +impl SelectOption { + pub fn new(id: impl Into, label: impl Into) -> Self { + Self { + id: id.into(), + label: label.into(), + detail: None, + icon: None, + disabled: false, + } + } + + pub fn detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + pub fn icon(mut self, icon: IconName) -> Self { + self.icon = Some(icon); + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } +} + +#[derive(Debug, Clone)] +pub enum SelectEvent { + Changed(SharedString), + Dismissed, +} + +/// Move a highlight by `delta`, skipping disabled rows so Enter can never +/// commit one, and clamping at both ends rather than wrapping. +/// +/// Pure, so the whole keyboard-navigation contract is testable without a +/// display. +pub fn next_enabled_option(options: &[SelectOption], from: usize, delta: isize) -> usize { + if options.is_empty() { + return 0; + } + let len = options.len() as isize; + let mut index = from as isize; + for _ in 0..len { + index = (index + delta).clamp(0, len - 1); + if !options[index as usize].disabled { + return index as usize; + } + // Every remaining candidate in this direction is disabled. + if index == 0 || index == len - 1 { + break; + } + } + from.min(options.len().saturating_sub(1)) +} + +pub struct Select { + id: ElementId, + options: Vec, + selected: Option, + placeholder: SharedString, + open: bool, + highlighted: usize, + full_width: bool, + disabled: bool, + tab_index: isize, + focus_handle: FocusHandle, +} + +impl EventEmitter for Select {} + +impl Focusable for Select { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Select { + pub fn new(id: impl Into, cx: &mut Context) -> Self { + Self { + id: id.into(), + options: Vec::new(), + selected: None, + placeholder: "Select…".into(), + open: false, + highlighted: 0, + full_width: false, + disabled: false, + tab_index: 0, + focus_handle: cx.focus_handle(), + } + } + + pub fn set_options(&mut self, options: Vec, cx: &mut Context) { + self.options = options; + self.highlighted = self.selected_index().unwrap_or(0); + cx.notify(); + } + + pub fn set_selected(&mut self, id: Option, cx: &mut Context) { + self.selected = id; + self.highlighted = self.selected_index().unwrap_or(0); + cx.notify(); + } + + pub fn set_placeholder( + &mut self, + placeholder: impl Into, + cx: &mut Context, + ) { + self.placeholder = placeholder.into(); + cx.notify(); + } + + pub fn set_full_width(&mut self, full_width: bool, cx: &mut Context) { + self.full_width = full_width; + cx.notify(); + } + + pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context) { + self.disabled = disabled; + if disabled { + self.open = false; + } + cx.notify(); + } + + pub fn set_tab_index(&mut self, tab_index: isize, cx: &mut Context) { + self.tab_index = tab_index; + cx.notify(); + } + + pub fn selected(&self) -> Option<&SharedString> { + self.selected.as_ref() + } + + pub fn is_open(&self) -> bool { + self.open + } + + fn selected_index(&self) -> Option { + let selected = self.selected.as_ref()?; + self.options + .iter() + .position(|option| &option.id == selected) + } + + /// The text shown on the closed trigger. + fn trigger_label(&self) -> SharedString { + // A selection that is no longer in the option list still shows its raw + // id rather than reverting to the placeholder: a pinned model that has + // left the catalogue must stay visible and changeable. + match self.selected_index() { + Some(index) => self.options[index].label.clone(), + None => self + .selected + .clone() + .filter(|selected| !selected.is_empty()) + .unwrap_or_else(|| self.placeholder.clone()), + } + } + + pub fn toggle(&mut self, cx: &mut Context) { + if self.disabled { + return; + } + self.open = !self.open; + if self.open { + self.highlighted = self.selected_index().unwrap_or(0); + } else { + cx.emit(SelectEvent::Dismissed); + } + cx.notify(); + } + + /// Close the menu, emitting `Dismissed` if it was open. + /// + /// For a host that owns an Esc binding: gpui runs a binding before this + /// element's own key listener, so the listener never sees that Esc. + pub fn close(&mut self, cx: &mut Context) { + if !self.open { + return; + } + self.open = false; + cx.emit(SelectEvent::Dismissed); + cx.notify(); + } + + fn commit(&mut self, index: usize, cx: &mut Context) { + let Some(option) = self.options.get(index) else { + return; + }; + if option.disabled { + return; + } + let id = option.id.clone(); + self.selected = Some(id.clone()); + self.open = false; + cx.emit(SelectEvent::Changed(id)); + cx.notify(); + } + + fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context) { + let key = event.keystroke.key.as_str(); + if !self.open { + if matches!(key, "enter" | "space" | "down") { + self.toggle(cx); + cx.stop_propagation(); + } + return; + } + + match key { + "escape" => self.close(cx), + "enter" => { + let index = self.highlighted; + self.commit(index, cx); + } + "down" => { + self.highlighted = next_enabled_option(&self.options, self.highlighted, 1); + cx.notify(); + } + "up" => { + self.highlighted = next_enabled_option(&self.options, self.highlighted, -1); + cx.notify(); + } + "home" => { + self.highlighted = next_enabled_option(&self.options, 0, 0) + .min(self.options.len().saturating_sub(1)); + if self + .options + .get(self.highlighted) + .is_some_and(|option| option.disabled) + { + self.highlighted = next_enabled_option(&self.options, 0, 1); + } + cx.notify(); + } + "end" => { + let last = self.options.len().saturating_sub(1); + self.highlighted = if self.options.get(last).is_some_and(|o| o.disabled) { + next_enabled_option(&self.options, last, -1) + } else { + last + }; + cx.notify(); + } + _ => return, + } + cx.stop_propagation(); + } +} + +impl Render for Select { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.colors().clone(); + let label = self.trigger_label(); + let is_placeholder = self.selected.is_none(); + + let mut trigger = div() + .id(self.id.clone()) + .track_focus(&self.focus_handle) + .tab_index(self.tab_index) + // Not `h_flex()`: this row owns its own alignment and the forced + // vertical centring interferes once a detail line is present. + .flex() + .flex_row() + .items_center() + .gap(px(6.)) + .h(px(30.)) + .px(px(10.)) + .rounded(px(6.)) + .border_1() + .border_color(colors.border) + .bg(if self.disabled { + colors.element_disabled + } else { + colors.editor_background + }) + .focus_visible({ + let focused = colors.border_focused; + move |style: StyleRefinement| style.border_color(focused) + }) + .on_key_down(cx.listener(Self::on_key_down)) + .child( + div() + .flex_1() + .min_w_0() + .child( + Label::new(label) + .size(LabelSize::Small) + .color(if self.disabled { + Color::Disabled + } else if is_placeholder { + Color::Muted + } else { + Color::Default + }), + ), + ) + .child( + Icon::new(IconName::ChevronDown) + .size(IconSize::XSmall) + .color(Color::Muted), + ); + + if self.full_width { + trigger = trigger.w_full(); + } else { + trigger = trigger.min_w(px(180.)); + } + + if !self.disabled { + trigger = trigger + .cursor_pointer() + .hover(|style| style.border_color(colors.border_focused)) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + cx.stop_propagation(); + this.toggle(cx); + })); + } + + let mut container = div().relative().child(trigger); + if self.full_width { + container = container.w_full(); + } + + if !self.open { + return container; + } + + let mut menu = div() + .id("select-menu") + .flex() + .flex_col() + .min_w(px(200.)) + .max_h(px(280.)) + .overflow_y_scroll() + .py(px(4.)) + .rounded(px(6.)) + .border_1() + .border_color(colors.border) + .bg(colors.elevated_surface_background) + .elevation_2(cx); + + for (index, option) in self.options.iter().enumerate() { + let is_selected = self.selected.as_ref() == Some(&option.id); + let is_highlighted = index == self.highlighted; + let disabled = option.disabled; + + let mut row = div() + .id(ElementId::NamedInteger( + "select-option".into(), + index as u64, + )) + .flex() + .flex_row() + .items_center() + .gap(px(6.)) + .mx(px(4.)) + .px(px(8.)) + .py(px(4.)) + .rounded(px(4.)) + .when(is_highlighted && !disabled, |el| { + el.bg(colors.element_selected) + }) + .when_some(option.icon, |el, icon| { + el.child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)) + }) + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .child( + Label::new(option.label.clone()) + .size(LabelSize::Small) + .color(if disabled { + Color::Disabled + } else { + Color::Default + }), + ) + .when_some(option.detail.clone(), |el, detail| { + el.child( + Label::new(detail) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + }), + ) + .when(is_selected, |el| { + el.child( + Icon::new(IconName::Check) + .size(IconSize::XSmall) + .color(Color::Accent), + ) + }); + + if !disabled { + row = row + .cursor_pointer() + .hover(|style| style.bg(colors.ghost_element_hover)) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + cx.stop_propagation(); + this.commit(index, cx); + })); + } + + menu = menu.child(row); + } + + container.child(deferred( + anchored() + .snap_to_window_with_margin(px(8.)) + .child(div().absolute().top(px(34.)).child(menu)), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn options(specs: &[(&str, bool)]) -> Vec { + specs + .iter() + .map(|(id, disabled)| SelectOption::new(*id, *id).disabled(*disabled)) + .collect() + } + + fn nav(specs: &[(&str, bool)], from: usize, delta: isize) -> usize { + next_enabled_option(&options(specs), from, delta) + } + + #[test] + fn arrow_navigation_moves_one_row_at_a_time() { + let specs = [("a", false), ("b", false), ("c", false)]; + assert_eq!(nav(&specs, 0, 1), 1); + assert_eq!(nav(&specs, 1, 1), 2); + assert_eq!(nav(&specs, 2, -1), 1); + } + + #[test] + fn navigation_clamps_at_both_ends_rather_than_wrapping() { + let specs = [("a", false), ("b", false)]; + assert_eq!(nav(&specs, 1, 1), 1); + assert_eq!(nav(&specs, 0, -1), 0); + } + + /// Enter must never commit a disabled row, so the highlight skips them. + #[test] + fn navigation_skips_disabled_rows() { + let specs = [("a", false), ("b", true), ("c", false)]; + assert_eq!(nav(&specs, 0, 1), 2); + assert_eq!(nav(&specs, 2, -1), 0); + } + + #[test] + fn navigation_on_an_empty_list_stays_put_instead_of_panicking() { + assert_eq!(nav(&[], 0, 1), 0); + assert_eq!(nav(&[], 0, -1), 0); + } + + #[test] + fn a_run_of_disabled_rows_at_the_edge_leaves_the_highlight_where_it_was() { + let specs = [("a", false), ("b", true), ("c", true)]; + assert_eq!(nav(&specs, 0, 1), 0); + } + + #[test] + fn option_builders_compose() { + let option = SelectOption::new("id", "Label") + .detail("1M ctx") + .icon(IconName::Sparkle) + .disabled(true); + assert_eq!(option.id, "id"); + assert_eq!(option.detail.as_deref(), Some("1M ctx")); + assert_eq!(option.icon, Some(IconName::Sparkle)); + assert!(option.disabled); + } + + mod headless { + use gpui::Entity; + use rgitui_test_support::ViewTest; + + use super::*; + + struct Host { + select: Entity

j+6`!L37I>imlI1JI;01muwGSX@wgy6J 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, /// 底部组项顺序,按 `SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS` 固定顺序排列。 bottom_items: Vec<&'static str>, + /// 是否发生过 Maven 运行。对齐 Tauri:左侧栏的 maven 项仅在 + /// `hasMavenRun` 为真时出现(`onMavenClick` 有条件传入); + /// 右侧插件栏的 Maven 入口不受此限制。 + /// Maven 运行尚未接入后端,当前恒为 false,由后续 Maven 集成调用 + /// [`ActivityRailView::set_has_maven_run`] 打开。 + #[allow(dead_code)] + has_maven_run: bool, } impl EventEmitter for ActivityRailView {} @@ -52,6 +59,23 @@ impl ActivityRailView { /// 构造期只做一次快照:设置变更由 `view.rs` 重建/重订阅处理,这里不持有 /// `App`,避免把全局状态带进渲染路径。 pub fn new(cx: &mut Context) -> Self { + let (top_items, bottom_items) = Self::compute_items(false, cx); + + Self { + active_view: Some("files".to_string()), + active_bottom: None, + top_items, + bottom_items, + has_maven_run: false, + } + } + + /// 按设置与 `hasMavenRun` 计算左右分组。maven 仅在发生过 Maven 运行后 + /// 才出现在左侧栏,与 Tauri `main-sidebar.tsx` 的条件传入一致。 + fn compute_items( + has_maven_run: bool, + cx: &mut Context, + ) -> (Vec<&'static str>, Vec<&'static str>) { let s = settings::get(cx); let ordered = normalize_order(&s.sidebar_activity_items_order); let features = &s.core_features; @@ -61,6 +85,7 @@ impl ActivityRailView { .into_iter() .filter(|id| is_feature_available(id, features)) .filter(|id| !hidden.iter().any(|h| h.as_str() == *id)) + .filter(|id| *id != "maven" || has_maven_run) .collect(); // 顶部组保留可见顺序;底部组按 Tauri 常量顺序取交集,保证与参考实现一致。 @@ -74,13 +99,20 @@ impl ActivityRailView { .copied() .filter(|id| visible.contains(id)) .collect(); + (top_items, bottom_items) + } - Self { - active_view: Some("files".to_string()), - active_bottom: None, - top_items, - bottom_items, + /// 设置是否发生过 Maven 运行;为真时左侧栏出现 maven 项。 + #[allow(dead_code)] + pub fn set_has_maven_run(&mut self, has_run: bool, cx: &mut Context) { + self.has_maven_run = has_run; + let (top_items, bottom_items) = Self::compute_items(has_run, cx); + self.top_items = top_items; + self.bottom_items = bottom_items; + if !has_run && self.active_bottom.as_deref() == Some("maven") { + self.active_bottom = None; } + cx.notify(); } pub fn set_active_view(&mut self, view: Option, cx: &mut Context) { diff --git a/linux/src/workbench/terminal.rs b/linux/src/workbench/terminal.rs index 090eaa3cb..bb3dca67c 100644 --- a/linux/src/workbench/terminal.rs +++ b/linux/src/workbench/terminal.rs @@ -2,7 +2,7 @@ use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::component::scroll::ScrollableElement as _; use gpui_kit::component::{h_flex, v_flex, Sizable as _}; use gpui_kit::{ - div, px, rgb, Context, InteractiveElement as _, IntoElement, ParentElement as _, Render, + div, px, Context, InteractiveElement as _, IntoElement, ParentElement as _, Render, Styled as _, Window, }; use portable_pty::{native_pty_system, CommandBuilder, PtySize}; @@ -10,6 +10,8 @@ use std::io::{Read, Write}; use std::sync::{Arc, Mutex}; use std::thread; +use crate::theme::ThemeColors; + /// Linux 原生 PTY 会话 pub struct TerminalSession { writer: Arc>>, @@ -141,7 +143,9 @@ impl Render for TerminalView { v_flex() .size_full() - .bg(rgb(0x0e0f17)) + // 终端区背景跟随主题(对齐 Tauri xterm 的 `--background`), + // 浅色主题下不再残留深色底。 + .bg(ThemeColors::background()) .p_2() .child( // 终端顶部控制栏 @@ -151,14 +155,14 @@ impl Render for TerminalView { .justify_between() .items_center() .border_b_1() - .border_color(rgb(0x23263b)) + .border_color(ThemeColors::border()) .pb_1() .child( h_flex() .items_center() .gap_2() .text_xs() - .text_color(rgb(0x8a90a2)) + .text_color(ThemeColors::subtle_foreground()) .child("Linux PTY Shell") .child(format!("({})", self.working_dir)), ) @@ -180,12 +184,14 @@ impl Render for TerminalView { .overflow_y_scrollbar() .p_2() .text_xs() - .text_color(rgb(0xd1d5db)) + .text_color(ThemeColors::foreground()) .font_family("monospace") .children(lines.into_iter().enumerate().map(|(idx, line)| { - div() - .id(idx) - .child(if line.is_empty() { " ".to_string() } else { line }) + div().id(idx).child(if line.is_empty() { + " ".to_string() + } else { + line + }) })), ) .child( @@ -193,16 +199,16 @@ impl Render for TerminalView { h_flex() .h(px(32.0)) .w_full() - .bg(rgb(0x141522)) + .bg(ThemeColors::surface()) .border_t_1() - .border_color(rgb(0x23263b)) + .border_color(ThemeColors::border()) .items_center() .px_2() .gap_2() .child( div() .text_xs() - .text_color(rgb(0x10b981)) + .text_color(ThemeColors::success()) .font_family("monospace") .child("$"), ) @@ -210,7 +216,7 @@ impl Render for TerminalView { div() .flex_1() .text_xs() - .text_color(rgb(0xffffff)) + .text_color(ThemeColors::foreground()) .font_family("monospace") .child(if self.current_input.is_empty() { "Type command here...".to_string() diff --git a/linux/src/workbench/toolbar.rs b/linux/src/workbench/toolbar.rs index ec2e162ae..f34013cbe 100644 --- a/linux/src/workbench/toolbar.rs +++ b/linux/src/workbench/toolbar.rs @@ -50,11 +50,16 @@ pub enum ToolbarEvent { OpenRecent, } +/// 应用图标:与 Tauri 端 `public/logo.png` 同一文件,编译期嵌入。 +const APP_LOGO_PNG: &[u8] = include_bytes!("../../assets/logo.png"); + pub struct ToolbarView { pub workspace_name: String, pub git_branch: Option, /// 紧凑菜单条是否展开(对齐 Tauri `isCompactMenuVisible`)。 compact_menu_open: bool, + /// 解码后的应用图标,项目菜单触发器左侧的徽标(对齐 Tauri 的 `logo.png`)。 + app_logo: std::sync::Arc, } impl EventEmitter for ToolbarView {} @@ -70,6 +75,10 @@ impl ToolbarView { workspace_name: name, git_branch: None, compact_menu_open: false, + app_logo: std::sync::Arc::new(gpui_kit::Image::from_bytes( + gpui_kit::ImageFormat::Png, + APP_LOGO_PNG.to_vec(), + )), } } @@ -392,26 +401,23 @@ impl Render for ToolbarView { .gap_2() .child(app_menu) .child({ - // 项目菜单:logo + 项目名 + ChevronDown,点击展开项目列表 + // 项目菜单:应用图标 + 项目名 + ChevronDown,点击展开项目列表 let v = view.clone(); let name = project_name.clone(); + let logo = self.app_logo.clone(); Button::new("tb-project-selector") .small() .ghost() .child( - // 项目徽标:Tauri 用 public/logo.png,这里以同尺寸圆角方块承载品牌色。 + // 项目徽标:Tauri 用 public/logo.png(size-5 圆角),保持一致。 div() .size(px(20.0)) .flex_shrink_0() .rounded(px(6.0)) - .bg(ThemeColors::primary()) - .flex() - .items_center() - .justify_center() + .overflow_hidden() .child( - Icon::new(IconName::Zap) - .size(px(12.0)) - .text_color(ThemeColors::background()), + gpui_kit::img(gpui_kit::ImageSource::Image(logo)) + .size_full(), ), ) .child( From b218703e82e2682a56dae254161b792f6bc50390 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Thu, 24 Sep 2026 13:59:14 +0800 Subject: [PATCH 010/109] =?UTF-8?q?fix(linux):=20=E7=A9=BA=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E5=AF=B9=E9=BD=90=20Tauri=20EmptyEditorState?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原空态是自创的 Zap 大图标 + "Lithe IDEA" + 英文介绍 + 快捷键提示, Tauri 不存在。改为复刻 `empty-editor-state.tsx`: 文件图标(40px)+ 右下角放大镜叠加(20px),标题「选择文件以查看」, 描述「外部工具产生的更改会自动显示。」(与 `locale.ts` 中文文案逐字一致)。 --- linux/src/workbench/editor.rs | 102 +++++++++++++--------------------- 1 file changed, 40 insertions(+), 62 deletions(-) diff --git a/linux/src/workbench/editor.rs b/linux/src/workbench/editor.rs index cd505bc84..41db6953e 100644 --- a/linux/src/workbench/editor.rs +++ b/linux/src/workbench/editor.rs @@ -48,12 +48,15 @@ pub struct EditorView { impl EditorView { pub fn new(workspace_root: String, window: &mut Window, cx: &mut Context) -> Self { let editor_state = cx.new(|cx| EditorState::new(window, cx).line_number(true)); - let _editor_subscription = - cx.subscribe_in(&editor_state, window, |this, _state, event: &InputEvent, _window, cx| { + let _editor_subscription = cx.subscribe_in( + &editor_state, + window, + |this, _state, event: &InputEvent, _window, cx| { if matches!(event, InputEvent::Change) { this.on_editor_change(cx); } - }); + }, + ); Self { workspace_root, @@ -420,85 +423,60 @@ impl Render for EditorView { .flex_1() .w_full() .overflow_hidden() - .when_some(active_tab.as_ref().map(|_| editor_state.clone()), |this, state| { - this.child( - Editor::new(&state) - .h(relative(1.0)) - .bordered(false) - .readonly(false), - ) - }) + .when_some( + active_tab.as_ref().map(|_| editor_state.clone()), + |this, state| { + this.child( + Editor::new(&state) + .h(relative(1.0)) + .bordered(false) + .readonly(false), + ) + }, + ) .when(active_tab.is_none(), |this| { - // 无打开文件 Empty State(精致 Lithe 居中徽标与操作提示) + // 无打开文件空态,对齐 Tauri `EmptyEditorState`: + // 文件图标 + 右下角放大镜叠加,标题与描述使用 i18n 同款文案。 this.child( h_flex() .size_full() .items_center() .justify_center() + .bg(ThemeColors::bg_editor()) + .px_6() + .py_8() .child( v_flex() + .max_w(px(448.0)) .items_center() .gap_3() .child( - Icon::new(IconName::Zap) + div() + .relative() .size(px(48.0)) - .text_color(ThemeColors::accent_blue()), + .flex() + .items_center() + .justify_center() + .text_color(ThemeColors::text_muted()) + .child(Icon::new(IconName::FileText).size(px(40.0))) + .child( + div().absolute().bottom_0().right_0().child( + Icon::new(IconName::Search).size(px(20.0)), + ), + ), ) .child( div() - .text_lg() - .font_weight(FontWeight::BOLD) + .text_base() + .font_weight(FontWeight::MEDIUM) .text_color(ThemeColors::text_primary()) - .child("Lithe IDEA"), + .child("选择文件以查看"), ) .child( div() - .text_xs() + .text_sm() .text_color(ThemeColors::text_muted()) - .child("Next-generation IDE for Linux, powered by GPUI Kit & Rust Core"), - ) - .child( - v_flex() - .pt_2() - .gap_2() - .child( - h_flex() - .items_center() - .gap_2() - .text_xs() - .text_color(ThemeColors::text_muted()) - .child("Search files everywhere:") - .child( - div() - .px_1p5() - .py(px(1.0)) - .bg(ThemeColors::bg_tab_hover()) - .border_1() - .border_color(ThemeColors::border()) - .rounded_sm() - .text_color(ThemeColors::text_primary()) - .child("Ctrl+P"), - ), - ) - .child( - h_flex() - .items_center() - .gap_2() - .text_xs() - .text_color(ThemeColors::text_muted()) - .child("Toggle terminal panel:") - .child( - div() - .px_1p5() - .py(px(1.0)) - .bg(ThemeColors::bg_tab_hover()) - .border_1() - .border_color(ThemeColors::border()) - .rounded_sm() - .text_color(ThemeColors::text_primary()) - .child("Ctrl+`"), - ), - ), + .child("外部工具产生的更改会自动显示。"), ), ), ) From 38523f4b67dd68e78a512f9b6596f199087d5c3c Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Thu, 24 Sep 2026 14:06:37 +0800 Subject: [PATCH 011/109] =?UTF-8?q?fix(linux):=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E6=A0=87=E9=A2=98=E4=B8=8E=20Tauri=20?= =?UTF-8?q?=E4=B8=AD=E6=96=87=E6=96=87=E6=A1=88=E9=80=90=E5=AD=97=E5=AF=B9?= =?UTF-8?q?=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 逐项核对 `settings-dialog.tsx` 各分类 `labelKey` 在 `locale.ts` / `ai-commit.ts` 中的中文翻译,修正 8 处自创标题: 常规(原通用)、项目 · JDK 与 Maven(原项目)、运行配置(原运行)、 LSP(原语言服务)、AI 聊天与编辑(原 AI 助手)、AI 与提交(原 AI 提交)、 Git(原版本控制)。编辑器/快捷键/终端/日志/更新原本已一致。 --- linux/src/workbench/settings_dialog.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/linux/src/workbench/settings_dialog.rs b/linux/src/workbench/settings_dialog.rs index 155297809..9214a130e 100644 --- a/linux/src/workbench/settings_dialog.rs +++ b/linux/src/workbench/settings_dialog.rs @@ -73,19 +73,19 @@ impl SettingsCategory { } } - /// 中文标题,展示在导航项与内容区标题。 + /// 中文标题,与 Tauri `settings-dialog.tsx` 各分类 `labelKey` 的中文翻译逐字对应。 pub fn title(self) -> &'static str { match self { - SettingsCategory::General => "通用", - SettingsCategory::Project => "项目", - SettingsCategory::Run => "运行", + SettingsCategory::General => "常规", + SettingsCategory::Project => "项目 · JDK 与 Maven", + SettingsCategory::Run => "运行配置", SettingsCategory::Editor => "编辑器", SettingsCategory::Keyboard => "快捷键", SettingsCategory::Terminal => "终端", - SettingsCategory::Lsp => "语言服务", - SettingsCategory::Ai => "AI 助手", - SettingsCategory::AiCommit => "AI 提交", - SettingsCategory::Git => "版本控制", + SettingsCategory::Lsp => "LSP", + SettingsCategory::Ai => "AI 聊天与编辑", + SettingsCategory::AiCommit => "AI 与提交", + SettingsCategory::Git => "Git", SettingsCategory::Logs => "日志", SettingsCategory::Updates => "更新", } @@ -109,7 +109,7 @@ impl SettingsCategory { } } - /// 由持久化的 `lastSettingsTab` 反解分类;`language` 归到 LSP,未知值回退通用。 + /// 由持久化的 `lastSettingsTab` 反解分类;`language` 归到 LSP,未知值回退常规。 pub fn from_id(id: &str) -> Self { match id { "general" => SettingsCategory::General, @@ -461,7 +461,7 @@ impl Render for SettingsDialog { } } -/// 通用渲染块:分组、行、控件与分类内容。 +/// 常规渲染块:分组、行、控件与分类内容。 impl SettingsDialog { /// 左侧导航项:active 使用 accent 背景 + 左侧 primary 竖条 + 加粗前景。 fn render_nav_item(&self, cat: SettingsCategory, cx: &mut Context) -> impl IntoElement { From 44688b2d96f869d3be6797c1f3dafa669cb85eeb Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Thu, 24 Sep 2026 14:39:20 +0800 Subject: [PATCH 012/109] =?UTF-8?q?feat(linux):=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E5=86=85=E5=AE=B9=E4=B8=8E=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E5=AE=8C=E5=85=A8=E5=AF=B9=E9=BD=90=20Tauri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 图标(对照 settings-dialog.tsx): general→Settings、run→Cog、terminal→SquareTerminal、 ai/ai-commit→WandSparkles、git→Code(与编辑器同图标,和 Tauri 一致)。 面板内容对照 macos-settings-panels.tsx 各 Panel 逐项重写: - 常规:外观(配色主题/外观模式下拉)、语言、项目打开方式、 自动保存、Git 保护策略、隐藏路径(目录/文件模式展示+应用)。 - 编辑器:显示(字号步进 10-22、用法与作者开关)、标签页 (缓冲区轮播)、缩进(制表符宽度下拉)。 - 快捷键:预设下拉(Lithe/VSCode/JetBrains/Xcode)+ 快捷键搜索框。 - 终端:Shell 分组默认 Shell 下拉(系统默认/PowerShell/命令提示符/WSL)。 - LSP:自动补全/参数提示/语义高亮 + 已检测服务器说明。 - AI/AI 提交:分组结构与说明(完整配置后续接入)。 - Git:Fetch 默认行为/集成/视图/默认差异视图/编辑器全量偏好项。 - 日志:位置/诊断/保留策略/诊断包分组结构。 - 更新:软件更新分组(版本+检查按钮+状态文案)。 - 项目:JDK/Maven 三字段结构;运行:描述+生成按钮。 Settings 新增 24 个持久化字段,键与默认值与 Tauri defaultSettings 一致;新增下拉框/多行文本展示控件。 --- linux/src/settings.rs | 85 ++ linux/src/workbench/settings_dialog.rs | 1551 +++++++++++++----------- 2 files changed, 913 insertions(+), 723 deletions(-) diff --git a/linux/src/settings.rs b/linux/src/settings.rs index e3dfdd291..75f3f5105 100644 --- a/linux/src/settings.rs +++ b/linux/src/settings.rs @@ -86,6 +86,32 @@ impl Default for CoreFeatures { } } +/// Tauri 默认隐藏目录模式(`DEFAULT_HIDDEN_DIRECTORY_PATTERNS`)。 +pub const DEFAULT_HIDDEN_DIRECTORY_PATTERNS: &[&str] = &[ + ".git", + ".hg", + ".idea", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + ".svn", + "CVS", + "__pycache__", + "_svn", +]; + +/// Tauri 默认隐藏文件模式(`DEFAULT_HIDDEN_FILE_PATTERNS`)。 +pub const DEFAULT_HIDDEN_FILE_PATTERNS: &[&str] = &[ + "*.pyc", + "*.pyo", + "*.rbc", + "*.yarb", + "*~", + ".DS_Store", + "vssver.scc", + "vssver2.scc", +]; + /// 应用设置真源,字段名与 Tauri `defaultSettings` 的 camelCase 键一一对应。 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -99,8 +125,10 @@ pub struct Settings { pub word_wrap: bool, pub line_numbers: bool, pub show_minimap: bool, + pub code_lens: bool, // Terminal pub terminal_scrollback: usize, + pub terminal_default_shell_id: String, // UI pub display_language: String, pub reduce_motion: bool, @@ -127,6 +155,33 @@ pub struct Settings { // Tabs pub max_open_tabs: usize, pub horizontal_tab_scroll: bool, + // Keyboard + pub keybinding_preset: String, + // Language + pub auto_completion: bool, + pub parameter_hints: bool, + pub semantic_tokens: bool, + // Projects + pub open_folders_in_new_window: bool, + pub ask_where_to_open_projects: bool, + // File tree + pub hidden_file_patterns: Vec, + pub hidden_directory_patterns: Vec, + // Git + pub auto_refresh_git_status: bool, + pub show_untracked_files: bool, + pub show_staged_first: bool, + pub git_default_diff_view: String, + pub open_diff_on_click: bool, + pub git_changes_folder_view: bool, + pub compact_git_status_badges: bool, + pub collapse_empty_git_sections: bool, + pub remember_last_git_panel_mode: bool, + pub confirm_before_discard: bool, + pub enable_inline_git_blame: bool, + pub git_fetch_prune: bool, + pub git_fetch_submodules: String, + pub git_fetch_tags: String, // Advanced pub last_settings_tab: String, pub core_features: CoreFeatures, @@ -142,7 +197,9 @@ impl Default for Settings { word_wrap: false, line_numbers: true, show_minimap: true, + code_lens: true, terminal_scrollback: 10000, + terminal_default_shell_id: String::new(), display_language: "zh-CN".to_string(), reduce_motion: false, show_status_bar: true, @@ -174,6 +231,34 @@ impl Default for Settings { right_tool_window_width: 400.0, max_open_tabs: 100, horizontal_tab_scroll: true, + keybinding_preset: "none".to_string(), + auto_completion: true, + parameter_hints: true, + semantic_tokens: true, + open_folders_in_new_window: true, + ask_where_to_open_projects: true, + hidden_file_patterns: DEFAULT_HIDDEN_FILE_PATTERNS + .iter() + .map(|s| s.to_string()) + .collect(), + hidden_directory_patterns: DEFAULT_HIDDEN_DIRECTORY_PATTERNS + .iter() + .map(|s| s.to_string()) + .collect(), + auto_refresh_git_status: true, + show_untracked_files: true, + show_staged_first: true, + git_default_diff_view: "unified".to_string(), + open_diff_on_click: true, + git_changes_folder_view: true, + compact_git_status_badges: false, + collapse_empty_git_sections: false, + remember_last_git_panel_mode: false, + confirm_before_discard: true, + enable_inline_git_blame: true, + git_fetch_prune: true, + git_fetch_submodules: "inherit".to_string(), + git_fetch_tags: "inherit".to_string(), last_settings_tab: "general".to_string(), core_features: CoreFeatures::default(), } diff --git a/linux/src/workbench/settings_dialog.rs b/linux/src/workbench/settings_dialog.rs index 9214a130e..fe09ae4c5 100644 --- a/linux/src/workbench/settings_dialog.rs +++ b/linux/src/workbench/settings_dialog.rs @@ -9,6 +9,7 @@ use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::menu::DropdownMenu as _; use gpui_kit::component::scroll::ScrollableElement as _; use gpui_kit::component::{h_flex, v_flex, Icon, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; @@ -91,19 +92,21 @@ impl SettingsCategory { } } - /// 导航图标;只使用 `IconName` 中确定存在的变体。 + /// 导航图标;与 Tauri `settings-dialog.tsx` 各分类图标一一对应 + /// (GearSix→Settings、Gear→Cog、CodeBlock→Code、TerminalWindow→SquareTerminal、 + /// MagicWand→WandSparkles、ArrowClockwise→RotateCw)。 pub fn icon(self) -> IconName { match self { - SettingsCategory::General => IconName::SlidersHorizontal, + SettingsCategory::General => IconName::Settings, SettingsCategory::Project => IconName::Folder, - SettingsCategory::Run => IconName::Play, + SettingsCategory::Run => IconName::Cog, SettingsCategory::Editor => IconName::Code, SettingsCategory::Keyboard => IconName::Keyboard, - SettingsCategory::Terminal => IconName::Terminal, + SettingsCategory::Terminal => IconName::SquareTerminal, SettingsCategory::Lsp => IconName::Database, - SettingsCategory::Ai => IconName::Bot, - SettingsCategory::AiCommit => IconName::Bot, - SettingsCategory::Git => IconName::GitBranch, + SettingsCategory::Ai => IconName::WandSparkles, + SettingsCategory::AiCommit => IconName::WandSparkles, + SettingsCategory::Git => IconName::Code, SettingsCategory::Logs => IconName::FileText, SettingsCategory::Updates => IconName::RotateCw, } @@ -142,20 +145,10 @@ pub struct SettingsDialog { pub active_category: SettingsCategory, /// 当前工作区根路径,Project 分类只读展示 workspace_root: String, - // 以下字段对应 Tauri 设置但 Linux `Settings` 尚未提供,暂作对话框本地占位。 - open_folders_in_new_window: bool, - keybinding_preset: String, - vim_mode: bool, - vim_relative_line_numbers: bool, - terminal_cursor_style: String, - lsp_auto_completion: bool, - lsp_parameter_hints: bool, - auto_refresh_git_status: bool, - show_untracked_files: bool, - show_staged_first: bool, - git_default_diff_view: String, - enable_inline_git_blame: bool, - log_level: String, + /// Git 本地更改保护策略(对齐 Tauri `GeneralPanel` 的本地 `gitPolicy` state,暂不落盘) + git_policy: String, + /// 本次会话诊断日志开关(对齐 Tauri 日志面板的会话级状态,重启恢复默认) + diagnostic_mode: bool, } impl EventEmitter for SettingsDialog {} @@ -172,19 +165,8 @@ impl SettingsDialog { Self { active_category, workspace_root, - open_folders_in_new_window: true, - keybinding_preset: "none".to_string(), - vim_mode: false, - vim_relative_line_numbers: false, - terminal_cursor_style: "bar".to_string(), - lsp_auto_completion: true, - lsp_parameter_hints: true, - auto_refresh_git_status: true, - show_untracked_files: true, - show_staged_first: true, - git_default_diff_view: "unified".to_string(), - enable_inline_git_blame: true, - log_level: "info".to_string(), + git_policy: "ask".to_string(), + diagnostic_mode: false, } } @@ -201,21 +183,10 @@ impl SettingsDialog { cx.notify(); } - /// 恢复占位项的本地默认值(不涉及持久化)。 + /// 恢复本地占位项的默认值(不涉及持久化)。 fn reset_placeholders(&mut self) { - self.open_folders_in_new_window = true; - self.keybinding_preset = "none".to_string(); - self.vim_mode = false; - self.vim_relative_line_numbers = false; - self.terminal_cursor_style = "bar".to_string(); - self.lsp_auto_completion = true; - self.lsp_parameter_hints = true; - self.auto_refresh_git_status = true; - self.show_untracked_files = true; - self.show_staged_first = true; - self.git_default_diff_view = "unified".to_string(); - self.enable_inline_git_blame = true; - self.log_level = "info".to_string(); + self.git_policy = "ask".to_string(); + self.diagnostic_mode = false; } /// 写入设置、广播 `Changed` 并刷新视图。 @@ -529,11 +500,12 @@ impl SettingsDialog { .child(v_flex().w_full().gap_3().p_3().child(content)) } - /// 设置行:左侧标签与描述,右侧控件。 + /// 设置行:左侧标签与描述,右侧控件。标签与描述使用 owned 字符串, + /// 以便展示动态值(主题名、路径、版本号等)。 fn render_row( &self, - label: &'static str, - description: Option<&'static str>, + label: String, + description: Option, control: impl IntoElement, ) -> impl IntoElement { let mut info = v_flex().flex_1().min_w_0().child( @@ -601,44 +573,6 @@ impl SettingsDialog { .on_click(cx.listener(move |this, _event, _window, cx| toggle(this, cx))) } - /// 单选项:分段药丸,选中态用 primary 背景。 - fn render_segment( - &self, - id: &'static str, - label: &'static str, - selected: bool, - cx: &mut Context, - on_select: impl Fn(&mut Self, &mut Context) + 'static, - ) -> impl IntoElement { - h_flex() - .id(id) - .items_center() - .justify_center() - .px_2p5() - .py_1() - .rounded_sm() - .border_1() - .cursor_pointer() - .text_xs() - .when(selected, |el| { - el.bg(ThemeColors::primary()) - .border_color(ThemeColors::primary()) - .text_color(ThemeColors::foreground()) - .font_weight(FontWeight::MEDIUM) - }) - .when(!selected, |el| { - el.bg(ThemeColors::background()) - .border_color(ThemeColors::border()) - .text_color(ThemeColors::subtle_foreground()) - .hover(|h| { - h.bg(ThemeColors::accent()) - .text_color(ThemeColors::foreground()) - }) - }) - .child(label) - .on_click(cx.listener(move |this, _event, _window, cx| on_select(this, cx))) - } - /// 数值步进器:减号 + 当前值 + 加号。 fn render_stepper( &self, @@ -694,39 +628,80 @@ impl SettingsDialog { .child(value) } - /// 键位徽标:Keyboard 分类展示用,点击不触发录制。 - fn render_key_badge(&self, label: &'static str) -> impl IntoElement { - div() - .px_2() - .py_0p5() - .rounded_sm() + /// 下拉选择框:当前值按钮 + ChevronDown,点击展开选项列表。 + /// 对齐 Tauri 各面板的原生 ``(`controlClassName` + `w-40/32/44`)。 - /// `options` 为 (值, 展示文本) 对,按钮显示当前值对应的展示文本,选中项打勾。 + /// `options` 为 (值, 展示文本) 对,展示文本为 owned String(便于动态拼接如 "2 个空格"), + /// 按钮显示当前值对应的展示文本,选中项打勾。 fn render_dropdown( &self, id: &'static str, current: String, width: f32, - options: &'static [(&'static str, &'static str)], + options: Vec<(&'static str, String)>, cx: &mut Context, on_select: impl Fn(&mut Self, &'static str, &mut Context) + 'static, ) -> impl IntoElement { let view = cx.entity(); let on_select = std::rc::Rc::new(on_select); + let current_owned = current.clone(); + let options_for_label = options.clone(); // 按钮展示当前值对应的展示文本(对齐原生 select 显示 label 的行为)。 - let current_label = options + let current_label = options_for_label .iter() - .find(|(value, _)| *value == current.as_str()) - .map(|(_, label)| label.to_string()) - .unwrap_or(current.clone()); + .find(|(value, _)| *value == current_owned.as_str()) + .map(|(_, label)| label.clone()) + .unwrap_or(current_owned.clone()); Button::new(id) .small() .ghost() @@ -670,18 +686,20 @@ impl SettingsDialog { ) .dropdown_menu(move |menu, _window, _cx| { let mut menu = menu; - for (value, label) in options { + for (value, label) in &options { let v = view.clone(); let select = on_select.clone(); - let selected = *value == current.as_str(); - let item = gpui_kit::component::menu::PopupMenuItem::new(*label); + let value = *value; + let label = label.clone(); + let selected = value == current.as_str(); + let item = gpui_kit::component::menu::PopupMenuItem::new(label); let item = if selected { item.icon(IconName::Check) } else { item }; menu = menu.item(item.on_click(move |_, _, cx| { - v.update(cx, |this, cx| select(this, *value, cx)); + v.update(cx, |this, cx| select(this, value, cx)); })); } menu @@ -705,7 +723,7 @@ impl SettingsDialog { } /// 分组内的说明文本。 - fn render_note(&self, text: &'static str) -> impl IntoElement { + fn render_note(&self, text: String) -> impl IntoElement { div() .text_xs() .text_color(ThemeColors::subtle_foreground()) @@ -742,18 +760,29 @@ impl SettingsDialog { .gap_4() .child( self.render_group( - "外观", + crate::i18n::menu_text(cx, "settings.mac.appearance").to_string(), v_flex() .w_full() .gap_3() .child(self.render_row( - "配色主题".to_string(), + crate::i18n::menu_text(cx, "settings.mac.colorTheme").to_string(), None, self.render_dropdown( "general-theme", s.theme.clone(), 160.0, - &[("lithe-dark", "深色"), ("lithe-light", "浅色")], + vec![ + ( + "lithe-dark", + crate::i18n::menu_text(cx, "settings.mac.dark") + .to_string(), + ), + ( + "lithe-light", + crate::i18n::menu_text(cx, "settings.mac.light") + .to_string(), + ), + ], cx, |this, theme_id, cx| { // 跟随系统时写入对应的自动主题,否则直接切换主题。 @@ -772,85 +801,134 @@ impl SettingsDialog { }, ), )) - .child(self.render_row( - "外观模式".to_string(), - Some("选择配色主题,并设置是否跟随系统外观。".to_string()), - self.render_dropdown( - "general-appearance-mode", - appearance_mode, - 160.0, - &[("system", "跟随系统"), ("light", "浅色"), ("dark", "深色")], - cx, - |this, mode, cx| { - this.commit_theme(cx, |s| { - if mode == "system" { - s.sync_system_theme = true; - } else { - s.sync_system_theme = false; - s.theme = if mode == "light" { - "lithe-light".to_string() + .child( + self.render_row( + crate::i18n::menu_text(cx, "settings.mac.appearanceMode") + .to_string() + .to_string(), + Some( + crate::i18n::menu_text( + cx, + "settings.mac.appearanceDescription", + ) + .to_string(), + ), + self.render_dropdown( + "general-appearance-mode", + appearance_mode, + 160.0, + vec![ + ( + "system", + crate::i18n::menu_text(cx, "settings.mac.followSystem") + .to_string(), + ), + ( + "light", + crate::i18n::menu_text(cx, "settings.mac.light") + .to_string(), + ), + ( + "dark", + crate::i18n::menu_text(cx, "settings.mac.dark") + .to_string(), + ), + ], + cx, + |this, mode, cx| { + this.commit_theme(cx, |s| { + if mode == "system" { + s.sync_system_theme = true; } else { - "lithe-dark".to_string() - }; - } - }) - }, + s.sync_system_theme = false; + s.theme = if mode == "light" { + "lithe-light".to_string() + } else { + "lithe-dark".to_string() + }; + } + }) + }, + ), ), - )), + ), ), ) .child(self.render_group( - "语言", + crate::i18n::menu_text(cx, "settings.mac.language").to_string(), v_flex().w_full().gap_3().child(self.render_row( - "语言".to_string(), - Some("界面语言会立即生效。默认语言为英文。".to_string()), + crate::i18n::menu_text(cx, "settings.mac.language").to_string(), + Some( + crate::i18n::menu_text(cx, "settings.mac.languageDescription").to_string(), + ), self.render_dropdown( "general-language", s.display_language.clone(), 160.0, - &[("en-US", "English"), ("zh-CN", "简体中文")], + vec![ + ("en-US", "English".to_string()), + ("zh-CN", "简体中文".to_string()), + ], cx, |this, lang, cx| this.commit(cx, |s| s.display_language = lang.to_string()), ), )), )) - .child(self.render_group( - "项目", - v_flex().w_full().gap_3().child(self.render_row( - "项目打开方式".to_string(), - Some( - "选择打开其他项目时是每次询问、保留在此窗口,还是创建新窗口。".to_string(), - ), - self.render_dropdown( - "general-project-placement", - placement, - 160.0, - &[ - ("ask", "每次询问"), - ("this-window", "此窗口"), - ("new-window", "新窗口"), - ], - cx, - |this, mode, cx| { - this.commit(cx, |s| match mode { - "ask" => s.ask_where_to_open_projects = true, - "this-window" => { - s.ask_where_to_open_projects = false; - s.open_folders_in_new_window = false; - } - _ => { - s.ask_where_to_open_projects = false; - s.open_folders_in_new_window = true; - } - }) - }, + .child( + self.render_group( + crate::i18n::menu_text(cx, "settings.mac.projects").to_string(), + v_flex().w_full().gap_3().child( + self.render_row( + crate::i18n::menu_text(cx, "settings.mac.openProjectsIn").to_string(), + Some( + crate::i18n::menu_text(cx, "settings.mac.openProjectsDescription") + .to_string() + .to_string(), + ), + self.render_dropdown( + "general-project-placement", + placement, + 160.0, + vec![ + ( + "ask", + crate::i18n::menu_text(cx, "settings.mac.askEveryTime") + .to_string(), + ), + ( + "this-window", + crate::i18n::menu_text(cx, "settings.mac.thisWindow") + .to_string(), + ), + ( + "new-window", + crate::i18n::menu_text(cx, "settings.mac.newWindow") + .to_string(), + ), + ], + cx, + |this, mode, cx| { + this.commit(cx, |s| match mode { + "ask" => s.ask_where_to_open_projects = true, + "this-window" => { + s.ask_where_to_open_projects = false; + s.open_folders_in_new_window = false; + } + _ => { + s.ask_where_to_open_projects = false; + s.open_folders_in_new_window = true; + } + }) + }, + ), + ), ), - )), - )) + ), + ) .child(self.render_group( - "文件", + crate::i18n::menu_text(cx, "settings.mac.files").to_string(), v_flex().w_full().gap_3().child(self.render_row( - "自动保存更改的文件".to_string(), + crate::i18n::menu_text(cx, "settings.mac.autoSave").to_string(), None, self.render_toggle("general-auto-save", s.auto_save, cx, |this, cx| { this.commit(cx, |s| s.auto_save = !s.auto_save) @@ -858,18 +936,29 @@ impl SettingsDialog { )), )) .child(self.render_group( - "Git", + crate::i18n::menu_text(cx, "settings.tabs.git").to_string(), v_flex().w_full().gap_3().child(self.render_row( - "保存本地更改的方式".to_string(), - Some("选择执行 Git 操作前保护本地更改的方式。".to_string()), + crate::i18n::menu_text(cx, "settings.mac.saveLocalChangesWith").to_string(), + Some( + crate::i18n::menu_text(cx, "settings.mac.gitPolicyDescription").to_string(), + ), self.render_dropdown( "general-git-policy", self.git_policy.clone(), 160.0, - &[ - ("ask", "每次询问"), - ("shelf", "暂存架"), - ("stash", "Git 贮藏"), + vec![ + ( + "ask", + crate::i18n::menu_text(cx, "settings.mac.askEveryTime").to_string(), + ), + ( + "shelf", + crate::i18n::menu_text(cx, "settings.mac.shelf").to_string(), + ), + ( + "stash", + crate::i18n::menu_text(cx, "settings.mac.gitStash").to_string(), + ), ], cx, |this, policy, cx| { @@ -881,35 +970,35 @@ impl SettingsDialog { )) .child( self.render_group( - "隐藏路径", + crate::i18n::menu_text(cx, "settings.mac.hiddenPaths").to_string(), v_flex() .w_full() .gap_3() - .child(self.render_note( - "每行一项。目录名称会隐藏匹配的文件夹;文件条目支持 * 和 ?。", - )) .child( - v_flex().w_full().gap_1p5().child( - div() - .text_xs() - .text_color(ThemeColors::foreground()) - .child("目录"), + self.render_note( + crate::i18n::menu_text(cx, "settings.mac.hiddenPathsDescription") + .to_string(), ), ) + .child(v_flex().w_full().gap_1p5().child( + div().text_xs().text_color(ThemeColors::foreground()).child( + crate::i18n::menu_text(cx, "settings.mac.directories").to_string(), + ), + )) .child(self.render_text_block(s.hidden_directory_patterns.join("\n"), 72.0)) - .child( - div() - .text_xs() - .text_color(ThemeColors::foreground()) - .child("文件模式"), - ) + .child(div().text_xs().text_color(ThemeColors::foreground()).child( + crate::i18n::menu_text(cx, "settings.mac.filePatterns").to_string(), + )) .child(self.render_text_block(s.hidden_file_patterns.join("\n"), 56.0)) .child( h_flex().w_full().justify_end().child( Button::new("general-apply-patterns") .small() .primary() - .label("应用") + .label( + crate::i18n::menu_text(cx, "settings.mac.apply") + .to_string(), + ) .on_click(cx.listener(|_this, _event, _window, cx| { // 模式编辑器后续接入;当前落盘值即显示值。 cx.notify(); @@ -934,36 +1023,42 @@ impl SettingsDialog { .text_color(ThemeColors::foreground()) .child(self.workspace_root.clone()), ) - .child(self.render_note( - "仅保存在当前电脑,作用于当前项目。运行配置默认继承这些值,单独设置的覆盖值保持不变。路径留空时使用自动选择。", - )) + .child( + self.render_note(crate::i18n::menu_text(cx, "settings.project.scope").to_string()), + ) .child( self.render_group( - "工具链", + crate::i18n::menu_text(cx, "settings.project.toolchain").to_string(), v_flex() .w_full() .gap_3() .child(self.render_row( - "JDK 主目录".to_string(), - Some("自动检测(留空)".to_string()), - self.render_value(String::new()), - )) - .child(self.render_row( - "Maven 主目录 / 可执行文件".to_string(), - Some("自动检测(留空)".to_string()), + crate::i18n::menu_text(cx, "run.jdkHome").to_string(), + Some(crate::i18n::menu_text(cx, "run.toolchainAuto").to_string()), self.render_value(String::new()), )) .child(self.render_row( - "Maven JDK 主目录".to_string(), - Some("使用项目 JDK".to_string()), + crate::i18n::menu_text(cx, "run.mavenExecutable").to_string(), + Some(crate::i18n::menu_text(cx, "run.toolchainAuto").to_string()), self.render_value(String::new()), )) + .child( + self.render_row( + crate::i18n::menu_text(cx, "run.mavenJdkHome").to_string(), + Some( + crate::i18n::menu_text(cx, "settings.project.useProjectJdk") + .to_string() + .to_string(), + ), + self.render_value(String::new()), + ), + ) .child( h_flex().w_full().justify_end().child( Button::new("project-save") .small() .primary() - .label("保存") + .label(crate::i18n::menu_text(cx, "ui.save").to_string()) .on_click(cx.listener(|_this, _event, _window, cx| { // 工具链探测与保存尚未接入后端。 cx.notify(); @@ -978,19 +1073,21 @@ impl SettingsDialog { fn render_run_content(&self, cx: &mut Context) -> impl IntoElement { v_flex().w_full().gap_4().child( self.render_group( - "运行配置", + crate::i18n::menu_text(cx, "settings.run.title").to_string(), v_flex() .w_full() .gap_3() .child(self.render_note( - "选择服务或任务,配置启动参数、环境变量和项目环境覆盖项;点击保存后生效。", + crate::i18n::menu_text(cx, "settings.run.description").to_string(), )) .child( h_flex().w_full().justify_end().child( Button::new("run-generate") .small() .primary() - .label("生成运行配置") + .label( + crate::i18n::menu_text(cx, "settings.run.generate").to_string(), + ) .on_click(cx.listener(|_this, _event, _window, cx| { // 运行配置生成尚未接入后端。 cx.notify(); @@ -1004,17 +1101,23 @@ impl SettingsDialog { /// 编辑器:对齐 Tauri `EditorPanel`(显示/编辑器标签页/缩进)。 fn render_editor_content(&self, cx: &mut Context) -> impl IntoElement { let s = settings::get(cx).clone(); + let spaces = crate::i18n::menu_text(cx, "settings.mac.spaces").to_string(); + let tab_options = [ + ("2", format!("2 {spaces}")), + ("4", format!("4 {spaces}")), + ("8", format!("8 {spaces}")), + ]; v_flex() .w_full() .gap_4() .child( self.render_group( - "显示", + crate::i18n::menu_text(cx, "settings.mac.display").to_string(), v_flex() .w_full() .gap_3() .child(self.render_row( - "字体大小".to_string(), + crate::i18n::menu_text(cx, "settings.mac.fontSize").to_string(), None, self.render_stepper( "font-dec", @@ -1030,7 +1133,7 @@ impl SettingsDialog { ), )) .child(self.render_row( - "显示用法与 Git 作者".to_string(), + crate::i18n::menu_text(cx, "settings.mac.showCodeVision").to_string(), None, self.render_toggle("editor-code-lens", s.code_lens, cx, |this, cx| { this.commit(cx, |s| s.code_lens = !s.code_lens) @@ -1038,31 +1141,49 @@ impl SettingsDialog { )), ), ) - .child(self.render_group( - "编辑器标签页", - v_flex().w_full().gap_3().child(self.render_row( - "缓冲区轮播".to_string(), - Some("在主视图中将打开的缓冲区显示为可横向滚动的轮播".to_string()), - self.render_toggle( - "editor-buffer-carousel", - s.horizontal_tab_scroll, - cx, - |this, cx| { - this.commit(cx, |s| s.horizontal_tab_scroll = !s.horizontal_tab_scroll) - }, + .child( + self.render_group( + crate::i18n::menu_text(cx, "settings.mac.editorTabs").to_string(), + v_flex().w_full().gap_3().child( + self.render_row( + crate::i18n::menu_text(cx, "settings.editor.bufferCarousel") + .to_string() + .to_string(), + Some( + crate::i18n::menu_text( + cx, + "settings.editor.bufferCarouselDescription", + ) + .to_string(), + ), + self.render_toggle( + "editor-buffer-carousel", + s.horizontal_tab_scroll, + cx, + |this, cx| { + this.commit(cx, |s| { + s.horizontal_tab_scroll = !s.horizontal_tab_scroll + }) + }, + ), + ), ), - )), - )) + ), + ) .child(self.render_group( - "缩进", + crate::i18n::menu_text(cx, "settings.mac.indentation").to_string(), v_flex().w_full().gap_3().child(self.render_row( - "制表符宽度".to_string(), + crate::i18n::menu_text(cx, "settings.mac.tabWidth").to_string(), None, self.render_dropdown( "editor-tab-width", s.tab_size.to_string(), 128.0, - &[("2", "2 个空格"), ("4", "4 个空格"), ("8", "8 个空格")], + vec![ + ("2", tab_options[0].1.clone()), + ("4", tab_options[1].1.clone()), + ("8", tab_options[2].1.clone()), + ], cx, |this, size, cx| { this.commit(cx, |s| s.tab_size = size.parse().unwrap_or(2)) @@ -1079,19 +1200,19 @@ impl SettingsDialog { .w_full() .gap_4() .child(self.render_group( - "快捷键方案", + crate::i18n::menu_text(cx, "settings.mac.keymapPreset").to_string(), v_flex().w_full().gap_3().child(self.render_row( - "预设".to_string(), + crate::i18n::menu_text(cx, "settings.mac.preset").to_string(), None, self.render_dropdown( "keymap-preset", s.keybinding_preset.clone(), 176.0, - &[ - ("none", "Lithe"), - ("vscode", "Visual Studio Code"), - ("jetbrains", "JetBrains"), - ("xcode", "Xcode"), + vec![ + ("none", "Lithe".to_string()), + ("vscode", "Visual Studio Code".to_string()), + ("jetbrains", "JetBrains".to_string()), + ("xcode", "Xcode".to_string()), ], cx, |this, preset, cx| { @@ -1102,7 +1223,7 @@ impl SettingsDialog { )) .child( self.render_group( - "键盘快捷键", + crate::i18n::menu_text(cx, "settings.mac.shortcuts").to_string(), v_flex() .w_full() .gap_3() @@ -1128,12 +1249,16 @@ impl SettingsDialog { .flex_1() .text_xs() .text_color(ThemeColors::subtle_foreground()) - .child("搜索快捷键"), + .child(crate::i18n::menu_text( + cx, + "settings.mac.searchShortcuts", + )), ), ) .child( self.render_note( - "选择快捷键预设,然后使用命令面板查看和运行可用命令。", + crate::i18n::menu_text(cx, "settings.mac.shortcutsDescription") + .to_string(), ), ), ), @@ -1144,20 +1269,37 @@ impl SettingsDialog { fn render_terminal_content(&self, cx: &mut Context) -> impl IntoElement { let s = settings::get(cx).clone(); v_flex().w_full().gap_4().child(self.render_group( - "Shell", + crate::i18n::menu_text(cx, "settings.mac.shell").to_string(), v_flex().w_full().gap_3().child(self.render_row( - "默认 Shell".to_string(), - Some("用于新的终端会话。".to_string()), + crate::i18n::menu_text(cx, "settings.mac.defaultShell").to_string(), + Some( + crate::i18n::menu_text(cx, "settings.mac.defaultShellDescription").to_string(), + ), self.render_dropdown( "terminal-default-shell", s.terminal_default_shell_id.clone(), 176.0, - &[ - ("", "系统默认"), - ("powershell", "PowerShell"), - ("cmd", "命令提示符"), - ("wsl", "WSL"), - ], + vec![ + ( + "", + crate::i18n::menu_text(cx, "settings.mac.systemDefault") + .to_string(), + ), + ( + "powershell", + crate::i18n::menu_text(cx, "settings.mac.shellPowerShell") + .to_string(), + ), + ( + "cmd", + crate::i18n::menu_text(cx, "settings.mac.shellCommandPrompt") + .to_string(), + ), + ( + "wsl", + crate::i18n::menu_text(cx, "settings.mac.shellWsl").to_string(), + ), + ], cx, |this, shell, cx| { this.commit(cx, |s| s.terminal_default_shell_id = shell.to_string()) @@ -1175,24 +1317,34 @@ impl SettingsDialog { .gap_4() .child( self.render_group( - "语言服务", + crate::i18n::menu_text(cx, "settings.mac.languageServices").to_string(), v_flex() .w_full() .gap_3() - .child(self.render_row( - "自动补全".to_string(), - Some("显示活动语言服务器提供的补全建议。".to_string()), - self.render_toggle( - "lsp-auto-completion", - s.auto_completion, - cx, - |this, cx| { - this.commit(cx, |s| s.auto_completion = !s.auto_completion) - }, + .child( + self.render_row( + crate::i18n::menu_text(cx, "settings.mac.autoCompletion") + .to_string() + .to_string(), + Some( + crate::i18n::menu_text( + cx, + "settings.mac.autoCompletionDescription", + ) + .to_string(), + ), + self.render_toggle( + "lsp-auto-completion", + s.auto_completion, + cx, + |this, cx| { + this.commit(cx, |s| s.auto_completion = !s.auto_completion) + }, + ), ), - )) + ) .child(self.render_row( - "参数提示".to_string(), + crate::i18n::menu_text(cx, "settings.mac.parameterHints").to_string(), None, self.render_toggle( "lsp-parameter-hints", @@ -1203,83 +1355,122 @@ impl SettingsDialog { }, ), )) - .child(self.render_row( - "语义高亮".to_string(), - None, - self.render_toggle( - "lsp-semantic-highlighting", - s.semantic_tokens, - cx, - |this, cx| { - this.commit(cx, |s| s.semantic_tokens = !s.semantic_tokens) - }, + .child( + self.render_row( + crate::i18n::menu_text(cx, "settings.mac.semanticHighlighting") + .to_string() + .to_string(), + None, + self.render_toggle( + "lsp-semantic-highlighting", + s.semantic_tokens, + cx, + |this, cx| { + this.commit(cx, |s| s.semantic_tokens = !s.semantic_tokens) + }, + ), ), - )), + ), + ), + ) + .child( + self.render_group( + crate::i18n::menu_text(cx, "settings.mac.detectedServers").to_string(), + self.render_note( + crate::i18n::menu_text(cx, "settings.mac.detectedServersDescription") + .to_string(), + ), ), ) - .child(self.render_group( - "已检测语言服务器", - self.render_note("语言服务器由已安装的语言扩展检测,并在打开受支持文件时启动。"), - )) } /// AI 聊天与编辑:对齐 `AISettings` 的 Lithe Agent 分组结构 /// (提供商/模型行),完整选择器尚未接入后端。 - fn render_ai_content(&self, _cx: &mut Context) -> impl IntoElement { + fn render_ai_content(&self, cx: &mut Context) -> impl IntoElement { v_flex() .w_full() .gap_4() .child( self.render_group( - "Lithe Agent", + "Lithe Agent".to_string(), v_flex() .w_full() .gap_3() - .child(self.render_row( - "提供商".to_string(), - Some("选择 Lithe Agent 使用的提供商".to_string()), - self.render_value("Anthropic".to_string()), - )) - .child(self.render_row( - "模型".to_string(), - Some("选择 Lithe Agent 使用的模型".to_string()), - self.render_value("claude-sonnet-4-6".to_string()), - )), + .child( + self.render_row( + crate::i18n::menu_text(cx, "aiSettings.provider").to_string(), + Some( + crate::i18n::menu_text(cx, "aiSettings.providerDescription") + .to_string() + .to_string(), + ), + self.render_value("Anthropic".to_string()), + ), + ) + .child( + self.render_row( + crate::i18n::menu_text(cx, "aiSettings.model").to_string(), + Some( + crate::i18n::menu_text(cx, "aiSettings.modelDescription") + .to_string() + .to_string(), + ), + self.render_value("claude-sonnet-4-6".to_string()), + ), + ), ), ) .child(self.render_group( - "说明", - self.render_note("完整提供商与模型选择尚未接入,后续版本提供。"), + crate::i18n::menu_text(cx, "settings.ai.noteTitle").to_string(), + self.render_note( + crate::i18n::menu_text(cx, "settings.ai.providerNote").to_string(), + ), )) } /// AI 与提交:对齐 `AiCommitSettingsPanel` 的分组结构 /// (配置文件/规则),完整配置尚未接入后端。 - fn render_ai_commit_content(&self, _cx: &mut Context) -> impl IntoElement { + fn render_ai_commit_content(&self, cx: &mut Context) -> impl IntoElement { v_flex() .w_full() .gap_4() .child( self.render_group( - "提交信息", + crate::i18n::menu_text(cx, "settings.mac.commitMessage").to_string(), v_flex() .w_full() .gap_3() - .child(self.render_row( - "配置文件".to_string(), - Some("用于生成提交信息的模型配置".to_string()), - self.render_value("默认".to_string()), - )) - .child(self.render_row( - "规则".to_string(), - Some("生成提交信息时遵循的规则".to_string()), - self.render_value("默认".to_string()), - )), + .child( + self.render_row( + crate::i18n::menu_text(cx, "settings.ai.commitProfile").to_string(), + Some( + crate::i18n::menu_text( + cx, + "settings.ai.commitProfileDescription", + ) + .to_string(), + ), + self.render_value("默认".to_string()), + ), + ) + .child( + self.render_row( + crate::i18n::menu_text(cx, "settings.ai.commitRules").to_string(), + Some( + crate::i18n::menu_text( + cx, + "settings.ai.commitRulesDescription", + ) + .to_string(), + ), + self.render_value("默认".to_string()), + ), + ), ), ) .child(self.render_group( - "说明", - self.render_note("AI 提交配置尚未接入,后续版本提供。"), + crate::i18n::menu_text(cx, "settings.ai.noteTitle").to_string(), + self.render_note(crate::i18n::menu_text(cx, "settings.ai.commitNote").to_string()), )) } @@ -1292,13 +1483,13 @@ impl SettingsDialog { .gap_4() .child( self.render_group( - "Fetch 默认行为", + crate::i18n::menu_text(cx, "git.fetch.defaults").to_string(), v_flex() .w_full() .gap_3() .child(self.render_row( - "清理失效的远程跟踪引用".to_string(), - Some("用于所有项目中的普通 Fetch。".to_string()), + crate::i18n::menu_text(cx, "git.fetch.prune").to_string(), + Some(crate::i18n::menu_text(cx, "git.fetch.scope").to_string()), self.render_toggle( "git-fetch-prune", s.git_fetch_prune, @@ -1309,18 +1500,40 @@ impl SettingsDialog { ), )) .child(self.render_row( - "获取子模块".to_string(), + crate::i18n::menu_text(cx, "git.fetch.submodules").to_string(), None, self.render_dropdown( "git-fetch-submodules", s.git_fetch_submodules.clone(), 160.0, - &[ - ("inherit", "使用 Git 配置"), - ("no", "不获取子模块"), - ("onDemand", "按需获取"), - ("yes", "获取全部子模块"), - ], + vec![ + ( + "inherit", + crate::i18n::menu_text( + cx, + "git.fetch.submodules.inherit", + ) + .to_string(), + ), + ( + "no", + crate::i18n::menu_text(cx, "git.fetch.submodules.no") + .to_string(), + ), + ( + "onDemand", + crate::i18n::menu_text( + cx, + "git.fetch.submodules.onDemand", + ) + .to_string(), + ), + ( + "yes", + crate::i18n::menu_text(cx, "git.fetch.submodules.yes") + .to_string(), + ), + ], cx, |this, value, cx| { this.commit(cx, |s| s.git_fetch_submodules = value.to_string()) @@ -1328,18 +1541,34 @@ impl SettingsDialog { ), )) .child(self.render_row( - "获取标签".to_string(), - Some("凭据沿用现有 Git 凭据助手和 SSH 配置。".to_string()), + crate::i18n::menu_text(cx, "git.fetch.tags").to_string(), + Some(crate::i18n::menu_text(cx, "git.fetch.credentials").to_string()), self.render_dropdown( "git-fetch-tags", s.git_fetch_tags.clone(), 160.0, - &[ - ("inherit", "使用 Git 配置"), - ("all", "获取全部标签"), - ("none", "不获取标签"), - ("prune", "同步标签并删除远程已不存在的本地标签"), - ], + vec![ + ( + "inherit", + crate::i18n::menu_text(cx, "git.fetch.tags.inherit") + .to_string(), + ), + ( + "all", + crate::i18n::menu_text(cx, "git.fetch.tags.all") + .to_string(), + ), + ( + "none", + crate::i18n::menu_text(cx, "git.fetch.tags.none") + .to_string(), + ), + ( + "prune", + crate::i18n::menu_text(cx, "git.fetch.tags.prune") + .to_string(), + ), + ], cx, |this, value, cx| { this.commit(cx, |s| s.git_fetch_tags = value.to_string()) @@ -1350,192 +1579,312 @@ impl SettingsDialog { ) .child( self.render_group( - "集成", + crate::i18n::menu_text(cx, "settings.git.integration").to_string(), v_flex() .w_full() .gap_3() - .child(self.render_row( - "Git 集成".to_string(), - Some("启用 Git 仓库的源代码管理功能".to_string()), - self.render_toggle( - "git-integration", - s.core_features.git, - cx, - |this, cx| { - this.commit(cx, |s| s.core_features.git = !s.core_features.git) - }, + .child( + self.render_row( + crate::i18n::menu_text(cx, "settings.git.gitIntegration") + .to_string() + .to_string(), + Some( + crate::i18n::menu_text( + cx, + "settings.git.gitIntegrationDescription", + ) + .to_string(), + ), + self.render_toggle( + "git-integration", + s.core_features.git, + cx, + |this, cx| { + this.commit(cx, |s| { + s.core_features.git = !s.core_features.git + }) + }, + ), ), - )) - .child(self.render_row( - "自动刷新 Git 状态".to_string(), - Some("相关文件或 Git 事件发生变化后自动刷新 Git 视图".to_string()), - self.render_toggle( - "git-auto-refresh", - s.auto_refresh_git_status, - cx, - |this, cx| { - this.commit(cx, |s| { - s.auto_refresh_git_status = !s.auto_refresh_git_status - }) - }, + ) + .child( + self.render_row( + crate::i18n::menu_text(cx, "settings.git.autoRefresh").to_string(), + Some( + crate::i18n::menu_text( + cx, + "settings.git.autoRefreshDescription", + ) + .to_string(), + ), + self.render_toggle( + "git-auto-refresh", + s.auto_refresh_git_status, + cx, + |this, cx| { + this.commit(cx, |s| { + s.auto_refresh_git_status = !s.auto_refresh_git_status + }) + }, + ), ), - )) - .child(self.render_row( - "丢弃前确认".to_string(), - Some("丢弃文件或仓库更改前显示确认提示".to_string()), - self.render_toggle( - "git-confirm-discard", - s.confirm_before_discard, - cx, - |this, cx| { - this.commit(cx, |s| { - s.confirm_before_discard = !s.confirm_before_discard - }) - }, + ) + .child( + self.render_row( + crate::i18n::menu_text(cx, "settings.git.confirmDiscard") + .to_string() + .to_string(), + Some( + crate::i18n::menu_text( + cx, + "settings.git.confirmDiscardDescription", + ) + .to_string(), + ), + self.render_toggle( + "git-confirm-discard", + s.confirm_before_discard, + cx, + |this, cx| { + this.commit(cx, |s| { + s.confirm_before_discard = !s.confirm_before_discard + }) + }, + ), ), - )), + ), ), ) .child( self.render_group( - "Git 视图", + crate::i18n::menu_text(cx, "settings.git.view").to_string(), v_flex() .w_full() .gap_3() - .child(self.render_row( - "基于文件夹的更改".to_string(), - Some("以类似文件视图的文件夹树形式显示 Git 更改".to_string()), - self.render_toggle( - "git-folder-changes", - s.git_changes_folder_view, - cx, - |this, cx| { - this.commit(cx, |s| { - s.git_changes_folder_view = !s.git_changes_folder_view - }) - }, + .child( + self.render_row( + crate::i18n::menu_text(cx, "settings.git.folderChanges") + .to_string() + .to_string(), + Some( + crate::i18n::menu_text( + cx, + "settings.git.folderChangesDescription", + ) + .to_string(), + ), + self.render_toggle( + "git-folder-changes", + s.git_changes_folder_view, + cx, + |this, cx| { + this.commit(cx, |s| { + s.git_changes_folder_view = !s.git_changes_folder_view + }) + }, + ), ), - )) - .child(self.render_row( - "显示未跟踪文件".to_string(), - Some("在 Git 状态面板中显示未跟踪文件".to_string()), - self.render_toggle( - "git-show-untracked", - s.show_untracked_files, - cx, - |this, cx| { - this.commit(cx, |s| { - s.show_untracked_files = !s.show_untracked_files - }) - }, + ) + .child( + self.render_row( + crate::i18n::menu_text(cx, "settings.git.untracked").to_string(), + Some( + crate::i18n::menu_text(cx, "settings.git.untrackedDescription") + .to_string() + .to_string(), + ), + self.render_toggle( + "git-show-untracked", + s.show_untracked_files, + cx, + |this, cx| { + this.commit(cx, |s| { + s.show_untracked_files = !s.show_untracked_files + }) + }, + ), ), - )) - .child(self.render_row( - "优先显示已暂存项".to_string(), - Some("在 Git 面板中将已暂存更改显示在未暂存更改之前".to_string()), - self.render_toggle( - "git-show-staged-first", - s.show_staged_first, - cx, - |this, cx| { - this.commit(cx, |s| s.show_staged_first = !s.show_staged_first) - }, + ) + .child( + self.render_row( + crate::i18n::menu_text(cx, "settings.git.stagedFirst").to_string(), + Some( + crate::i18n::menu_text( + cx, + "settings.git.stagedFirstDescription", + ) + .to_string(), + ), + self.render_toggle( + "git-show-staged-first", + s.show_staged_first, + cx, + |this, cx| { + this.commit(cx, |s| { + s.show_staged_first = !s.show_staged_first + }) + }, + ), ), - )) - .child(self.render_row( - "单击打开差异".to_string(), - Some("单击已更改文件时打开差异,而不是直接打开文件".to_string()), - self.render_toggle( - "git-open-diff-on-click", - s.open_diff_on_click, - cx, - |this, cx| { - this.commit(cx, |s| { - s.open_diff_on_click = !s.open_diff_on_click - }) - }, + ) + .child( + self.render_row( + crate::i18n::menu_text(cx, "settings.git.openDiff").to_string(), + Some( + crate::i18n::menu_text(cx, "settings.git.openDiffDescription") + .to_string() + .to_string(), + ), + self.render_toggle( + "git-open-diff-on-click", + s.open_diff_on_click, + cx, + |this, cx| { + this.commit(cx, |s| { + s.open_diff_on_click = !s.open_diff_on_click + }) + }, + ), ), - )) - .child(self.render_row( - "紧凑 Git 状态标记".to_string(), - Some("在 Git 面板中使用更紧凑的差异统计和暂存标签布局".to_string()), - self.render_toggle( - "git-compact-badges", - s.compact_git_status_badges, - cx, - |this, cx| { - this.commit(cx, |s| { - s.compact_git_status_badges = !s.compact_git_status_badges - }) - }, + ) + .child( + self.render_row( + crate::i18n::menu_text(cx, "settings.git.compactBadges") + .to_string() + .to_string(), + Some( + crate::i18n::menu_text( + cx, + "settings.git.compactBadgesDescription", + ) + .to_string(), + ), + self.render_toggle( + "git-compact-badges", + s.compact_git_status_badges, + cx, + |this, cx| { + this.commit(cx, |s| { + s.compact_git_status_badges = + !s.compact_git_status_badges + }) + }, + ), ), - )) - .child(self.render_row( - "折叠空分区".to_string(), - Some("没有项目时隐藏已暂存更改等空 Git 分区".to_string()), - self.render_toggle( - "git-collapse-empty", - s.collapse_empty_git_sections, + ) + .child( + self.render_row( + crate::i18n::menu_text(cx, "settings.git.collapseEmpty") + .to_string() + .to_string(), + Some( + crate::i18n::menu_text( + cx, + "settings.git.collapseEmptyDescription", + ) + .to_string(), + ), + self.render_toggle( + "git-collapse-empty", + s.collapse_empty_git_sections, + cx, + |this, cx| { + this.commit(cx, |s| { + s.collapse_empty_git_sections = + !s.collapse_empty_git_sections + }) + }, + ), + ), + ) + .child( + self.render_row( + crate::i18n::menu_text(cx, "settings.git.rememberPanel") + .to_string() + .to_string(), + Some( + crate::i18n::menu_text( + cx, + "settings.git.rememberPanelDescription", + ) + .to_string(), + ), + self.render_toggle( + "git-remember-panel", + s.remember_last_git_panel_mode, + cx, + |this, cx| { + this.commit(cx, |s| { + s.remember_last_git_panel_mode = + !s.remember_last_git_panel_mode + }) + }, + ), + ), + ), + ), + ) + .child( + self.render_group( + crate::i18n::menu_text(cx, "settings.git.defaultDiff").to_string(), + v_flex().w_full().gap_3().child( + self.render_row( + crate::i18n::menu_text(cx, "settings.git.defaultDiff").to_string(), + Some( + crate::i18n::menu_text(cx, "settings.git.defaultDiffDescription") + .to_string() + .to_string(), + ), + self.render_dropdown( + "git-default-diff-view", + s.git_default_diff_view.clone(), + 160.0, + vec![ + ( + "unified", + crate::i18n::menu_text(cx, "settings.git.unified") + .to_string(), + ), + ( + "split", + crate::i18n::menu_text(cx, "settings.git.split") + .to_string(), + ), + ], cx, - |this, cx| { - this.commit(cx, |s| { - s.collapse_empty_git_sections = - !s.collapse_empty_git_sections - }) + |this, value, cx| { + this.commit(cx, |s| s.git_default_diff_view = value.to_string()) }, ), - )) - .child(self.render_row( - "记住上次 Git 面板模式".to_string(), - Some("重新打开 Git 视图时恢复上次打开的底部 Git 面板分区".to_string()), + ), + ), + ), + ) + .child( + self.render_group( + crate::i18n::menu_text(cx, "settings.git.editor").to_string(), + v_flex().w_full().gap_3().child( + self.render_row( + crate::i18n::menu_text(cx, "settings.git.inlineBlame").to_string(), + Some( + crate::i18n::menu_text(cx, "settings.git.inlineBlameDescription") + .to_string() + .to_string(), + ), self.render_toggle( - "git-remember-panel", - s.remember_last_git_panel_mode, + "git-inline-blame", + s.enable_inline_git_blame, cx, |this, cx| { this.commit(cx, |s| { - s.remember_last_git_panel_mode = - !s.remember_last_git_panel_mode + s.enable_inline_git_blame = !s.enable_inline_git_blame }) }, ), - )), + ), + ), ), ) - .child(self.render_group( - "默认差异视图", - v_flex().w_full().gap_3().child(self.render_row( - "默认差异视图".to_string(), - Some("选择 Git 差异的默认布局".to_string()), - self.render_dropdown( - "git-default-diff-view", - s.git_default_diff_view.clone(), - 160.0, - &[("unified", "统一视图"), ("split", "拆分视图")], - cx, - |this, value, cx| { - this.commit(cx, |s| s.git_default_diff_view = value.to_string()) - }, - ), - )), - )) - .child(self.render_group( - "编辑器", - v_flex().w_full().gap_3().child(self.render_row( - "启用行内 Blame".to_string(), - Some("在编辑器中显示当前行的 Git Blame 元数据".to_string()), - self.render_toggle( - "git-inline-blame", - s.enable_inline_git_blame, - cx, - |this, cx| { - this.commit(cx, |s| { - s.enable_inline_git_blame = !s.enable_inline_git_blame - }) - }, - ), - )), - )) } /// 日志:对齐 Tauri `LogSettingsPanel` 的分组结构 @@ -1547,95 +1896,150 @@ impl SettingsDialog { .gap_4() .child( self.render_group( - "日志位置", + crate::i18n::menu_text(cx, "settings.logs.locations").to_string(), v_flex() .w_full() .gap_3() - .child(self.render_row( - "当前日志位置".to_string(), - Some("本次会话实际写入日志的目录。".to_string()), - self.render_value(default_dir.clone()), - )) - .child(self.render_row( - "默认日志位置".to_string(), - Some("Lithe 始终维护并清理这个应用自有目录。".to_string()), - self.render_value(default_dir), - )) - .child(self.render_row( - "自定义日志位置".to_string(), - Some("选择父目录后,Lithe 会写入其中的 Lithe/logs 子目录。".to_string()), - h_flex().gap_1p5().child( - Button::new("logs-choose-dir") - .small() - .ghost() - .label("选择…") - .on_click(cx.listener(|_this, _event, _window, cx| { - // 目录选择尚未接入后端。 - cx.notify(); - })), + .child( + self.render_row( + crate::i18n::menu_text(cx, "settings.logs.effectivePath") + .to_string() + .to_string(), + Some( + crate::i18n::menu_text( + cx, + "settings.logs.effectivePathDescription", + ) + .to_string(), + ), + self.render_value(default_dir.clone()), ), - )), + ) + .child( + self.render_row( + crate::i18n::menu_text(cx, "settings.logs.defaultPath").to_string(), + Some( + crate::i18n::menu_text( + cx, + "settings.logs.defaultPathDescription", + ) + .to_string(), + ), + self.render_value(default_dir), + ), + ) + .child( + self.render_row( + crate::i18n::menu_text(cx, "settings.logs.customPath").to_string(), + Some( + crate::i18n::menu_text( + cx, + "settings.logs.customPathDescription", + ) + .to_string(), + ), + h_flex().gap_1p5().child( + Button::new("logs-choose-dir") + .small() + .ghost() + .label( + crate::i18n::menu_text(cx, "settings.logs.choose") + .to_string(), + ) + .on_click(cx.listener(|_this, _event, _window, cx| { + // 目录选择尚未接入后端。 + cx.notify(); + })), + ), + ), + ), ), ) .child( self.render_group( - "诊断", - v_flex().w_full().gap_3().child(self.render_row( - "本次会话启用诊断日志".to_string(), - Some( - "记录 DEBUG 事件和低频 FPS 心跳;Lithe 重启后自动恢复为 INFO。" + crate::i18n::menu_text(cx, "settings.logs.diagnostics").to_string(), + v_flex().w_full().gap_3().child( + self.render_row( + crate::i18n::menu_text(cx, "settings.logs.diagnosticMode").to_string(), + Some( + crate::i18n::menu_text( + cx, + "settings.logs.diagnosticModeDescription", + ) .to_string(), + ), + self.render_toggle( + "logs-diagnostic-mode", + self.diagnostic_mode, + cx, + |this, cx| { + this.diagnostic_mode = !this.diagnostic_mode; + cx.notify(); + }, + ), ), - self.render_toggle( - "logs-diagnostic-mode", - self.diagnostic_mode, - cx, - |this, cx| { - this.diagnostic_mode = !this.diagnostic_mode; - cx.notify(); - }, - ), - )), + ), ), ) .child( self.render_group( - "保留策略", + crate::i18n::menu_text(cx, "settings.logs.retention").to_string(), v_flex() .w_full() .gap_3() - .child(self.render_note( - "单个日志达到 10 MB 后轮转,每天保留最新五个常规日志,并在 Lithe 启动时删除超过 30 天的日志。", - )) - .child(self.render_row( - "清除当前目录日志".to_string(), - Some("只删除当前目录中由 Lithe 管理的日志文件。".to_string()), - Button::new("logs-clear") + .child( + self.render_note( + crate::i18n::menu_text(cx, "settings.logs.retentionDescription") + .to_string(), + ), + ) + .child( + self.render_row( + crate::i18n::menu_text(cx, "settings.logs.clearCurrent") + .to_string() + .to_string(), + Some( + crate::i18n::menu_text( + cx, + "settings.logs.clearCurrentDescription", + ) + .to_string(), + ), + Button::new("logs-clear") + .small() + .ghost() + .label( + crate::i18n::menu_text(cx, "settings.logs.clearLogs") + .to_string(), + ) + .on_click(cx.listener(|_this, _event, _window, cx| { + // 日志清理尚未接入后端。 + cx.notify(); + })), + ), + ), + ), + ) + .child( + self.render_group( + crate::i18n::menu_text(cx, "settings.logs.diagnosticBundle").to_string(), + v_flex().w_full().gap_3().child( + self.render_row( + crate::i18n::menu_text(cx, "settings.logs.exportBundle").to_string(), + None, + Button::new("logs-export-bundle") .small() .ghost() - .label("清除日志") + .label(crate::i18n::menu_text( + cx, + "settings.logs.exportBundleConfirm", + )) .on_click(cx.listener(|_this, _event, _window, cx| { - // 日志清理尚未接入后端。 + // 诊断包导出尚未接入后端。 cx.notify(); })), - )), - ), - ) - .child( - self.render_group( - "诊断包", - v_flex().w_full().gap_3().child(self.render_row( - "导出诊断包…".to_string(), - None, - Button::new("logs-export-bundle") - .small() - .ghost() - .label("导出") - .on_click(cx.listener(|_this, _event, _window, cx| { - // 诊断包导出尚未接入后端。 - cx.notify(); - })), - )), + ), + ), ), ) } @@ -1644,25 +2048,34 @@ impl SettingsDialog { fn render_updates_content(&self, cx: &mut Context) -> impl IntoElement { v_flex().w_full().gap_4().child( self.render_group( - "软件更新", + crate::i18n::menu_text(cx, "settings.mac.softwareUpdate").to_string(), v_flex() .w_full() .gap_3() .child( self.render_row( "Lithe".to_string(), - Some(format!("当前版本:{}", env!("CARGO_PKG_VERSION"))), + Some( + crate::i18n::menu_text(cx, "settings.mac.currentVersion") + .to_string() + .replace("{version}", env!("CARGO_PKG_VERSION")), + ), Button::new("check-updates") .small() .primary() - .label("检查更新") + .label( + crate::i18n::menu_text(cx, "settings.mac.checkForUpdates") + .to_string(), + ) .on_click(cx.listener(|_this, _event, _window, cx| { // 更新检查尚未接入后端。 cx.notify(); })), ), ) - .child(self.render_note("Lithe 可以检查新的预览版和稳定版。")), + .child(self.render_note( + crate::i18n::menu_text(cx, "settings.mac.updateHint").to_string(), + )), ), ) } diff --git a/linux/src/workbench/sidebar.rs b/linux/src/workbench/sidebar.rs index 4de434712..630bfa696 100644 --- a/linux/src/workbench/sidebar.rs +++ b/linux/src/workbench/sidebar.rs @@ -401,8 +401,8 @@ impl Render for SidebarView { // 标题跟随语言:Explorer 用 workbench.project(项目/Project)。 let title = match self.active_tab { SidebarTab::Explorer => crate::i18n::menu_text(cx, "workbench.project").to_string(), - SidebarTab::Search => "SEARCH".to_string(), - SidebarTab::Git => "GIT".to_string(), + SidebarTab::Search => crate::i18n::menu_text(cx, "workbench.search").to_string(), + SidebarTab::Git => crate::i18n::menu_text(cx, "workbench.sourceControl").to_string(), }; // 过滤行在链式构建前算好,避免在 `.when` 闭包里同时借用 self 与 cx。 let filter_row = if self.show_tree_filter { @@ -506,7 +506,7 @@ impl Render for SidebarView { .small() .ghost() .icon(IconName::RotateCw) - .tooltip("Refresh") + .tooltip(crate::i18n::menu_text(cx, "ui.refresh")) .on_click(cx.listener(|this, _event, _window, cx| { this.refresh(cx); this.refresh_git(cx); @@ -847,7 +847,7 @@ impl SidebarView { .text_xs() .text_color(ThemeColors::text_muted()) .p_2() - .child("Loading workspace files..."), + .child(format!("{}...", crate::i18n::menu_text(cx, "ui.loading"))), ) }) .when_some(self.error_message.as_ref(), |this, err| { @@ -973,10 +973,9 @@ impl SidebarView { .text_color(ThemeColors::text_muted()), ) .child( - div() - .text_xs() - .text_color(ThemeColors::text_muted()) - .child("Search files or text..."), + div().text_xs().text_color(ThemeColors::text_muted()).child( + crate::i18n::menu_text(cx, "workbench.searchInFiles").to_string(), + ), ), ) .when(self.is_searching, |this| { @@ -985,7 +984,7 @@ impl SidebarView { .text_xs() .text_color(ThemeColors::text_muted()) .p_1() - .child("Searching..."), + .child(format!("{}...", crate::i18n::menu_text(cx, "ui.loading"))), ) }) .children(self.search_results.iter().enumerate().map(|(idx, res)| { @@ -1074,7 +1073,11 @@ impl SidebarView { div() .text_xs() .text_color(ThemeColors::text_muted()) - .child(format!("Changed Files ({})", self.git_changes.len())), + .child(format!( + "{} ({})", + crate::i18n::menu_text(cx, "workbench.changes"), + self.git_changes.len() + )), ) .children(self.git_changes.iter().enumerate().map(|(idx, change)| { let path = change.path.clone(); @@ -1160,7 +1163,8 @@ impl SidebarView { ThemeColors::text_primary() }) .child(if self.git_commit_message.is_empty() { - "Commit message (e.g. feat: update files)...".to_string() + crate::i18n::menu_text(cx, "git.commitMessagePlaceholder") + .to_string() } else { self.git_commit_message.clone() }), @@ -1170,18 +1174,19 @@ impl SidebarView { .w_full() .items_center() .justify_between() - .child( - div() - .text_xs() - .text_color(ThemeColors::text_muted()) - .child(format!("{} changed", self.git_changes.len())), - ) + .child(div().text_xs().text_color(ThemeColors::text_muted()).child( + format!( + "{} ({})", + crate::i18n::menu_text(cx, "workbench.changes"), + self.git_changes.len() + ), + )) .child( Button::new("git-commit-btn") .small() .primary() .icon(IconName::Check) - .label("Commit") + .label(crate::i18n::menu_text(cx, "git.commit")) .on_click(cx.listener(|this, _event, _window, cx| { let msg = if this.git_commit_message.trim().is_empty() { "feat: update workspace files".to_string() diff --git a/linux/src/workbench/status_bar.rs b/linux/src/workbench/status_bar.rs index 46cc6a94d..d75e0b3c2 100644 --- a/linux/src/workbench/status_bar.rs +++ b/linux/src/workbench/status_bar.rs @@ -126,15 +126,10 @@ impl StatusBarView { fn render_leading_item(&self, id: &str) -> Option { match id { "filePath" => { - let label = self - .current_file - .clone() - .unwrap_or_else(|| "No file active".to_string()); - Some(labeled( - IconName::FileText, - label, - ThemeColors::subtle_foreground(), - )) + // 无活动文件时整项不渲染,对齐 Tauri(filePath 为 null)。 + self.current_file.clone().map(|label| { + labeled(IconName::FileText, label, ThemeColors::subtle_foreground()) + }) } // 分支未知时整项不渲染,避免用占位名误导用户。 "branch" => self diff --git a/linux/src/workbench/terminal.rs b/linux/src/workbench/terminal.rs index bb3dca67c..2bcc79919 100644 --- a/linux/src/workbench/terminal.rs +++ b/linux/src/workbench/terminal.rs @@ -170,7 +170,7 @@ impl Render for TerminalView { Button::new("clear-term") .small() .ghost() - .label("Clear") + .label(crate::i18n::menu_text(cx, "ui.clear")) .on_click(cx.listener(|this, _event, _window, cx| { this.clear(cx); })), diff --git a/linux/src/workbench/toolbar.rs b/linux/src/workbench/toolbar.rs index 0a642d223..12e805ad5 100644 --- a/linux/src/workbench/toolbar.rs +++ b/linux/src/workbench/toolbar.rs @@ -509,7 +509,6 @@ impl Render for ToolbarView { .small() .ghost() .icon(IconName::List) - .tooltip("Menu") .selected(open) .on_click(cx.listener( |this, _event: &gpui_kit::ClickEvent, _window, cx| { @@ -694,7 +693,7 @@ impl Render for ToolbarView { .small() .ghost() .icon(IconName::Search) - .tooltip("Search") + .tooltip(crate::i18n::menu_text(cx, "menu.quickOpen")) .on_click(cx.listener(|_this, _event, _window, cx| { cx.emit(ToolbarEvent::QuickOpen); })), diff --git a/linux/src/workbench/welcome_screen.rs b/linux/src/workbench/welcome_screen.rs index 1c886080d..07704ce11 100644 --- a/linux/src/workbench/welcome_screen.rs +++ b/linux/src/workbench/welcome_screen.rs @@ -276,7 +276,8 @@ impl WelcomeScreenView { ThemeColors::foreground() }) .child(if self.query.is_empty() { - "Search projects".to_string() + crate::i18n::menu_text(cx, "welcome.searchProjects") + .to_string() } else { self.query.clone() }), @@ -328,7 +329,10 @@ impl WelcomeScreenView { .text_sm() .font_weight(FontWeight::MEDIUM) .text_color(ThemeColors::foreground()) - .child("No recent projects"), + .child(crate::i18n::menu_text( + cx, + "welcome.noRecentProjects", + )), ) .child( div() @@ -402,7 +406,16 @@ impl WelcomeScreenView { .xsmall() .ghost() .icon(IconName::Trash) - .tooltip("Remove from recent") + .tooltip( + crate::i18n::menu_text(cx, "welcome.removeRecent") + .replace( + "{name}", + remove_path + .rsplit('/') + .next() + .unwrap_or(&remove_path), + ), + ) .on_click(cx.listener( move |this, _event, _window, cx| { // 阻止冒泡到整行,避免删除时同时打开项目 From 183d4223547f5ff92556c5a955a47af901291f93 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Thu, 24 Sep 2026 17:36:27 +0800 Subject: [PATCH 018/109] =?UTF-8?q?feat(linux):=20=E6=96=B0=E5=BB=BA/?= =?UTF-8?q?=E6=89=93=E5=BC=80/=E5=85=8B=E9=9A=86=E4=BB=93=E5=BA=93?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=8E=A5=E5=85=A5=EF=BC=8C=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=20Tauri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对照 new-project-content.tsx / new-project-model.ts / project-picker.tsx: - 新增 ProjectDialog(遮罩+560x480 卡片):New 模式来源列表 (空项目/Next.js/Vite+React,搜索过滤)→详情表单(名称/位置/ 包管理器循环切换/目标预览/中文错误)→创建中;Clone 模式 (URL/名称自动推导/位置)→后台 git clone。 - 名称校验、URL 推导名、starter 命令字符串逐字移植 model.ts; 35 个 newProject.*/welcome.* 中英文案照抄 locale.ts。 - Cargo 新增 rfd 0.16:打开与选择父文件夹走原生目录框。 - view.rs:open_project_path 统一换根(sidebar/maven/关欢迎页); 工具栏与欢迎页的 New/Open/Clone 由占位改为真实链路; 模板项目打开后 starter 命令发往底部终端。 --- linux/Cargo.toml | 1 + linux/src/i18n.rs | 76 ++ linux/src/workbench/mod.rs | 3 + linux/src/workbench/project_dialog.rs | 1344 +++++++++++++++++++++++++ linux/src/workbench/view.rs | 116 ++- 5 files changed, 1513 insertions(+), 27 deletions(-) create mode 100644 linux/src/workbench/project_dialog.rs diff --git a/linux/Cargo.toml b/linux/Cargo.toml index b6aa6d589..dba77ba7f 100644 --- a/linux/Cargo.toml +++ b/linux/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] +rfd = "0.16" gpui-kit = "0.6" lithe-core = { path = "../rust/lithe-core" } portable-pty = "0.9" diff --git a/linux/src/i18n.rs b/linux/src/i18n.rs index caf6acd5e..ea3fbc7e1 100644 --- a/linux/src/i18n.rs +++ b/linux/src/i18n.rs @@ -508,6 +508,82 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { "Rules applied when generating commit messages", ), "settings.ai.defaultValue" => ("默认", "Default"), + // ---- 新建项目对话框(与 Tauri locale.ts newProject.* 逐字对齐) ---- + "newProject.backToStarters" => ("返回项目模板", "Back to project starters"), + "newProject.repositoryUrl" => ("仓库 URL", "Repository URL"), + "newProject.repositoryUrlDescription" => ( + "支持 HTTPS 和 SSH 仓库 URL。", + "HTTPS and SSH repository URLs are supported.", + ), + "newProject.projectName" => ("项目名称", "Project Name"), + "newProject.location" => ("位置", "Location"), + "newProject.chooseParentFolder" => ("选择父文件夹", "Choose a parent folder"), + "newProject.chooseProjectLocation" => ("选择项目位置", "Choose project location"), + "newProject.packageManager" => ("包管理器", "Package Manager"), + "newProject.packageManagerDescription" => ( + "安装会在集成终端中运行,方便你查看输出。", + "Setup runs in the integrated terminal so you can follow its output.", + ), + "newProject.projectLocation" => ("项目位置", "Project location"), + "newProject.starters" => ("模板", "Starters"), + "newProject.errorEnterName" => ("请输入项目名称。", "Enter a project name."), + "newProject.errorPathTraversalName" => ( + "项目名称不能是路径穿越片段。", + "Project names cannot be path traversal segments.", + ), + "newProject.errorReservedCharacters" => ( + "项目名称不能包含路径分隔符或保留字符。", + "Project names cannot contain path separators or reserved characters.", + ), + "newProject.errorTrailingPeriodOrSpace" => ( + "项目名称不能以句点或空格结尾。", + "Project names cannot end with a period or space.", + ), + "newProject.errorCrossPlatformName" => ( + "请选择所有平台都支持的项目名称。", + "Choose a project name that is supported on every platform.", + ), + "newProject.errorDestinationExists" => ( + "{path} 已存在文件或文件夹。", + "A file or folder already exists at {path}.", + ), + "newProject.errorCreatedButCouldNotOpen" => ( + "项目已创建,但 Lithe 无法打开它。", + "The project was created but Lithe could not open it.", + ), + "newProject.cloningRepository" => ("正在克隆仓库", "Cloning Repository"), + "newProject.creatingProject" => ("正在创建项目", "Creating Project"), + "newProject.cloningRepositoryStatus" => ("正在克隆仓库", "Cloning repository"), + "newProject.preparingProject" => ("正在准备项目", "Preparing project"), + // ---- 新建项目来源(与 Tauri locale.ts welcome.* 逐字对齐) ---- + "welcome.newProject" => ("新建项目", "New Project"), + "welcome.chooseHowToStart" => ("选择如何开始...", "Choose how to start..."), + "welcome.emptyProject" => ("空项目", "Empty Project"), + "welcome.emptyProjectDescription" => ( + "创建一个空白文件夹,从零开始。", + "Create a clean folder and start from scratch.", + ), + "welcome.builtIn" => ("内置", "Built-in"), + "welcome.webApp" => ("Web 应用", "Web app"), + "welcome.nextjsDescription" => ( + "包含 App Router、TypeScript、Tailwind CSS、ESLint 和 src 目录。", + "App Router, TypeScript, Tailwind CSS, ESLint, and a src directory.", + ), + "welcome.viteReactDescription" => ( + "轻量的 React 和 TypeScript 起步模板。", + "A lightweight React and TypeScript starter.", + ), + "welcome.cloneRepository" => ("克隆仓库", "Clone Repository"), + "welcome.cloneRepositoryDescription" => ( + "将已有 Git 仓库克隆到本地新项目。", + "Clone an existing Git repository into a new local project.", + ), + "welcome.gitBadge" => ("Git", "Git"), + "welcome.noStarters" => ( + "没有匹配 “{query}” 的项目模板。", + "No project starters match \"{query}\".", + ), + "welcome.backToProjects" => ("返回项目列表", "Back to projects"), _ => return "", }; if is_zh(cx) { diff --git a/linux/src/workbench/mod.rs b/linux/src/workbench/mod.rs index ec06815c9..b560769ab 100644 --- a/linux/src/workbench/mod.rs +++ b/linux/src/workbench/mod.rs @@ -3,6 +3,7 @@ pub mod bottom_panel; pub mod command_palette; pub mod editor; pub mod maven; +pub mod project_dialog; pub mod quick_open; pub mod search_everywhere; pub mod settings_dialog; @@ -26,6 +27,8 @@ pub use editor::EditorView; #[allow(unused_imports)] pub use maven::{MavenEvent, MavenView}; #[allow(unused_imports)] +pub use project_dialog::{ProjectDialog, ProjectDialogEvent, ProjectDialogMode}; +#[allow(unused_imports)] pub use quick_open::{QuickOpenEvent, QuickOpenModal}; #[allow(unused_imports)] pub use search_everywhere::{SearchEverywhereEvent, SearchEverywhereModal, SearchScope}; diff --git a/linux/src/workbench/project_dialog.rs b/linux/src/workbench/project_dialog.rs new file mode 100644 index 000000000..4512fc8b9 --- /dev/null +++ b/linux/src/workbench/project_dialog.rs @@ -0,0 +1,1344 @@ +//! 新建/克隆项目对话框:复刻 Tauri 端 `new-project-content.tsx` 的两步流程。 +//! +//! - New 模式:source 列表(搜索 + 3 个来源行)→ details 表单 → 创建。 +//! - Clone 模式:直接进 details(仓库 URL + 自动推导名称 + 位置)。 +//! - 名称校验、URL 推导名、starter 命令逐条移植自 +//! `windows/tauri/src/features/window/lib/new-project-model.ts`。 +//! - 文案键与 `windows/tauri/src/i18n/locale.ts` 的 `newProject.*` / +//! `welcome.*` 对齐,`{path}` / `{query}` 占位在渲染时替换。 +//! - 遮罩 + 卡片布局仿 `settings_dialog.rs`,行样式与键盘输入仿 +//! `sidebar.rs` / `quick_open.rs`(只读展示行 + `on_key_down` 追加)。 +//! - 执行:空项目同步 `create_dir_all`;模板项目同步建目录并把 starter +//! 命令随事件交给父级发往新终端;clone 在 +//! `cx.background_executor()` 后台跑 `git clone`,避免阻塞 UI。 + +use gpui_kit::assets::IconName; +use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::scroll::ScrollableElement as _; +use gpui_kit::component::{h_flex, v_flex, Disableable as _, Icon, Sizable as _}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::{ + div, px, rgba, AnyElement, Context, EventEmitter, FocusHandle, FontWeight, + InteractiveElement as _, IntoElement, KeyDownEvent, ParentElement as _, Render, + StatefulInteractiveElement as _, Styled as _, Window, +}; + +/// 对话框模式:新建项目向导,或直接克隆仓库。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProjectDialogMode { + New, + Clone, +} + +/// 向导步骤:来源选择 → 详情表单 → 创建中(Clone 模式跳过来源选择)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProjectDialogStep { + Source, + Details, + Creating, +} + +/// 新建来源(clone 走 [`ProjectDialogMode::Clone`],不在 New 列表中)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NewProjectSource { + Empty, + Nextjs, + ViteReact, +} + +/// 包管理器:详情页循环切换按钮在三者之间轮换。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PackageManager { + Npm, + Pnpm, + Bun, +} + +impl PackageManager { + /// 按钮展示文本,与 Tauri `packageManagerOptions` 的 label 一致。 + fn label(self) -> &'static str { + match self { + PackageManager::Npm => "npm", + PackageManager::Pnpm => "pnpm", + PackageManager::Bun => "Bun", + } + } +} + +/// 当前接收键盘输入的字段(对话框内只有一个焦点句柄,按点击行切换)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ActiveField { + Search, + ProjectName, + RepositoryUrl, + Location, +} + +/// 对话框对外事件。 +#[derive(Debug, Clone)] +pub enum ProjectDialogEvent { + /// 项目已就绪:`path` 为新项目目录;模板项目附带 starter 命令, + /// 父级打开目录后应把该命令原样发往新终端。 + OpenedProject { + path: String, + starter: Option, + }, + /// 请求关闭对话框。 + Close, +} + +/// Windows 保留名(不含扩展名的首段命中即跨平台不合法)。 +const WINDOWS_RESERVED_NAMES: &[&str] = &[ + "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", + "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9", +]; + +/// 文件名保留字符(含路径分隔符)。 +const RESERVED_CHARS: [char; 9] = ['<', '>', ':', '"', '/', '\\', '|', '?', '*']; + +/// 居中新建项目面板(宽 560px,高 480px)。 +pub struct ProjectDialog { + mode: ProjectDialogMode, + step: ProjectDialogStep, + source: NewProjectSource, + /// 来源搜索串。 + query: String, + selected_index: usize, + project_name: String, + repository_url: String, + location_path: String, + package_manager: PackageManager, + /// 名称是否被用户亲手改过;未改过时 URL 变化会自动推导名称。 + name_was_edited: bool, + error_message: Option, + active_field: ActiveField, + focus_handle: FocusHandle, +} + +impl EventEmitter for ProjectDialog {} + +impl ProjectDialog { + /// 新建对话框:位置默认取 `$HOME`(不引入新依赖,不读真实 home 库)。 + pub fn new(cx: &mut Context) -> Self { + Self { + mode: ProjectDialogMode::New, + step: ProjectDialogStep::Source, + source: NewProjectSource::Empty, + query: String::new(), + selected_index: 0, + project_name: String::new(), + repository_url: String::new(), + location_path: std::env::var("HOME").unwrap_or_default(), + package_manager: PackageManager::Npm, + name_was_edited: false, + error_message: None, + active_field: ActiveField::Search, + focus_handle: cx.focus_handle(), + } + } + + /// 切换模式并重置整个表单(New 进来源选择,Clone 直接进详情)。 + pub fn set_mode(&mut self, mode: ProjectDialogMode, cx: &mut Context) { + self.mode = mode; + self.step = match mode { + ProjectDialogMode::New => ProjectDialogStep::Source, + ProjectDialogMode::Clone => ProjectDialogStep::Details, + }; + self.source = NewProjectSource::Empty; + self.query.clear(); + self.selected_index = 0; + self.project_name.clear(); + self.repository_url.clear(); + self.location_path = std::env::var("HOME").unwrap_or_default(); + self.package_manager = PackageManager::Npm; + self.name_was_edited = false; + self.error_message = None; + self.active_field = match mode { + ProjectDialogMode::New => ActiveField::Search, + ProjectDialogMode::Clone => ActiveField::RepositoryUrl, + }; + cx.notify(); + } + + /// 系统目录选择框;取消/失败(含无 zenity 等后端缺失)返回 `None`, + /// 调用方一律视为取消。 + pub fn pick_folder(initial: Option<&str>) -> Option { + let mut dialog = rfd::FileDialog::new(); + if let Some(dir) = initial.map(str::trim).filter(|s| !s.is_empty()) { + dialog = dialog.set_directory(dir); + } + dialog + .pick_folder() + .map(|path| path.to_string_lossy().to_string()) + } + + /// 移植 `getProjectNameError`:返回 i18n 键,渲染时再解析为文案。 + fn name_error_key(&self) -> Option<&'static str> { + let name = self.project_name.trim(); + if name.is_empty() { + return Some("newProject.errorEnterName"); + } + if name == "." || name == ".." { + return Some("newProject.errorPathTraversalName"); + } + if name + .chars() + .any(|c| (c as u32) < 32 || RESERVED_CHARS.contains(&c)) + { + return Some("newProject.errorReservedCharacters"); + } + if name.ends_with('.') || name.ends_with(' ') { + return Some("newProject.errorTrailingPeriodOrSpace"); + } + let base = name.split('.').next().unwrap_or("").to_lowercase(); + if WINDOWS_RESERVED_NAMES.contains(&base.as_str()) { + return Some("newProject.errorCrossPlatformName"); + } + None + } + + /// 移植 `inferProjectNameFromRepositoryUrl`:去尾斜杠、取末段、去 `.git` + /// (大小写不敏感)。无 URL 解码依赖,跳过 `decodeURIComponent`。 + fn infer_name_from_url(url: &str) -> String { + let trimmed = url.trim().trim_end_matches(['/', '\\']); + if trimmed.is_empty() { + return String::new(); + } + let last = trimmed + .rsplit(|c| c == '/' || c == ':' || c == '\\') + .find(|segment| !segment.is_empty()) + .unwrap_or(""); + if last.len() >= 4 && last[last.len() - 4..].eq_ignore_ascii_case(".git") { + last[..last.len() - 4].to_string() + } else { + last.to_string() + } + } + + /// 移植 `getStarterCommand`:命令字符串与 `new-project-model.ts` 逐字一致。 + fn starter_command(source: NewProjectSource, pm: PackageManager) -> Option { + match source { + NewProjectSource::Empty => None, + NewProjectSource::Nextjs => { + let runner = match pm { + PackageManager::Npm => "npx --yes", + PackageManager::Pnpm => "pnpm dlx", + PackageManager::Bun => "bunx", + }; + let flag = match pm { + PackageManager::Npm => "--use-npm", + PackageManager::Pnpm => "--use-pnpm", + PackageManager::Bun => "--use-bun", + }; + Some(format!( + "{runner} create-next-app@latest . --typescript --tailwind --eslint --app --src-dir --import-alias \"@/*\" {flag}" + )) + } + NewProjectSource::ViteReact => { + let create = match pm { + PackageManager::Npm => "npm create vite@latest . -- --template react-ts", + PackageManager::Pnpm => "pnpm create vite@latest . --template react-ts", + PackageManager::Bun => "bun create vite@latest . --template react-ts", + }; + let install = match pm { + PackageManager::Npm => "npm install", + PackageManager::Pnpm => "pnpm install", + PackageManager::Bun => "bun install", + }; + Some(format!("{create} && {install}")) + } + } + } + + /// 目标目录 = 位置 + 名称(对齐 `getNewProjectPath` 的拼接语义)。 + fn destination_path(&self) -> String { + let base = self.location_path.trim().trim_end_matches('/'); + let name = self.project_name.trim(); + if base.is_empty() { + return name.to_string(); + } + format!("{base}/{name}") + } + + /// 是否允许创建(对齐 Tauri `canCreate`)。 + fn can_create(&self) -> bool { + if self.step != ProjectDialogStep::Details { + return false; + } + if self.name_error_key().is_some() { + return false; + } + if self.location_path.trim().is_empty() { + return false; + } + if self.mode == ProjectDialogMode::Clone && self.repository_url.trim().is_empty() { + return false; + } + true + } + + /// 选中来源并进入详情表单。 + fn choose_source(&mut self, source: NewProjectSource, cx: &mut Context) { + self.source = source; + self.step = ProjectDialogStep::Details; + self.project_name.clear(); + self.repository_url.clear(); + self.name_was_edited = false; + self.error_message = None; + self.active_field = ActiveField::ProjectName; + cx.notify(); + } + + /// 从详情返回来源选择。 + fn back_to_source(&mut self, cx: &mut Context) { + self.step = ProjectDialogStep::Source; + self.query.clear(); + self.selected_index = 0; + self.error_message = None; + self.active_field = ActiveField::Search; + cx.notify(); + } + + /// 包管理器循环切换:npm → pnpm → Bun → npm。 + fn cycle_package_manager(&mut self, cx: &mut Context) { + self.package_manager = match self.package_manager { + PackageManager::Npm => PackageManager::Pnpm, + PackageManager::Pnpm => PackageManager::Bun, + PackageManager::Bun => PackageManager::Npm, + }; + cx.notify(); + } + + /// 用系统对话框重选父目录;取消则保持原值。 + fn choose_location(&mut self, cx: &mut Context) { + if let Some(dir) = Self::pick_folder(Some(self.location_path.trim())) { + self.location_path = dir; + self.error_message = None; + cx.notify(); + } + } + + /// 往当前输入字段追加文本(URL 变化时自动推导名称)。 + fn push_text(&mut self, text: &str) { + match self.active_field { + ActiveField::Search => { + self.query.push_str(text); + self.selected_index = 0; + } + ActiveField::ProjectName => { + self.project_name.push_str(text); + self.name_was_edited = true; + self.error_message = None; + } + ActiveField::RepositoryUrl => { + self.repository_url.push_str(text); + self.error_message = None; + if !self.name_was_edited { + self.project_name = Self::infer_name_from_url(&self.repository_url); + } + } + ActiveField::Location => { + self.location_path.push_str(text); + self.error_message = None; + } + } + } + + /// 当前输入字段退格(URL 退格同样触发名称重推导)。 + fn pop_text(&mut self) { + match self.active_field { + ActiveField::Search => { + self.query.pop(); + self.selected_index = 0; + } + ActiveField::ProjectName => { + self.project_name.pop(); + self.error_message = None; + } + ActiveField::RepositoryUrl => { + self.repository_url.pop(); + self.error_message = None; + if !self.name_was_edited { + self.project_name = Self::infer_name_from_url(&self.repository_url); + } + } + ActiveField::Location => { + self.location_path.pop(); + self.error_message = None; + } + } + } + + /// 执行创建: + /// - Empty / 模板:目标存在即报错,否则同步建目录后发射事件 + /// (模板附带 starter 命令,父级负责发往新终端); + /// - Clone:先进 Creating,再后台跑 `git clone`,成功发射事件, + /// 失败回 Details 并显示 stderr。 + fn begin_create(&mut self, cx: &mut Context) { + if !self.can_create() { + return; + } + let dest = self.destination_path(); + if std::path::Path::new(&dest).exists() { + let template = crate::i18n::menu_text(cx, "newProject.errorDestinationExists"); + self.error_message = Some(template.replace("{path}", &dest)); + cx.notify(); + return; + } + + if self.mode == ProjectDialogMode::Clone { + self.step = ProjectDialogStep::Creating; + self.error_message = None; + cx.notify(); + let url = self.repository_url.trim().to_string(); + let dest_for_clone = dest.clone(); + cx.spawn(async move |this, cx| { + let output = cx + .background_executor() + .spawn(async move { + std::process::Command::new("git") + .args(["clone", url.as_str(), dest_for_clone.as_str()]) + .output() + }) + .await; + match output { + Ok(result) if result.status.success() => { + let _ = this.update(cx, |_this, cx| { + cx.emit(ProjectDialogEvent::OpenedProject { + path: dest, + starter: None, + }); + }); + } + Ok(result) => { + let stderr = String::from_utf8_lossy(&result.stderr).trim().to_string(); + let message = if stderr.is_empty() { + format!("git clone failed: {}", result.status) + } else { + stderr + }; + let _ = this.update(cx, |this, cx| { + this.step = ProjectDialogStep::Details; + this.error_message = Some(message); + cx.notify(); + }); + } + Err(err) => { + let message = format!("git clone failed: {err}"); + let _ = this.update(cx, |this, cx| { + this.step = ProjectDialogStep::Details; + this.error_message = Some(message); + cx.notify(); + }); + } + } + }) + .detach(); + return; + } + + match std::fs::create_dir_all(&dest) { + Ok(()) => { + let starter = Self::starter_command(self.source, self.package_manager); + cx.emit(ProjectDialogEvent::OpenedProject { + path: dest, + starter, + }); + } + Err(err) => { + self.error_message = Some(err.to_string()); + cx.notify(); + } + } + } + + /// New 模式来源选择项(Clone 不在列表中)。 + fn filtered_ids(&self, cx: &gpui_kit::App) -> Vec { + let query = self.query.trim().to_lowercase(); + const ALL: [NewProjectSource; 3] = [ + NewProjectSource::Empty, + NewProjectSource::Nextjs, + NewProjectSource::ViteReact, + ]; + if query.is_empty() { + return ALL.to_vec(); + } + ALL.into_iter() + .filter(|source| { + let label = source.label(cx).to_lowercase(); + let desc = crate::i18n::menu_text(cx, source.description_key()).to_lowercase(); + label.contains(query.as_str()) + || desc.contains(query.as_str()) + || source + .keywords() + .iter() + .any(|kw| kw.contains(query.as_str())) + }) + .collect() + } +} + +impl NewProjectSource { + /// 来源图标。 + fn icon(self) -> IconName { + match self { + NewProjectSource::Empty => IconName::Folder, + NewProjectSource::Nextjs => IconName::Package, + NewProjectSource::ViteReact => IconName::Code, + } + } + + /// 来源标题:Empty 走 i18n,模板用固定英文产品名。 + fn label(self, cx: &gpui_kit::App) -> String { + match self { + NewProjectSource::Empty => { + crate::i18n::menu_text(cx, "welcome.emptyProject").to_string() + } + NewProjectSource::Nextjs => "Next.js".to_string(), + NewProjectSource::ViteReact => "Vite + React".to_string(), + } + } + + /// 来源描述 i18n 键。 + fn description_key(self) -> &'static str { + match self { + NewProjectSource::Empty => "welcome.emptyProjectDescription", + NewProjectSource::Nextjs => "welcome.nextjsDescription", + NewProjectSource::ViteReact => "welcome.viteReactDescription", + } + } + + /// 来源徽标 i18n 键。 + fn badge_key(self) -> &'static str { + match self { + NewProjectSource::Empty => "welcome.builtIn", + NewProjectSource::Nextjs | NewProjectSource::ViteReact => "welcome.webApp", + } + } + + /// 搜索关键词(与 Tauri `keywords` 一致)。 + fn keywords(self) -> &'static [&'static str] { + match self { + NewProjectSource::Empty => &["blank", "folder", "local", "empty"], + NewProjectSource::Nextjs => &["react", "typescript", "tailwind", "frontend"], + NewProjectSource::ViteReact => &["react", "typescript", "frontend"], + } + } +} + +impl Render for ProjectDialog { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // 请求聚焦以接收按键输入(与 quick_open 相同)。 + window.focus(&self.focus_handle, cx); + + let is_creating = self.step == ProjectDialogStep::Creating; + let is_clone = self.mode == ProjectDialogMode::Clone; + + // 全屏半透明遮罩:创建中禁用关闭,点击空白处关闭。 + div() + .id("project-dialog-backdrop") + .track_focus(&self.focus_handle) + .absolute() + .inset_0() + .bg(rgba(0x00000088)) + .flex() + .items_center() + .justify_center() + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { + if this.step == ProjectDialogStep::Creating { + return; + } + let key = event.keystroke.key.as_str(); + match key { + "escape" => match (this.mode, this.step) { + (ProjectDialogMode::New, ProjectDialogStep::Details) => { + this.back_to_source(cx); + } + _ => cx.emit(ProjectDialogEvent::Close), + }, + "enter" => match this.step { + ProjectDialogStep::Source => { + let ids = this.filtered_ids(cx); + let index = this.selected_index.min(ids.len().saturating_sub(1)); + if let Some(id) = ids.get(index).copied() { + this.choose_source(id, cx); + } + } + ProjectDialogStep::Details => this.begin_create(cx), + ProjectDialogStep::Creating => {} + }, + "up" | "arrowup" => { + if this.step == ProjectDialogStep::Source { + let total = this.filtered_ids(cx).len(); + if total > 0 { + this.selected_index = (this.selected_index + total - 1) % total; + cx.notify(); + } + } + } + "down" | "arrowdown" => { + if this.step == ProjectDialogStep::Source { + let total = this.filtered_ids(cx).len(); + if total > 0 { + this.selected_index = (this.selected_index + 1) % total; + cx.notify(); + } + } + } + "backspace" => { + // 来源选择页的退格改搜索串,其余页改当前字段。 + if this.step == ProjectDialogStep::Source { + this.active_field = ActiveField::Search; + } + this.pop_text(); + cx.notify(); + } + "space" => { + if this.step == ProjectDialogStep::Source { + this.active_field = ActiveField::Search; + } + this.push_text(" "); + cx.notify(); + } + _ => { + // 无修饰键时把可打印字符追加到当前字段。 + if !event.keystroke.modifiers.control + && !event.keystroke.modifiers.alt + && !event.keystroke.modifiers.platform + { + let mut changed = false; + if let Some(ch) = &event.keystroke.key_char { + if this.step == ProjectDialogStep::Source { + this.active_field = ActiveField::Search; + } + this.push_text(ch); + changed = true; + } else if key.chars().count() == 1 { + if this.step == ProjectDialogStep::Source { + this.active_field = ActiveField::Search; + } + this.push_text(key); + changed = true; + } + if changed { + cx.notify(); + } + } + } + } + })) + .on_mouse_down( + gpui_kit::MouseButton::Left, + cx.listener(|this, _event, _window, cx| { + if this.step != ProjectDialogStep::Creating { + cx.emit(ProjectDialogEvent::Close); + } + }), + ) + .child( + v_flex() + .id("project-dialog-card") + .w(px(560.0)) + .h(px(480.0)) + .bg(crate::theme::ThemeColors::surface()) + .border_1() + .border_color(crate::theme::ThemeColors::border()) + .rounded_lg() + .shadow_lg() + .overflow_hidden() + .on_mouse_down( + gpui_kit::MouseButton::Left, + cx.listener(|_this, _event, _window, cx| { + // 卡片内点击不冒泡到遮罩,避免误关闭。 + cx.stop_propagation(); + }), + ) + .child(self.render_header(is_clone, is_creating, cx)) + .child(match self.step { + ProjectDialogStep::Source => self.render_source_body(cx).into_any_element(), + ProjectDialogStep::Details => { + self.render_details_body(is_clone, cx).into_any_element() + } + ProjectDialogStep::Creating => { + self.render_creating_body(is_clone, cx).into_any_element() + } + }) + .when(self.step == ProjectDialogStep::Details, |card| { + card.child(self.render_footer(is_clone, cx)) + }), + ) + } +} + +/// 头部 / 各步骤主体 / 底部渲染。 +impl ProjectDialog { + /// 头部:返回按钮 + 图标标题 + 关闭按钮(创建中隐藏可关闭项)。 + fn render_header( + &self, + is_clone: bool, + is_creating: bool, + cx: &mut Context, + ) -> impl IntoElement { + let (icon, title) = if is_creating { + ( + if is_clone { + IconName::GitBranch + } else { + self.source.icon() + }, + crate::i18n::menu_text( + cx, + if is_clone { + "newProject.cloningRepository" + } else { + "newProject.creatingProject" + }, + ) + .to_string(), + ) + } else { + match (self.mode, self.step) { + (ProjectDialogMode::New, ProjectDialogStep::Source) => ( + IconName::Folder, + crate::i18n::menu_text(cx, "welcome.newProject").to_string(), + ), + (_, ProjectDialogStep::Details) if is_clone => ( + IconName::GitBranch, + crate::i18n::menu_text(cx, "welcome.cloneRepository").to_string(), + ), + _ => (self.source.icon(), self.source.label(cx)), + } + }; + // 返回行为:来源页/Clone 详情关闭对话框,New 详情返回来源页。 + let (back_tooltip, back_to_source) = match (self.mode, self.step) { + (ProjectDialogMode::New, ProjectDialogStep::Details) => ( + crate::i18n::menu_text(cx, "newProject.backToStarters").to_string(), + true, + ), + _ => ( + crate::i18n::menu_text(cx, "welcome.backToProjects").to_string(), + false, + ), + }; + + h_flex() + .h(px(44.0)) + .w_full() + .items_center() + .gap_2() + .px_3() + .border_b_1() + .border_color(crate::theme::ThemeColors::border()) + .bg(crate::theme::ThemeColors::surface()) + .when(!is_creating, |header| { + header.child( + Button::new("project-dialog-header-back") + .small() + .ghost() + .icon(IconName::ArrowLeft) + .tooltip(back_tooltip) + .on_click(cx.listener(move |this, _event, _window, cx| { + if back_to_source { + this.back_to_source(cx); + } else { + cx.emit(ProjectDialogEvent::Close); + } + })), + ) + }) + .child( + Icon::new(icon) + .size(px(16.0)) + .text_color(crate::theme::ThemeColors::primary()), + ) + .child( + div() + .flex_1() + .truncate() + .text_sm() + .font_weight(FontWeight::BOLD) + .text_color(crate::theme::ThemeColors::foreground()) + .child(title), + ) + .when(!is_creating, |header| { + header.child( + Button::new("project-dialog-close") + .small() + .ghost() + .icon(IconName::Close) + .tooltip(crate::i18n::menu_text(cx, "ui.close")) + .on_click(cx.listener(|_this, _event, _window, cx| { + cx.emit(ProjectDialogEvent::Close); + })), + ) + }) + } + + /// 来源选择主体:搜索行 + 来源行列表(空态显示 `noStarters`)。 + fn render_source_body(&self, cx: &mut Context) -> impl IntoElement { + let ids = self.filtered_ids(cx); + let current = self.selected_index.min(ids.len().saturating_sub(1)); + let query = self.query.clone(); + let placeholder = crate::i18n::menu_text(cx, "welcome.chooseHowToStart").to_string(); + + v_flex() + .flex_1() + .w_full() + .min_h_0() + .overflow_hidden() + .child( + h_flex() + .id("project-dialog-search") + .h(px(44.0)) + .w_full() + .flex_shrink_0() + .items_center() + .gap_2p5() + .px_4() + .border_b_1() + .border_color(crate::theme::ThemeColors::border()) + .cursor_pointer() + .child( + Icon::new(IconName::Search) + .size(px(15.0)) + .text_color(crate::theme::ThemeColors::primary()), + ) + .child( + h_flex() + .flex_1() + .items_center() + .gap_1() + .child( + div() + .text_sm() + .text_color(if query.is_empty() { + crate::theme::ThemeColors::subtle_foreground() + } else { + crate::theme::ThemeColors::foreground() + }) + .child(if query.is_empty() { + placeholder + } else { + query.clone() + }), + ) + .child( + div() + .w(px(2.0)) + .h(px(16.0)) + .bg(crate::theme::ThemeColors::primary()), + ), + ) + .on_click(cx.listener(|this, _event, window, cx| { + this.active_field = ActiveField::Search; + window.focus(&this.focus_handle, cx); + cx.notify(); + })), + ) + .child( + div() + .flex_1() + .w_full() + .overflow_y_scrollbar() + .py_1() + .when(ids.is_empty(), |list| { + let empty = crate::i18n::menu_text(cx, "welcome.noStarters") + .replace("{query}", &self.query); + list.child( + div() + .w_full() + .py_8() + .text_center() + .text_sm() + .text_color(crate::theme::ThemeColors::subtle_foreground()) + .child(empty), + ) + }) + .children(ids.into_iter().enumerate().map(|(position, id)| { + let is_selected = position == current; + let label = id.label(cx); + let description = + crate::i18n::menu_text(cx, id.description_key()).to_string(); + let badge = crate::i18n::menu_text(cx, id.badge_key()).to_string(); + let icon = id.icon(); + h_flex() + .id(("project-dialog-source", position)) + .h(px(52.0)) + .w_full() + .mx_2() + .px_2p5() + .items_center() + .gap_2p5() + .rounded_md() + .cursor_pointer() + .when(is_selected, |row| { + row.bg(crate::theme::ThemeColors::selected()) + }) + .when(!is_selected, |row| { + row.hover(|h| h.bg(crate::theme::ThemeColors::accent())) + }) + .child(Icon::new(icon).size(px(16.0)).text_color(if is_selected { + crate::theme::ThemeColors::primary() + } else { + crate::theme::ThemeColors::muted_foreground() + })) + .child( + v_flex() + .flex_1() + .min_w_0() + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(crate::theme::ThemeColors::foreground()) + .child(label), + ) + .child( + div() + .truncate() + .text_xs() + .text_color( + crate::theme::ThemeColors::subtle_foreground(), + ) + .child(description), + ), + ) + .child(source_badge(badge)) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.choose_source(id, cx); + })) + .into_any_element() + })), + ) + } + + /// 详情表单主体:URL(仅 Clone)+ 名称 + 位置 + 包管理器 + 目标预览 + 错误。 + fn render_details_body(&self, is_clone: bool, cx: &mut Context) -> impl IntoElement { + let name_error = self + .name_error_key() + .filter(|_| !self.project_name.is_empty()) + .map(|key| crate::i18n::menu_text(cx, key).to_string()); + let dest = self.destination_path(); + let show_dest = !self.location_path.trim().is_empty(); + let error = self.error_message.clone(); + let is_template = !is_clone && !matches!(self.source, NewProjectSource::Empty); + + div() + .flex_1() + .w_full() + .min_h_0() + .overflow_y_scrollbar() + .child( + v_flex() + .w_full() + .gap_3() + .p_4() + .when(is_clone, |body| { + body.child( + self.render_text_field( + "project-dialog-field-url", + crate::i18n::menu_text(cx, "newProject.repositoryUrl").to_string(), + self.repository_url.clone(), + "https://github.com/owner/repository.git".to_string(), + Some( + crate::i18n::menu_text( + cx, + "newProject.repositoryUrlDescription", + ) + .to_string(), + ), + None, + ActiveField::RepositoryUrl, + cx, + ), + ) + }) + .child(self.render_text_field( + "project-dialog-field-name", + crate::i18n::menu_text(cx, "newProject.projectName").to_string(), + self.project_name.clone(), + if is_clone { + "repository".to_string() + } else { + "my-project".to_string() + }, + None, + name_error, + ActiveField::ProjectName, + cx, + )) + .child(self.render_location_row(cx)) + .when(is_template, |body| { + body.child(self.render_package_manager_row(cx)) + }) + .when(show_dest, |body| { + body.child( + v_flex() + .w_full() + .rounded_md() + .border_1() + .border_color(crate::theme::ThemeColors::border()) + .bg(crate::theme::ThemeColors::surface()) + .overflow_hidden() + .child( + div() + .px_3() + .py_2() + .border_b_1() + .border_color(crate::theme::ThemeColors::border()) + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(crate::theme::ThemeColors::subtle_foreground()) + .child( + crate::i18n::menu_text( + cx, + "newProject.projectLocation", + ) + .to_string(), + ), + ) + .child( + div() + .px_3() + .py_2() + .font_family("monospace") + .text_xs() + .text_color(crate::theme::ThemeColors::foreground()) + .child(dest), + ), + ) + }) + .when_some(error, |body, message| { + body.child( + div() + .text_xs() + .text_color(crate::theme::ThemeColors::accent_red()) + .child(message), + ) + }), + ) + } + + /// 单行文本字段:标签 + 只读展示行(点击聚焦)+ 描述/错误。 + #[allow(clippy::too_many_arguments)] + fn render_text_field( + &self, + id: &'static str, + label: String, + value: String, + placeholder: String, + description: Option, + error: Option, + field: ActiveField, + cx: &mut Context, + ) -> impl IntoElement { + let is_active = self.active_field == field; + v_flex() + .w_full() + .gap_1p5() + .child( + div() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(crate::theme::ThemeColors::foreground()) + .child(label), + ) + .child( + h_flex() + .id(id) + .min_h(px(32.0)) + .w_full() + .items_center() + .gap_1() + .px_2p5() + .py_1p5() + .rounded_sm() + .border_1() + .cursor_pointer() + .when(is_active, |row| { + row.border_color(crate::theme::ThemeColors::primary()) + .bg(crate::theme::ThemeColors::background()) + }) + .when(!is_active, |row| { + row.border_color(crate::theme::ThemeColors::border()) + .bg(crate::theme::ThemeColors::background()) + .hover(|h| { + h.border_color(crate::theme::ThemeColors::muted_foreground()) + }) + }) + .child( + div() + .flex_1() + .truncate() + .text_sm() + .text_color(if value.is_empty() { + crate::theme::ThemeColors::subtle_foreground() + } else { + crate::theme::ThemeColors::foreground() + }) + .child(if value.is_empty() { placeholder } else { value }), + ) + .when(is_active, |row| { + row.child( + div() + .w(px(2.0)) + .h(px(16.0)) + .bg(crate::theme::ThemeColors::primary()), + ) + }) + .on_click(cx.listener(move |this, _event, window, cx| { + this.active_field = field; + window.focus(&this.focus_handle, cx); + cx.notify(); + })), + ) + .when_some(description, |this, desc| { + this.child( + div() + .text_xs() + .text_color(crate::theme::ThemeColors::subtle_foreground()) + .child(desc), + ) + }) + .when_some(error, |this, message| { + this.child( + div() + .text_xs() + .text_color(crate::theme::ThemeColors::accent_red()) + .child(message), + ) + }) + } + + /// 位置行:路径展示 + 选择父目录按钮。 + fn render_location_row(&self, cx: &mut Context) -> impl IntoElement { + let is_active = self.active_field == ActiveField::Location; + let value = self.location_path.clone(); + let placeholder = crate::i18n::menu_text(cx, "newProject.chooseParentFolder").to_string(); + v_flex() + .w_full() + .gap_1p5() + .child( + div() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(crate::theme::ThemeColors::foreground()) + .child(crate::i18n::menu_text(cx, "newProject.location").to_string()), + ) + .child( + h_flex() + .w_full() + .items_center() + .gap_2() + .child( + h_flex() + .id("project-dialog-field-location") + .flex_1() + .min_w_0() + .min_h(px(32.0)) + .items_center() + .px_2p5() + .py_1p5() + .rounded_sm() + .border_1() + .cursor_pointer() + .font_family("monospace") + .when(is_active, |row| { + row.border_color(crate::theme::ThemeColors::primary()) + .bg(crate::theme::ThemeColors::background()) + }) + .when(!is_active, |row| { + row.border_color(crate::theme::ThemeColors::border()) + .bg(crate::theme::ThemeColors::background()) + .hover(|h| { + h.border_color(crate::theme::ThemeColors::muted_foreground()) + }) + }) + .child( + div() + .flex_1() + .truncate() + .text_sm() + .text_color(if value.is_empty() { + crate::theme::ThemeColors::subtle_foreground() + } else { + crate::theme::ThemeColors::foreground() + }) + .child(if value.is_empty() { placeholder } else { value }), + ) + .on_click(cx.listener(|this, _event, window, cx| { + this.active_field = ActiveField::Location; + window.focus(&this.focus_handle, cx); + cx.notify(); + })), + ) + .child( + Button::new("project-dialog-browse") + .small() + .ghost() + .icon(IconName::FolderOpen) + .label(crate::i18n::menu_text(cx, "newProject.chooseParentFolder")) + .tooltip( + crate::i18n::menu_text(cx, "newProject.chooseProjectLocation") + .to_string(), + ) + .on_click(cx.listener(|this, _event, _window, cx| { + this.choose_location(cx); + })), + ), + ) + } + + /// 包管理器行:标签 + 描述 + 循环切换按钮(仅模板来源)。 + fn render_package_manager_row(&self, cx: &mut Context) -> impl IntoElement { + v_flex() + .w_full() + .gap_1p5() + .child( + div() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(crate::theme::ThemeColors::foreground()) + .child(crate::i18n::menu_text(cx, "newProject.packageManager").to_string()), + ) + .child( + h_flex() + .w_full() + .min_h(px(32.0)) + .items_center() + .justify_between() + .gap_2() + .child( + div() + .flex_1() + .text_xs() + .text_color(crate::theme::ThemeColors::subtle_foreground()) + .child( + crate::i18n::menu_text(cx, "newProject.packageManagerDescription") + .to_string(), + ), + ) + .child( + Button::new("project-dialog-pm") + .small() + .ghost() + .label(self.package_manager.label()) + .tooltip( + crate::i18n::menu_text(cx, "newProject.packageManager").to_string(), + ) + .on_click(cx.listener(|this, _event, _window, cx| { + this.cycle_package_manager(cx); + })), + ), + ) + } + + /// 创建中主体:进度文案 + 目标路径(禁用关闭)。 + fn render_creating_body(&self, is_clone: bool, cx: &mut Context) -> impl IntoElement { + let status = crate::i18n::menu_text( + cx, + if is_clone { + "newProject.cloningRepositoryStatus" + } else { + "newProject.preparingProject" + }, + ) + .to_string(); + v_flex() + .flex_1() + .w_full() + .items_center() + .justify_center() + .gap_2() + .p_6() + .child( + div() + .text_sm() + .text_color(crate::theme::ThemeColors::foreground()) + .child(format!("{status}...")), + ) + .child( + div() + .w_full() + .text_center() + .truncate() + .font_family("monospace") + .text_xs() + .text_color(crate::theme::ThemeColors::subtle_foreground()) + .child(self.destination_path()), + ) + } + + /// 底部:返回 + 创建/克隆按钮(Clone 模式无返回)。 + fn render_footer(&self, is_clone: bool, cx: &mut Context) -> impl IntoElement { + let can_create = self.can_create(); + let create_label = if is_clone { + crate::i18n::menu_text(cx, "welcome.cloneRepository").to_string() + } else { + match self.source { + NewProjectSource::Empty => { + crate::i18n::menu_text(cx, "welcome.emptyProject").to_string() + } + _ => crate::i18n::menu_text(cx, "welcome.newProject").to_string(), + } + }; + let create_icon = if is_clone { + IconName::GitBranch + } else { + match self.source { + NewProjectSource::Empty => IconName::FilePlus, + _ => IconName::Package, + } + }; + + h_flex() + .h(px(44.0)) + .w_full() + .flex_shrink_0() + .items_center() + .gap_2() + .px_3() + .border_t_1() + .border_color(crate::theme::ThemeColors::border()) + .bg(crate::theme::ThemeColors::surface()) + .when(!is_clone, |footer| { + footer.child( + Button::new("project-dialog-back") + .small() + .ghost() + .icon(IconName::ArrowLeft) + .label(crate::i18n::menu_text(cx, "newProject.backToStarters")) + .on_click(cx.listener(|this, _event, _window, cx| { + this.back_to_source(cx); + })), + ) + }) + .child(div().flex_1()) + .child( + Button::new("project-dialog-create") + .small() + .primary() + .icon(create_icon) + .label(create_label) + .disabled(!can_create) + .on_click(cx.listener(|this, _event, _window, cx| { + this.begin_create(cx); + })), + ) + } +} + +/// 来源徽标(对齐 quick_open 的徽标样式)。 +fn source_badge(label: String) -> AnyElement { + div() + .px_1p5() + .py(px(1.0)) + .rounded_sm() + .bg(crate::theme::ThemeColors::accent()) + .border_1() + .border_color(crate::theme::ThemeColors::border()) + .text_xs() + .text_color(crate::theme::ThemeColors::muted_foreground()) + .child(label) + .into_any_element() +} diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index ebf4be423..df3d85149 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -21,6 +21,7 @@ use crate::workbench::bottom_panel::{BottomPanelView, BottomTab}; use crate::workbench::command_palette::{CommandPaletteEvent, CommandPaletteModal}; use crate::workbench::editor::EditorView; use crate::workbench::maven::{MavenEvent, MavenView}; +use crate::workbench::project_dialog::{ProjectDialog, ProjectDialogEvent, ProjectDialogMode}; use crate::workbench::quick_open::{QuickOpenEvent, QuickOpenModal}; use crate::workbench::search_everywhere::{SearchEverywhereEvent, SearchEverywhereModal}; use crate::workbench::settings_dialog::{SettingsCategory, SettingsDialog, SettingsEvent}; @@ -57,6 +58,8 @@ pub struct WorkbenchView { pub show_command_palette: bool, /// 设置模态对话框是否可见 pub show_settings_dialog: bool, + /// 新建/克隆项目对话框是否可见 + pub show_project_dialog: bool, /// 右侧 Maven 导航工具窗口是否可见 pub show_maven: bool, @@ -84,6 +87,8 @@ pub struct WorkbenchView { pub command_palette: Entity, /// 设置模态对话框 pub settings_dialog: Entity, + /// 新建/克隆项目对话框 + pub project_dialog: Entity, /// 欢迎页 pub welcome_screen: Entity, @@ -115,6 +120,7 @@ impl WorkbenchView { let quick_open = cx.new(|cx| QuickOpenModal::new(cx)); let command_palette = cx.new(|cx| CommandPaletteModal::new(cx)); let settings_dialog = cx.new(|cx| SettingsDialog::new(cx)); + let project_dialog = cx.new(|cx| ProjectDialog::new(cx)); let welcome_screen = cx.new(|cx| WelcomeScreenView::new(cx)); let maven = cx.new(|cx| MavenView::new(root.clone(), cx)); let focus_handle = cx.focus_handle(); @@ -386,13 +392,24 @@ impl WorkbenchView { ToolbarEvent::MenuAction(id) => { this.handle_action(id, cx); } - ToolbarEvent::NewProject - | ToolbarEvent::OpenProject - | ToolbarEvent::CloneRepository => { - this.append_log( - "[Project] Project picker is not wired to the backend yet.", - cx, - ); + ToolbarEvent::NewProject => { + let _ = this.project_dialog.update(cx, |d, cx| { + d.set_mode(ProjectDialogMode::New, cx); + }); + this.show_project_dialog = true; + cx.notify(); + } + ToolbarEvent::OpenProject => { + if let Some(dir) = ProjectDialog::pick_folder(None) { + this.open_project_path(dir, cx); + } + } + ToolbarEvent::CloneRepository => { + let _ = this.project_dialog.update(cx, |d, cx| { + d.set_mode(ProjectDialogMode::Clone, cx); + }); + this.show_project_dialog = true; + cx.notify(); } ToolbarEvent::OpenRecent => { this.show_welcome = true; @@ -488,35 +505,56 @@ impl WorkbenchView { }, ); + // 9b. 订阅新建/克隆项目对话框事件 + let sub_project_dialog = cx.subscribe( + &project_dialog, + |this, _dialog, event: &ProjectDialogEvent, cx| match event { + ProjectDialogEvent::OpenedProject { path, starter } => { + this.show_project_dialog = false; + this.open_project_path(path.clone(), cx); + if let Some(cmd) = starter { + let _ = this.bottom_panel.update(cx, |bp, cx| { + bp.set_tab(BottomTab::Terminal, cx); + let _ = bp.terminal.update(cx, |term, cx| { + term.send_command(cmd, cx); + }); + }); + } + cx.notify(); + } + ProjectDialogEvent::Close => { + this.show_project_dialog = false; + cx.notify(); + } + }, + ); + // 10. 订阅欢迎页事件 let sub_welcome = cx.subscribe( &welcome_screen, |this, _welcome, event: &WelcomeEvent, cx| match event { - WelcomeEvent::OpenFolder | WelcomeEvent::NewProject => { - this.append_log( - "[Welcome] Project picker is not wired to the backend yet.", - cx, - ); - } - WelcomeEvent::CloneRepository => { - this.append_log( - "[Welcome] Clone repository is not wired to the backend yet.", - cx, - ); + WelcomeEvent::OpenFolder => { + if let Some(dir) = ProjectDialog::pick_folder(None) { + this.open_project_path(dir, cx); + } } - WelcomeEvent::OpenProject(path) => { - this.workspace_root = path.clone(); - this.show_welcome = false; - let _ = this.sidebar.update(cx, |sb, cx| { - sb.root_path = path.clone(); - sb.refresh(cx); - sb.refresh_git(cx); + WelcomeEvent::NewProject => { + let _ = this.project_dialog.update(cx, |d, cx| { + d.set_mode(ProjectDialogMode::New, cx); }); - let _ = this.maven.update(cx, |m, cx| { - m.set_root(path.clone(), cx); + this.show_project_dialog = true; + cx.notify(); + } + WelcomeEvent::CloneRepository => { + let _ = this.project_dialog.update(cx, |d, cx| { + d.set_mode(ProjectDialogMode::Clone, cx); }); + this.show_project_dialog = true; cx.notify(); } + WelcomeEvent::OpenProject(path) => { + this.open_project_path(path.clone(), cx); + } WelcomeEvent::OpenSettings => { this.open_settings(cx); } @@ -571,6 +609,7 @@ impl WorkbenchView { show_quick_open: false, show_command_palette: false, show_settings_dialog: false, + show_project_dialog: false, show_maven: false, toolbar, activity_rail, @@ -584,6 +623,7 @@ impl WorkbenchView { quick_open, command_palette, settings_dialog, + project_dialog, welcome_screen, focus_handle, client: CoreClient::new(), @@ -598,6 +638,7 @@ impl WorkbenchView { sub_quick_open, sub_command_palette, sub_settings, + sub_project_dialog, sub_welcome, sub_status, sub_appearance, @@ -691,6 +732,24 @@ impl WorkbenchView { cx.notify(); } + /// 切换到指定项目根路径:同步侧边栏、Maven 与欢迎页状态。 + fn open_project_path(&mut self, path: String, cx: &mut Context) { + if self.show_project_dialog { + self.show_project_dialog = false; + } + self.workspace_root = path.clone(); + self.show_welcome = false; + let _ = self.sidebar.update(cx, |sb, cx| { + sb.root_path = path.clone(); + sb.refresh(cx); + sb.refresh_git(cx); + }); + let _ = self.maven.update(cx, |m, cx| { + m.set_root(path.clone(), cx); + }); + cx.notify(); + } + fn append_log(&mut self, message: &str, cx: &mut Context) { let _ = self.bottom_panel.update(cx, |bp, cx| { bp.append_log(message.to_string(), cx); @@ -1070,6 +1129,9 @@ impl Render for WorkbenchView { .when(self.show_settings_dialog, |view| { view.child(self.settings_dialog.clone()) }) + .when(self.show_project_dialog, |view| { + view.child(self.project_dialog.clone()) + }) } } From 6a5fb61e5a925d51d71e2aa2d6bb3bc13f9b1300 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Thu, 24 Sep 2026 17:36:35 +0800 Subject: [PATCH 019/109] =?UTF-8?q?chore(linux):=20=E6=9B=B4=E6=96=B0=20Ca?= =?UTF-8?q?rgo.lock=EF=BC=88rfd=200.16=20=E4=BE=9D=E8=B5=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- linux/Cargo.lock | 70 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/linux/Cargo.lock b/linux/Cargo.lock index 4732d81ce..c7ef06208 100644 --- a/linux/Cargo.lock +++ b/linux/Cargo.lock @@ -270,6 +270,28 @@ dependencies = [ "libloading", ] +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.5", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + [[package]] name = "ashpd" version = "0.13.13" @@ -2372,7 +2394,7 @@ checksum = "c9e904ee0d351f4e85b967e26ae986d3aa195a8d9d35fcd18184a2c40989694e" dependencies = [ "aho-corasick", "anyhow", - "ashpd", + "ashpd 0.13.13", "async-channel", "chrono", "data-url", @@ -2634,7 +2656,7 @@ dependencies = [ "accesskit_unix", "anyhow", "as-raw-xcb-connection", - "ashpd", + "ashpd 0.13.13", "bitflags 2.13.2", "bytemuck", "calloop", @@ -3885,6 +3907,7 @@ dependencies = [ "gpui-kit", "lithe-core", "portable-pty", + "rfd", "serde", "serde_json", "tokio", @@ -4927,7 +4950,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78f2bfed90f1618b4b48dcad9307f25e14ae894e2949642c87c351601d62cebd" dependencies = [ "aes", - "ashpd", + "ashpd 0.13.13", "async-fs", "async-io", "async-lock", @@ -5998,6 +6021,30 @@ dependencies = [ "zune-jpeg 0.5.15", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "ashpd 0.11.1", + "block2 0.6.2", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "pollster 0.4.0", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "rgb" version = "0.8.53" @@ -7783,8 +7830,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.45.1" @@ -8796,6 +8850,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" @@ -9596,6 +9659,7 @@ dependencies = [ "enumflags2", "serde", "serde_bytes", + "url", "winnow 1.0.4", "zcheapstr", "zvariant_derive", From cb8ccbe9ce71f506244ecd3cff0ea16678140782 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Thu, 24 Sep 2026 17:55:56 +0800 Subject: [PATCH 020/109] =?UTF-8?q?feat(linux):=20=E6=9C=80=E8=BF=91?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E8=AE=B0=E5=BD=95=E5=B1=95=E7=A4=BA=E4=B8=8E?= =?UTF-8?q?=E5=88=86=E6=94=AF=E5=BE=BD=E6=A0=87=E7=82=B9=E5=87=BB=E5=88=87?= =?UTF-8?q?=E6=8D=A2=EF=BC=8C=E5=AF=B9=E9=BD=90=20Tauri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对照 recent-folders.ts / project-name-menu.tsx / GitBranchManager: - 最近项目:Settings 新增 recent_projects(去重首位/过滤不存在/ 截断 12,上限常量对齐 MAX_RECENT_PROJECTS);open_project_path 与 启动时记录;欢迎页列表改读持久化(点击打开/删除同步落盘); 项目胶囊“最近项目”分组渲染条目,点击直达。 - 分支徽标改下拉:分支列表来自 core git.references 本地分支; 当前分支打勾禁用;点其他分支经底部终端 git checkout,2 秒后 刷新 git 状态;分支名为专名不翻译。 - 欢迎页标题/副标题/按钮补 welcome.* 本地化;启动记录抽 settings::record_recent_project 共用。 构建零错误零告警,Xvfb 实测分支检出与最近菜单。 --- linux/src/i18n.rs | 7 + linux/src/settings.rs | 29 ++++ linux/src/workbench/toolbar.rs | 182 +++++++++++++++++++++----- linux/src/workbench/view.rs | 66 +++++++++- linux/src/workbench/welcome_screen.rs | 150 ++++++++++----------- 5 files changed, 316 insertions(+), 118 deletions(-) diff --git a/linux/src/i18n.rs b/linux/src/i18n.rs index ea3fbc7e1..b3a6a1fcf 100644 --- a/linux/src/i18n.rs +++ b/linux/src/i18n.rs @@ -131,6 +131,7 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { "titleProject.cloneRepository" => ("克隆仓库…", "Clone Repository…"), "titleProject.openProjects" => ("打开的项目", "Open Projects"), "titleProject.recentProjects" => ("最近项目", "Recent Projects"), + "titleProject.noRecentProjects" => ("没有最近项目", "No recent projects"), // ---- 侧边栏 explorer 头部 ---- "workbench.project" => ("项目", "Project"), "workbench.changes" => ("更改", "Changes"), @@ -196,6 +197,12 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { "search.noResults" => ("未找到结果", "No results found"), // ---- Welcome 屏(与 Tauri locale.ts welcome.* 对齐) ---- "welcome.searchProjects" => ("搜索项目", "Search projects"), + "welcome.recentProjects" => ("最近项目", "Recent Projects"), + "welcome.openFolderHint" => ("打开文件夹以开始使用。", "Open a folder to get started."), + "welcome.title" => ("欢迎使用 Lithe", "Welcome to Lithe"), + "welcome.projects" => ("项目", "Projects"), + "welcome.open" => ("打开", "Open"), + "welcome.clone" => ("克隆", "Clone"), "welcome.noRecentProjects" => ("暂无最近项目", "No recent projects"), "welcome.removeRecent" => ( "从最近项目中移除 {name}", diff --git a/linux/src/settings.rs b/linux/src/settings.rs index 0a2866062..016d5ede3 100644 --- a/linux/src/settings.rs +++ b/linux/src/settings.rs @@ -37,6 +37,17 @@ pub const SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS: &[&str] = &[ /// 状态栏左侧项顺序,对应 Tauri `FOOTER_LEADING_ITEM_IDS`。 pub const FOOTER_LEADING_ITEM_IDS: &[&str] = &["filePath", "branch"]; +/// 最近项目上限,对齐 Tauri `MAX_RECENT_PROJECTS`(`recent-folders.ts`)。 +pub const MAX_RECENT_PROJECTS: usize = 12; + +/// 取路径末段目录名用于展示(兼容 `/` 与 `\` 分隔)。 +pub fn project_dir_name(path: &str) -> &str { + path.rsplit(&['/', '\\'][..]) + .next() + .filter(|s| !s.is_empty()) + .unwrap_or(path) +} + /// 状态栏右侧项顺序,对应 Tauri `FOOTER_TRAILING_ITEM_IDS`。 pub const FOOTER_TRAILING_ITEM_IDS: &[&str] = &[ "cursor", @@ -164,6 +175,12 @@ pub struct Settings { // Projects pub open_folders_in_new_window: bool, pub ask_where_to_open_projects: bool, + /// 最近打开的项目绝对路径,首位最新。 + /// + /// 对齐 Tauri `recent-folders.ts`:去重(已有移到首位)、按打开倒序、 + /// 过滤不存在的路径、上限 [`MAX_RECENT_PROJECTS`](Linux 无 pin,只做倒序)。 + /// 结构体级 `#[serde(default)]` 已覆盖,兼容缺少该键的老配置。 + pub recent_projects: Vec, // File tree pub hidden_file_patterns: Vec, pub hidden_directory_patterns: Vec, @@ -248,6 +265,7 @@ impl Default for Settings { semantic_tokens: true, open_folders_in_new_window: true, ask_where_to_open_projects: true, + recent_projects: Vec::new(), hidden_file_patterns: DEFAULT_HIDDEN_FILE_PATTERNS .iter() .map(|s| s.to_string()) @@ -357,6 +375,17 @@ pub fn update(cx: &mut App, f: impl FnOnce(&mut Settings)) { cx.refresh_windows(); } +/// 记录最近项目(对齐 Tauri upsert):去重移到首位 → 过滤不存在 → 截断上限 → 落盘。 +pub fn record_recent_project(cx: &mut App, path: &str) { + update(cx, |s| { + s.recent_projects.retain(|p| p != path); + s.recent_projects.insert(0, path.to_string()); + s.recent_projects + .retain(|p| std::path::Path::new(p).exists()); + s.recent_projects.truncate(MAX_RECENT_PROJECTS); + }); +} + /// 解析当前应生效的主题 id:跟随系统时按外观取 `autoTheme*`,否则取 `theme`。 pub fn resolved_theme_id(settings: &Settings, system_is_dark: bool) -> String { if settings.sync_system_theme { diff --git a/linux/src/workbench/toolbar.rs b/linux/src/workbench/toolbar.rs index 12e805ad5..65abe8909 100644 --- a/linux/src/workbench/toolbar.rs +++ b/linux/src/workbench/toolbar.rs @@ -7,7 +7,7 @@ use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::component::menu::{DropdownMenu as _, PopupMenu, PopupMenuItem}; -use gpui_kit::component::{h_flex, Icon, Selectable as _, Sizable as _}; +use gpui_kit::component::{h_flex, v_flex, Icon, Selectable as _, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ div, px, Anchor, AnyElement, Context, EventEmitter, InteractiveElement as _, IntoElement, @@ -48,6 +48,10 @@ pub enum ToolbarEvent { OpenProject, CloneRepository, OpenRecent, + /// 打开最近项目中的指定路径(项目胶囊“最近项目”分组条目)。 + OpenRecentProject(String), + /// 检出指定本地分支(分支胶囊下拉条目,对齐 Tauri 分支管理器检出)。 + CheckoutBranch(String), } /// 应用图标:与 Tauri 端 `public/logo.png` 同一文件,编译期嵌入。 @@ -56,6 +60,8 @@ const APP_LOGO_PNG: &[u8] = include_bytes!("../../assets/logo.png"); pub struct ToolbarView { pub workspace_name: String, pub git_branch: Option, + /// 本地分支列表(view 侧由 core `git.references` 同步,供分支胶囊下拉切换)。 + pub branches: Vec, /// 紧凑菜单条是否展开(对齐 Tauri `isCompactMenuVisible`)。 compact_menu_open: bool, /// 解码后的应用图标,项目菜单触发器左侧的徽标(对齐 Tauri 的 `logo.png`)。 @@ -74,6 +80,7 @@ impl ToolbarView { Self { workspace_name: name, git_branch: None, + branches: Vec::new(), compact_menu_open: false, app_logo: std::sync::Arc::new(gpui_kit::Image::from_bytes( gpui_kit::ImageFormat::Png, @@ -86,6 +93,11 @@ impl ToolbarView { self.git_branch = branch; cx.notify(); } + + pub fn set_branches(&mut self, branches: Vec, cx: &mut Context) { + self.branches = branches; + cx.notify(); + } } /// 单个菜单项:i18n 键(`crate::i18n::menu_text`)+ 稳定动作 id + 快捷键展示文本。 @@ -409,6 +421,47 @@ fn event_item( }) } +/// 最近项目条目:目录名主行 + 全路径副行,对齐 Tauri `ProjectMenuRow`。 +fn recent_project_item( + name: String, + path: String, + view: &gpui_kit::Entity, +) -> PopupMenuItem { + let v = view.clone(); + let open_path = path.clone(); + // element 闭包是 Fn,多次调用时 clone 使用。 + PopupMenuItem::element(move |_window, _cx| { + let name = name.clone(); + let path = path.clone(); + v_flex() + .w_full() + .min_w_0() + .child( + div() + .w_full() + .truncate() + .text_xs() + .text_color(ThemeColors::foreground()) + .child(name), + ) + .child( + div() + .w_full() + .truncate() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child(path), + ) + }) + .on_click(move |_, _, cx| { + let path = open_path.clone(); + v.update(cx, |this, cx| { + this.compact_menu_open = false; + cx.emit(ToolbarEvent::OpenRecentProject(path)); + }); + }) +} + /// 按分组把菜单项灌入 `PopupMenu`,分组之间自动插入分隔线。 /// /// 文案在 builder 内用 `cx`(`&mut Context`)实时解析; @@ -486,6 +539,15 @@ impl Render for ToolbarView { let t_clone = crate::i18n::menu_text(cx, "titleProject.cloneRepository").to_string(); let t_open_projects = crate::i18n::menu_text(cx, "titleProject.openProjects").to_string(); let t_recent = crate::i18n::menu_text(cx, "titleProject.recentProjects").to_string(); + let t_no_recent = crate::i18n::menu_text(cx, "titleProject.noRecentProjects").to_string(); + // 最近项目(首位最新,过滤不存在的路径,上限与持久化数组一致)。 + let recents: Vec<(String, String)> = settings::get(cx) + .recent_projects + .iter() + .filter(|p| std::path::Path::new(p).exists()) + .take(settings::MAX_RECENT_PROJECTS) + .map(|p| (settings::project_dir_name(p).to_string(), p.clone())) + .collect(); // 九个顶层菜单标题:render 入口处一次性翻译成 owned String, // 后续 move 闭包只搬运字符串,不再借用 cx(避免 cx 被 move 后再借用)。 @@ -630,40 +692,51 @@ impl Render for ToolbarView { .size(px(14.0)) .text_color(ThemeColors::subtle_foreground()), ) - .dropdown_menu(move |menu, _window, _cx| { - menu.item(event_item( - t_new_project.clone(), - || ToolbarEvent::NewProject, - &v, - )) - .item(event_item(t_open.clone(), || ToolbarEvent::OpenProject, &v)) - .item(event_item( - t_clone.clone(), - || ToolbarEvent::CloneRepository, - &v, - )) - .separator() - // 打开的项目分组标签:只展示,不可点。 - .item(PopupMenuItem::label(t_open_projects.clone())) - .separator() - .item(event_item( - t_recent.clone(), - || ToolbarEvent::OpenRecent, - &v, - )) + .dropdown_menu(move |mut menu, _window, _cx| { + menu = menu + .item(event_item( + t_new_project.clone(), + || ToolbarEvent::NewProject, + &v, + )) + .item(event_item( + t_open.clone(), + || ToolbarEvent::OpenProject, + &v, + )) + .item(event_item( + t_clone.clone(), + || ToolbarEvent::CloneRepository, + &v, + )) + .separator() + // 打开的项目分组标签:只展示,不可点。 + .item(PopupMenuItem::label(t_open_projects.clone())) + .separator() + .item(PopupMenuItem::label(t_recent.clone())); + if recents.is_empty() { + menu = menu.item(PopupMenuItem::label(t_no_recent.clone())); + } else { + for (name, path) in &recents { + menu = menu.item(recent_project_item( + name.clone(), + path.clone(), + &v, + )); + } + } + menu }) }) - .child( - // 分支胶囊:只展示当前分支,不接真实切换 - h_flex() - .items_center() - .gap_1p5() - .px_2() - .py(px(3.0)) - .rounded_md() - .bg(ThemeColors::surface()) - .border_1() - .border_color(ThemeColors::border()) + .child({ + // 分支胶囊:点击展开本地分支下拉(对齐 Tauri 分支管理器), + // 当前分支打勾并禁用,点击其他分支发射 CheckoutBranch 由 view 侧检出。 + let v = view.clone(); + let current = branch.clone(); + let branches = self.branches.clone(); + Button::new("tb-branch-selector") + .small() + .ghost() .child( Icon::new(IconName::GitBranch) .size(px(13.0)) @@ -671,16 +744,53 @@ impl Render for ToolbarView { ) .child( div() + .max_w(px(160.0)) + .truncate() .text_xs() .text_color(ThemeColors::success()) - .child(branch), + .child(branch.clone()), ) .child( Icon::new(IconName::ChevronDown) .size(px(12.0)) .text_color(ThemeColors::subtle_foreground()), - ), - ), + ) + .dropdown_menu(move |mut menu, _window, _cx| { + if branches.is_empty() { + menu = menu.item( + PopupMenuItem::new(current.clone()) + .icon(IconName::Check) + .disabled(true), + ); + } else { + for name in &branches { + if *name == current { + menu = menu.item( + PopupMenuItem::new(name.clone()) + .icon(IconName::Check) + .disabled(true), + ); + } else { + let v = v.clone(); + let target = name.clone(); + menu = menu.item( + PopupMenuItem::new(name.clone()).on_click( + move |_, _, cx| { + let target = target.clone(); + v.update(cx, |_this, cx| { + cx.emit(ToolbarEvent::CheckoutBranch( + target, + )); + }); + }, + ), + ); + } + } + } + menu + }) + }), ) // 右侧:全局搜索图标按钮 + 窗口控件(对齐 Tauri `quickOpenAction` + `WindowControls`) .child( diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index df3d85149..5263035ed 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -192,6 +192,43 @@ impl WorkbenchView { sb.set_git_changes(sidebar.read(cx).git_changes.len(), cx); }); + // 同步本地分支列表:core `git.references` 取 kind == local 的 shortName, + // 供顶栏分支胶囊下拉切换(对齐 Tauri 分支管理器列表)。 + let toolbar_branches_sync = toolbar_branch_sync.clone(); + let branch_client = this.client.clone(); + let branch_root = this.workspace_root.clone(); + cx.spawn(async move |_this, cx| { + let task = branch_client.execute::( + &cx, + "git.references", + serde_json::json!({ "root": branch_root }), + ); + if let Ok(val) = task.await { + let mut branches: Vec = val + .get("references") + .and_then(|r| r.as_array()) + .map(|arr| { + arr.iter() + .filter(|item| { + item.get("kind").and_then(|k| k.as_str()) == Some("local") + }) + .filter_map(|item| { + item.get("shortName") + .and_then(|n| n.as_str()) + .map(|s| s.to_string()) + }) + .collect() + }) + .unwrap_or_default(); + branches.sort(); + branches.dedup(); + let _ = toolbar_branches_sync.update(cx, |tb, cx| { + tb.set_branches(branches, cx); + }); + } + }) + .detach(); + // 收集所有文件供全局搜索与快速打开 let mut file_list = Vec::new(); if let Some(root_node) = &sidebar.read(cx).root_node { @@ -415,6 +452,29 @@ impl WorkbenchView { this.show_welcome = true; cx.notify(); } + ToolbarEvent::OpenRecentProject(path) => { + this.open_project_path(path.clone(), cx); + } + ToolbarEvent::CheckoutBranch(branch) => { + // 分支检出走底部 Terminal(分支名双引号包裹防空格/特殊字符), + // 检出异步执行,2 秒后重读 sidebar git 状态刷新分支与变更。 + let _ = this.bottom_panel.update(cx, |bp, cx| { + bp.set_tab(BottomTab::Terminal, cx); + let _ = bp.terminal.update(cx, |term, cx| { + term.send_command(&format!("git checkout \"{branch}\""), cx); + }); + }); + let sidebar = this.sidebar.clone(); + cx.spawn(async move |_this, cx| { + cx.background_executor() + .timer(std::time::Duration::from_secs(2)) + .await; + let _ = sidebar.update(cx, |sb, cx| { + sb.refresh_git(cx); + }); + }) + .detach(); + } }, ); @@ -651,6 +711,8 @@ impl WorkbenchView { !crate::theme::ThemePalette::is_light(&settings::resolved_theme_id(s, false)) }; view.apply_resolved_theme(is_dark, cx); + // 启动目录同样记入最近项目(对齐 Tauri 打开即记录)。 + settings::record_recent_project(cx, &view.workspace_root.clone()); // 构造后初始化 Maven 入口可用性,避免无 pom 目录首次打开仍显示入口。 let maven_has_projects = view.maven.read(cx).has_projects(); let _ = view.plugin_rail.update(cx, |r, cx| { @@ -732,13 +794,15 @@ impl WorkbenchView { cx.notify(); } - /// 切换到指定项目根路径:同步侧边栏、Maven 与欢迎页状态。 + /// 切换到指定项目根路径:同步侧边栏、Maven 与欢迎页状态,并记录最近项目。 fn open_project_path(&mut self, path: String, cx: &mut Context) { if self.show_project_dialog { self.show_project_dialog = false; } self.workspace_root = path.clone(); self.show_welcome = false; + // 记录最近项目(对齐 Tauri upsert)。 + settings::record_recent_project(cx, &path); let _ = self.sidebar.update(cx, |sb, cx| { sb.root_path = path.clone(); sb.refresh(cx); diff --git a/linux/src/workbench/welcome_screen.rs b/linux/src/workbench/welcome_screen.rs index 07704ce11..c12f11e3b 100644 --- a/linux/src/workbench/welcome_screen.rs +++ b/linux/src/workbench/welcome_screen.rs @@ -11,23 +11,13 @@ use gpui_kit::component::scroll::ScrollableElement as _; use gpui_kit::component::{h_flex, v_flex, Icon, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, AnyElement, Context, EventEmitter, FocusHandle, FontWeight, InteractiveElement as _, - IntoElement, KeyDownEvent, ParentElement as _, Render, StatefulInteractiveElement as _, - Styled as _, Window, + div, px, AnyElement, App, Context, EventEmitter, FocusHandle, FontWeight, + InteractiveElement as _, IntoElement, KeyDownEvent, ParentElement as _, Render, + StatefulInteractiveElement as _, Styled as _, Window, }; use crate::theme::ThemeColors; -/// 最近项目条目。 -/// -/// `last_opened` 为展示用的相对时间文案,由上层决定格式,本模块不解析时间。 -#[derive(Debug, Clone)] -pub struct RecentProject { - pub name: String, - pub path: String, - pub last_opened: String, -} - /// 欢迎页对外事件。 #[derive(Debug, Clone)] pub enum WelcomeEvent { @@ -58,8 +48,10 @@ impl WelcomeEvent { } /// 整屏欢迎页:左侧导航栏 + 右侧项目区。 +/// +/// 最近项目直接读 `settings::get(cx).recent_projects`(首位最新),展示时过滤 +/// 不存在的路径;打开与删除都经 `settings::update` 落盘,与 `view.rs` 共用同一数组。 pub struct WelcomeScreenView { - pub recent_projects: Vec, pub selected_index: usize, pub query: String, pub focus_handle: FocusHandle, @@ -70,29 +62,31 @@ impl EventEmitter for WelcomeScreenView {} impl WelcomeScreenView { pub fn new(cx: &mut Context) -> Self { Self { - recent_projects: default_recent_projects(), selected_index: 0, query: String::new(), focus_handle: cx.focus_handle(), } } - /// 当前查询过滤后的最近项目。 - fn visible_projects(&self) -> Vec<&RecentProject> { + /// 当前查询过滤后的最近项目路径(首位最新,不存在的路径已滤除)。 + fn visible_projects(&self, cx: &App) -> Vec { let q = self.query.trim().to_lowercase(); - self.recent_projects + crate::settings::get(cx) + .recent_projects .iter() + .filter(|p| std::path::Path::new(p).exists()) .filter(|p| { - q.is_empty() - || p.name.to_lowercase().contains(&q) - || p.path.to_lowercase().contains(&q) + q.is_empty() || { + let name = crate::settings::project_dir_name(p).to_lowercase(); + name.contains(&q) || p.to_lowercase().contains(&q) + } }) + .cloned() .collect() } /// 当前有效选中下标(对空列表安全)。 - fn current_index(&self) -> usize { - let total = self.visible_projects().len(); + fn current_index(&self, total: usize) -> usize { if total == 0 { 0 } else { @@ -102,16 +96,22 @@ impl WelcomeScreenView { /// 打开可见列表中第 `position` 个项目。 fn open_at(&self, position: usize, cx: &mut Context) { - let visible = self.visible_projects(); - if let Some(project) = visible.get(position) { - cx.emit(WelcomeEvent::OpenProject(project.path.clone())); + let visible = self.visible_projects(cx); + if let Some(path) = visible.get(position) { + cx.emit(WelcomeEvent::OpenProject(path.clone())); } } - /// 从最近列表移除指定路径,并夹紧选中下标。 + /// 从最近列表移除指定路径(同步落盘),并夹紧选中下标。 fn remove_recent(&mut self, path: &str, cx: &mut Context) { - self.recent_projects.retain(|p| p.path != path); - let total = self.visible_projects().len(); + crate::settings::update(cx, |s| { + s.recent_projects.retain(|p| p != path); + }); + let total = crate::settings::get(cx) + .recent_projects + .iter() + .filter(|p| std::path::Path::new(p).exists()) + .count(); if self.selected_index >= total { self.selected_index = total.saturating_sub(1); } @@ -180,7 +180,7 @@ impl WelcomeScreenView { .text_sm() .font_weight(FontWeight::MEDIUM) .text_color(ThemeColors::foreground()) - .child("Projects"), + .child(crate::i18n::menu_text(cx, "welcome.projects")), ), ) .child(div().flex_1()) @@ -203,7 +203,11 @@ impl WelcomeScreenView { .size(px(15.0)) .text_color(ThemeColors::subtle_foreground()), ) - .child(div().text_sm().child("Settings")) + .child( + div() + .text_sm() + .child(crate::i18n::menu_text(cx, "workbench.settings")), + ) .on_click(cx.listener(|_this, _event, _window, cx| { cx.emit(WelcomeEvent::OpenSettings); })), @@ -213,8 +217,8 @@ impl WelcomeScreenView { /// 右侧项目区:标题、搜索与操作按钮、最近项目列表。 fn render_main(&self, cx: &mut Context) -> AnyElement { - let visible = self.visible_projects(); - let current_index = self.current_index(); + let visible = self.visible_projects(cx); + let current_index = self.current_index(visible.len()); v_flex() .flex_1() @@ -229,7 +233,7 @@ impl WelcomeScreenView { .text_xl() .font_weight(FontWeight::BOLD) .text_color(ThemeColors::foreground()) - .child("Welcome to Lithe"), + .child(crate::i18n::menu_text(cx, "welcome.title")), ), ) .child( @@ -237,7 +241,7 @@ impl WelcomeScreenView { div() .text_sm() .text_color(ThemeColors::subtle_foreground()) - .child("Open a project to get started"), + .child(crate::i18n::menu_text(cx, "welcome.openFolderHint")), ), ) .child( @@ -288,7 +292,7 @@ impl WelcomeScreenView { Button::new("welcome-clone") .small() .icon(IconName::GitBranch) - .label("Clone") + .label(crate::i18n::menu_text(cx, "welcome.clone")) .on_click(cx.listener(|_this, _event, _window, cx| { cx.emit(WelcomeEvent::CloneRepository); })), @@ -298,12 +302,23 @@ impl WelcomeScreenView { .small() .primary() .icon(IconName::FolderOpen) - .label("Open") + .label(crate::i18n::menu_text(cx, "welcome.open")) .on_click(cx.listener(|_this, _event, _window, cx| { cx.emit(WelcomeEvent::OpenFolder); })), ), ) + .child( + // 最近项目分组头(对齐 Tauri `welcome.recentProjects`) + div() + .w_full() + .pt_4() + .pb_1() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(ThemeColors::foreground()) + .child(crate::i18n::menu_text(cx, "welcome.recentProjects")), + ) .child( // 最近项目列表或空态 div() @@ -338,16 +353,17 @@ impl WelcomeScreenView { div() .text_xs() .text_color(ThemeColors::subtle_foreground()) - .child("Open a folder to start working"), + .child(crate::i18n::menu_text( + cx, + "welcome.openFolderHint", + )), ), ) }) - .children(visible.into_iter().enumerate().map(|(position, project)| { + .children(visible.into_iter().enumerate().map(|(position, path)| { let is_selected = position == current_index; - let name = project.name.clone(); - let path = project.path.clone(); - let last_opened = project.last_opened.clone(); - let remove_path = project.path.clone(); + let name = crate::settings::project_dir_name(&path).to_string(); + let remove_path = path.clone(); let badge = project_badge(&name, &path); // 每行使用独立的 hover group,避免兄弟行共享 group 名互相影响 let group_name = format!("welcome-recent-group-{position}"); @@ -380,20 +396,10 @@ impl WelcomeScreenView { .child(name), ) .child( - h_flex() - .gap_2() - .child( - div() - .text_xs() - .text_color(ThemeColors::subtle_foreground()) - .child(path), - ) - .child( - div() - .text_xs() - .text_color(ThemeColors::subtle_foreground()) - .child(last_opened), - ), + div() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child(path), ), ) .child( @@ -410,10 +416,9 @@ impl WelcomeScreenView { crate::i18n::menu_text(cx, "welcome.removeRecent") .replace( "{name}", - remove_path - .rsplit('/') - .next() - .unwrap_or(&remove_path), + crate::settings::project_dir_name( + &remove_path, + ), ), ) .on_click(cx.listener( @@ -455,14 +460,15 @@ impl Render for WelcomeScreenView { } } "down" | "arrowdown" => { - let total = this.visible_projects().len(); + let total = this.visible_projects(cx).len(); if total > 0 && this.selected_index + 1 < total { this.selected_index += 1; cx.notify(); } } "enter" => { - let idx = this.current_index(); + let total = this.visible_projects(cx).len(); + let idx = this.current_index(total); this.open_at(idx, cx); } "backspace" => { @@ -503,24 +509,6 @@ impl Render for WelcomeScreenView { } } -/// 默认示例最近项目。 -/// -/// 使用当前工作目录派生的占位路径,避免写入任何真实用户目录。 -fn default_recent_projects() -> Vec { - let base = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); - let sample = |name: &str, last_opened: &str| RecentProject { - name: name.to_string(), - path: base.join(name).to_string_lossy().to_string(), - last_opened: last_opened.to_string(), - }; - - vec![ - sample("lithe-sandbox", "2 hours ago"), - sample("example-project", "yesterday"), - sample("gpui-playground", "last week"), - ] -} - /// 按名称首字母从语义色板中取色,生成圆角首字母徽标。 fn project_badge(name: &str, path: &str) -> AnyElement { let initial = name From 28efe37863f0201bbf21d3980d08cca122007e24 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Thu, 24 Sep 2026 17:56:39 +0800 Subject: [PATCH 021/109] =?UTF-8?q?chore(linux):=20=E5=90=8C=E6=AD=A5=20we?= =?UTF-8?q?lcome=5Fscreen=20=E9=87=8D=E5=AF=BC=E5=87=BA=EF=BC=88RecentProj?= =?UTF-8?q?ect=20=E5=B7=B2=E5=88=A0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- linux/src/workbench/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linux/src/workbench/mod.rs b/linux/src/workbench/mod.rs index b560769ab..282778b2a 100644 --- a/linux/src/workbench/mod.rs +++ b/linux/src/workbench/mod.rs @@ -42,4 +42,4 @@ pub use status_bar::{StatusBarEvent, StatusBarView}; pub use toolbar::{ToolbarEvent, ToolbarView}; pub use view::WorkbenchView; #[allow(unused_imports)] -pub use welcome_screen::{RecentProject, WelcomeEvent, WelcomeScreenView}; +pub use welcome_screen::{WelcomeEvent, WelcomeScreenView}; From a42fef08bb52c2b6841d12500a89173885bf841c Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Thu, 24 Sep 2026 20:44:04 +0800 Subject: [PATCH 022/109] =?UTF-8?q?feat(linux):=20=E5=B7=A6=E4=B8=8A?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E8=8F=9C=E5=8D=95=E5=8A=A8=E4=BD=9C=E5=85=A8?= =?UTF-8?q?=E9=87=8F=E6=8E=A5=E7=BA=BF=EF=BC=8C=E5=AF=B9=E9=BD=90=20Tauri?= =?UTF-8?q?=20window-menu-bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 九菜单 File/Edit/View/Go/Terminal/Run/Tools/Window/Help 条目与 Tauri 同序,文案走 i18n - File:新窗口/开文件夹/关文件夹/另存为/全部保存/还原/标签页批量关闭与恢复 - Edit:撤销重做剪贴板全选注释复制行删除行上下移行;无 LSP 后端项禁用 - Go:跳转到行弹窗、上下标签页;无导航历史/LSP 项禁用 - View:换行/行号/空白符开关、字体缩放 10-22 落盘、go_to_line/面板开关 - Terminal:新建会话 respawn、关闭收起;单会话 split 禁用 - Window:全屏 toggle_fullscreen;Help 外链经 xdg-open - 紧凑菜单条去掉 on_mouse_down_out(吞弹窗点击),菜单项可触发 - editor_window_action fallback windows()[0];按键路径 editor_direct 直传 window - 行操作快捷键:Ctrl+D/Ctrl+//Ctrl+Shift+K/Alt+Up-Down - Xvfb 验证:复制行/切换注释/删除行生效 --- linux/src/i18n.rs | 20 + linux/src/workbench/branch_manager.rs | 874 ++++++++++++++++++++++++++ linux/src/workbench/editor.rs | 606 +++++++++++++++++- linux/src/workbench/go_to_line.rs | 219 +++++++ linux/src/workbench/mod.rs | 6 + linux/src/workbench/terminal.rs | 6 + linux/src/workbench/toolbar.rs | 206 +++--- linux/src/workbench/view.rs | 507 +++++++++++++-- 8 files changed, 2283 insertions(+), 161 deletions(-) create mode 100644 linux/src/workbench/branch_manager.rs create mode 100644 linux/src/workbench/go_to_line.rs diff --git a/linux/src/i18n.rs b/linux/src/i18n.rs index b3a6a1fcf..d45b27810 100644 --- a/linux/src/i18n.rs +++ b/linux/src/i18n.rs @@ -183,6 +183,23 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { "notifications.title" => ("通知", "Notifications"), // ---- Git ---- "git.commit" => ("提交", "Commit"), + // ---- Git 分支管理器(与 Tauri locale.ts git.* 逐字对齐) ---- + "git.repositories" => ("仓库", "Repositories"), + "git.branches" => ("分支", "Branches"), + "git.worktrees" => ("工作树", "Worktrees"), + "git.searchBranches" => ("搜索分支...", "Search branches..."), + "git.searchWorktrees" => ("搜索工作树...", "Search worktrees..."), + "git.filterRepositories" => ("筛选仓库...", "Filter repositories..."), + "git.branchCount" => ("{count} 个分支", "{count} branch"), + "git.branchesCount" => ("{count} 个分支", "{count} branches"), + "git.worktreeCount" => ("{count} 个工作树", "{count} worktree"), + "git.worktreesCount" => ("{count} 个工作树", "{count} worktrees"), + "git.repositoryCount" => ("{count} 个仓库", "{count} repository"), + "git.repositoriesCount" => ("{count} 个仓库", "{count} repositories"), + "git.selectorSections" => ("Git 选择分区", "Git selector sections"), + "git.noMatchingBranches" => ("没有匹配的分支", "No matching branches"), + "git.noBranchesFound" => ("未找到分支", "No branches found"), + "git.createNewBranch" => ("创建新分支 “{name}”", "Create new branch \"{name}\""), // ---- Quick Open / Command Palette / Search Everywhere(与 Tauri locale.ts 对齐) ---- "quickOpen.searchFiles" => ("输入以搜索文件...", "Type to search files..."), "quickOpen.searchSymbols" => ("输入以筛选符号...", "Type to filter symbols..."), @@ -209,6 +226,9 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { "Remove {name} from recent projects", ), // ---- Linux-frame 新增键(Tauri 无对应) ---- + "goToLine.title" => ("转到行", "Go to Line"), + "goToLine.placeholder" => ("输入行号...", "Type a line number..."), + "goToLine.hint" => ("按 Enter 跳转到指定行", "Press Enter to go to the specified line"), "search.scopeAll" => ("全部", "All"), "search.scopeFiles" => ("文件", "Files"), "search.scopeActions" => ("操作", "Actions"), diff --git a/linux/src/workbench/branch_manager.rs b/linux/src/workbench/branch_manager.rs new file mode 100644 index 000000000..36dc3cad1 --- /dev/null +++ b/linux/src/workbench/branch_manager.rs @@ -0,0 +1,874 @@ +//! 分支管理器弹窗:对齐 Tauri `GitBranchManager`(`GitCommandSurface` 弹窗)。 +//! +//! - 顶部三个 tab(仓库 / 分支 / 工作树),按 tab 切换搜索框占位与计数。 +//! - 分支行点击经 core 直连 `git.write` 检出,当前分支打勾; +//! 搜索串无精确匹配时首行可建分支(`createBranch` 后再 `checkout` 切过去)。 +//! - 工作树行点击把路径交给父级(`OpenWorktree`),由父级打开对应项目。 +//! - 成功统一发射 [`BranchManagerEvent::CheckoutDone`](父级刷新+关弹窗); +//! 失败只在弹窗内显示错误行,不发射。 +//! - 遮罩 + 卡片布局与键盘输入仿 `quick_open.rs`;core 调用模式照抄 +//! `sidebar.rs`(`CoreClient` + `cx.spawn`)与 `view.rs` 的分支解析。 + +use gpui_kit::assets::IconName; +use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::scroll::ScrollableElement as _; +use gpui_kit::component::{h_flex, v_flex, Icon, Selectable as _, Sizable as _}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::{ + div, px, rgba, Context, EventEmitter, FocusHandle, FontWeight, InteractiveElement as _, + IntoElement, KeyDownEvent, ParentElement as _, Render, StatefulInteractiveElement as _, + Styled as _, Window, +}; + +use crate::core::CoreClient; +use crate::theme::ThemeColors; + +/// 管理器顶部的三个分区(对齐 Tauri `CommandTabs`)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BranchTab { + Repositories, + Branches, + Worktrees, +} + +impl BranchTab { + /// 分区标题文案键(与 Tauri `git.repositories/branches/worktrees` 对齐)。 + fn title_key(self) -> &'static str { + match self { + BranchTab::Repositories => "git.repositories", + BranchTab::Branches => "git.branches", + BranchTab::Worktrees => "git.worktrees", + } + } + + /// 搜索框占位文案键(按 tab 切换)。 + fn placeholder_key(self) -> &'static str { + match self { + BranchTab::Repositories => "git.filterRepositories", + BranchTab::Branches => "git.searchBranches", + BranchTab::Worktrees => "git.searchWorktrees", + } + } +} + +/// 分支管理器对外事件。 +#[derive(Debug, Clone)] +pub enum BranchManagerEvent { + /// 检出或建分支成功:父级负责刷新 git 状态并关闭弹窗。 + CheckoutDone, + /// 请求打开指定工作树路径的项目(父级关弹窗后打开)。 + OpenWorktree(String), + /// 请求关闭弹窗(遮罩点击 / Esc)。 + Close, +} + +/// 工作树列表项(字段形状对齐 core `GitWorktreeResponse` 的 camelCase 返回)。 +#[derive(Debug, Clone)] +pub struct WorktreeInfo { + pub path: String, + pub branch: Option, + pub is_current: bool, +} + +/// 居中的分支管理器弹窗(宽 560、高 420,仿 `project_dialog` 卡片)。 +pub struct BranchManagerView { + repo_path: String, + current_branch: Option, + active_tab: BranchTab, + query: String, + branches: Vec, + worktrees: Vec, + error: Option, + busy: bool, + focus_handle: FocusHandle, + client: CoreClient, +} + +impl EventEmitter for BranchManagerView {} + +impl BranchManagerView { + pub fn new(repo_path: String, cx: &mut Context) -> Self { + let mut view = Self { + repo_path, + current_branch: None, + active_tab: BranchTab::Branches, + query: String::new(), + branches: Vec::new(), + worktrees: Vec::new(), + error: None, + busy: false, + focus_handle: cx.focus_handle(), + client: CoreClient::new(), + }; + view.reload(cx); + view + } + + /// 更新仓库并重载分支与工作树(打开弹窗时由父级调用)。 + pub fn set_repo(&mut self, path: String, current: Option, cx: &mut Context) { + self.repo_path = path; + self.current_branch = current; + self.query.clear(); + self.error = None; + self.busy = false; + self.reload(cx); + } + + /// 并发拉取 `git.references` 与 `worktrees`,分支排序去重。 + pub fn reload(&mut self, cx: &mut Context) { + let client = self.client.clone(); + let root = self.repo_path.clone(); + let root_wt = self.repo_path.clone(); + cx.spawn(async move |this, cx| { + let refs_task = client.execute::( + &cx, + "git.references", + serde_json::json!({ "root": root }), + ); + let wt_task = client.execute::( + &cx, + "worktrees", + serde_json::json!({ "root": root_wt }), + ); + let refs_val = refs_task.await.unwrap_or(serde_json::Value::Null); + let wt_val = wt_task.await.unwrap_or(serde_json::Value::Null); + // 分支解析照抄 view.rs observer:kind == local 的 shortName。 + let mut branches: Vec = refs_val + .get("references") + .and_then(|r| r.as_array()) + .map(|arr| { + arr.iter() + .filter(|item| item.get("kind").and_then(|k| k.as_str()) == Some("local")) + .filter_map(|item| { + item.get("shortName") + .and_then(|n| n.as_str()) + .map(|s| s.to_string()) + }) + .collect() + }) + .unwrap_or_default(); + branches.sort(); + branches.dedup(); + let worktrees: Vec = wt_val + .get("worktrees") + .and_then(|w| w.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|item| { + let path = item + .get("path") + .and_then(|p| p.as_str()) + .unwrap_or("") + .to_string(); + if path.is_empty() { + return None; + } + Some(WorktreeInfo { + path, + branch: item + .get("branch") + .and_then(|b| b.as_str()) + .map(|s| s.to_string()), + is_current: item + .get("isCurrent") + .and_then(|b| b.as_bool()) + .unwrap_or(false), + }) + }) + .collect() + }) + .unwrap_or_default(); + let _ = this.update(cx, |view, cx| { + view.branches = branches; + view.worktrees = worktrees; + view.error = None; + cx.notify(); + }); + }) + .detach(); + } + + /// 检出指定本地分支:`git.write` 成功即 `CheckoutDone`,失败只显示错误行。 + fn checkout(&mut self, branch: String, cx: &mut Context) { + if self.busy || Some(branch.as_str()) == self.current_branch.as_deref() { + return; + } + self.busy = true; + self.error = None; + cx.notify(); + let client = self.client.clone(); + let payload = git_write_payload( + &self.repo_path, + "checkout", + serde_json::json!({ + "reference": format!("refs/heads/{branch}"), + "referenceKind": "local", + }), + ); + cx.spawn(async move |this, cx| { + let task = + client.execute::(&cx, "git.write", payload); + match task.await { + Ok(_) => { + let _ = this.update(cx, |view, cx| { + view.busy = false; + cx.emit(BranchManagerEvent::CheckoutDone); + }); + } + Err(err) => { + let _ = this.update(cx, |view, cx| { + view.busy = false; + view.error = Some(err); + cx.notify(); + }); + } + } + }) + .detach(); + } + + /// 建分支后切过去:`createBranch`(`reference: "HEAD"`)成功再调一次 checkout。 + fn create_and_checkout(&mut self, name: String, cx: &mut Context) { + let name = name.trim().to_string(); + if self.busy || name.is_empty() { + return; + } + self.busy = true; + self.error = None; + cx.notify(); + let client = self.client.clone(); + let root = self.repo_path.clone(); + let checkout_ref = format!("refs/heads/{name}"); + let create_payload = git_write_payload( + &root, + "createBranch", + serde_json::json!({ "name": name, "reference": "HEAD" }), + ); + let checkout_payload = git_write_payload( + &root, + "checkout", + serde_json::json!({ "reference": checkout_ref, "referenceKind": "local" }), + ); + cx.spawn(async move |this, cx| { + let create_task = client.execute::( + &cx, + "git.write", + create_payload, + ); + let create_result = create_task.await; + let final_result = match create_result { + Ok(_) => { + let checkout_task = client.execute::( + &cx, + "git.write", + checkout_payload, + ); + checkout_task.await.map(|_| ()) + } + Err(err) => Err(err), + }; + match final_result { + Ok(()) => { + let _ = this.update(cx, |view, cx| { + view.busy = false; + cx.emit(BranchManagerEvent::CheckoutDone); + }); + } + Err(err) => { + let _ = this.update(cx, |view, cx| { + view.busy = false; + view.error = Some(err); + cx.notify(); + }); + } + } + }) + .detach(); + } + + /// 当前 tab 下按 query 过滤后的分支(大小写不敏感包含)。 + fn filtered_branches(&self) -> Vec { + let q = self.query.trim().to_lowercase(); + self.branches + .iter() + .filter(|b| q.is_empty() || b.to_lowercase().contains(&q)) + .cloned() + .collect() + } + + /// 当前 tab 下按 query 过滤后的工作树(按分支名或路径匹配)。 + fn filtered_worktrees(&self) -> Vec { + let q = self.query.trim().to_lowercase(); + self.worktrees + .iter() + .filter(|w| { + q.is_empty() + || w.path.to_lowercase().contains(&q) + || w.branch + .as_deref() + .unwrap_or("") + .to_lowercase() + .contains(&q) + }) + .cloned() + .collect() + } + + /// 当前仓库在 repositories tab 下是否匹配 query。 + fn repo_matches(&self) -> bool { + let q = self.query.trim().to_lowercase(); + q.is_empty() + || self.repo_path.to_lowercase().contains(&q) + || repo_name(&self.repo_path).to_lowercase().contains(&q) + } + + /// 计数行文案(`{count}` 占位在渲染时替换,单复数按英文区分键)。 + fn count_text(&self, cx: &gpui_kit::App) -> String { + let (key, count) = match self.active_tab { + BranchTab::Repositories => { + let n = usize::from(self.repo_matches()); + ( + if n == 1 { + "git.repositoryCount" + } else { + "git.repositoriesCount" + }, + n, + ) + } + BranchTab::Branches => { + let n = self.filtered_branches().len(); + ( + if n == 1 { + "git.branchCount" + } else { + "git.branchesCount" + }, + n, + ) + } + BranchTab::Worktrees => { + let n = self.filtered_worktrees().len(); + ( + if n == 1 { + "git.worktreeCount" + } else { + "git.worktreesCount" + }, + n, + ) + } + }; + crate::i18n::menu_text(cx, key).replace("{count}", &count.to_string()) + } +} + +/// 构造 `git.write` 全字段 camelCase payload(String 无值传 null,bool 传 false)。 +fn git_write_payload(root: &str, operation: &str, extra: serde_json::Value) -> serde_json::Value { + let mut payload = serde_json::json!({ + "root": root, + "operation": operation, + "paths": [], + "reference": null, + "gitReference": null, + "referenceKind": null, + "revision": null, + "name": null, + "message": null, + "remote": null, + "destination": null, + "mode": null, + "includeUntracked": false, + "checkout": false, + "amend": false, + "force": false, + "autoStash": false, + }); + if let (Some(obj), Some(patch)) = (payload.as_object_mut(), extra.as_object()) { + for (key, value) in patch { + obj.insert(key.clone(), value.clone()); + } + } + payload +} + +/// 仓库显示名:路径最后一段。 +fn repo_name(path: &str) -> String { + path.rsplit('/').next().unwrap_or(path).to_string() +} + +impl Render for BranchManagerView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + window.focus(&self.focus_handle, cx); + + let active_tab = self.active_tab; + let placeholder: String = + crate::i18n::menu_text(cx, active_tab.placeholder_key()).to_string(); + let count_text = self.count_text(cx); + let busy = self.busy; + + // 分支 tab:精确匹配存在时不展示建分支行。 + let trimmed_query = self.query.trim().to_string(); + let has_exact_match = + !trimmed_query.is_empty() && self.branches.iter().any(|b| *b == trimmed_query); + let show_create_row = + active_tab == BranchTab::Branches && !trimmed_query.is_empty() && !has_exact_match; + let create_label = if show_create_row { + crate::i18n::menu_text(cx, "git.createNewBranch").replace("{name}", &trimmed_query) + } else { + String::new() + }; + + div() + .id("branch-manager-backdrop") + .track_focus(&self.focus_handle) + .absolute() + .inset_0() + .bg(rgba(0x00000088)) + .flex() + .items_center() + .justify_center() + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { + let key = event.keystroke.key.as_str(); + match key { + "escape" => { + cx.emit(BranchManagerEvent::Close); + } + "backspace" => { + this.query.pop(); + this.error = None; + cx.notify(); + } + "space" => { + this.query.push(' '); + this.error = None; + cx.notify(); + } + _ => { + if !event.keystroke.modifiers.control + && !event.keystroke.modifiers.alt + && !event.keystroke.modifiers.platform + { + let mut changed = false; + if let Some(ch) = &event.keystroke.key_char { + this.query.push_str(ch); + changed = true; + } else if key.chars().count() == 1 { + this.query.push_str(key); + changed = true; + } + if changed { + this.error = None; + cx.notify(); + } + } + } + } + })) + .on_mouse_down( + gpui_kit::MouseButton::Left, + cx.listener(|_this, _event, _window, cx| { + cx.emit(BranchManagerEvent::Close); + }), + ) + .child( + v_flex() + .id("branch-manager-card") + .w(px(560.0)) + .h(px(420.0)) + .bg(ThemeColors::surface()) + .border_1() + .border_color(ThemeColors::border()) + .rounded_lg() + .shadow_lg() + .overflow_hidden() + .on_mouse_down( + gpui_kit::MouseButton::Left, + cx.listener(|_this, _event, _window, cx| { + cx.stop_propagation(); + }), + ) + .child(self.render_tabs(cx)) + .child(self.render_search_row(placeholder)) + .child( + div() + .w_full() + .px_4() + .py_1() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child(count_text), + ) + .child(self.render_list(show_create_row, create_label, busy, cx)) + .when(self.error.is_some(), |card| { + card.child( + div() + .w_full() + .px_4() + .py_2() + .border_t_1() + .border_color(ThemeColors::border()) + .text_xs() + .text_color(ThemeColors::destructive()) + .child(self.error.clone().unwrap_or_default()), + ) + }), + ) + } +} + +impl BranchManagerView { + /// 顶部三 tab 行(当前高亮)。 + fn render_tabs(&self, cx: &mut Context) -> impl IntoElement { + let tabs = [ + BranchTab::Repositories, + BranchTab::Branches, + BranchTab::Worktrees, + ]; + h_flex() + .w_full() + .items_center() + .gap_1() + .px_3() + .py_2() + .border_b_1() + .border_color(ThemeColors::border()) + .children(tabs.into_iter().enumerate().map(|(ix, tab)| { + let label: String = crate::i18n::menu_text(cx, tab.title_key()).to_string(); + let selected = tab == self.active_tab; + Button::new(format!("branch-manager-tab-{ix}")) + .small() + .ghost() + .label(label) + .selected(selected) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.active_tab = tab; + cx.notify(); + })) + })) + } + + /// 搜索行:图标 + 查询串/占位 + 静态光标(仿 quick_open 输入行)。 + fn render_search_row(&self, placeholder: String) -> impl IntoElement { + h_flex() + .h(px(48.0)) + .w_full() + .items_center() + .gap_2p5() + .px_4() + .border_b_1() + .border_color(ThemeColors::border()) + .child( + Icon::new(IconName::Search) + .size(px(16.0)) + .text_color(ThemeColors::primary()), + ) + .child( + h_flex() + .flex_1() + .items_center() + .gap_1() + .child( + div() + .text_sm() + .text_color(if self.query.is_empty() { + ThemeColors::subtle_foreground() + } else { + ThemeColors::foreground() + }) + .child(if self.query.is_empty() { + placeholder + } else { + self.query.clone() + }), + ) + .child(div().w(px(2.0)).h(px(16.0)).bg(ThemeColors::primary())), + ) + } + + /// 按当前 tab 渲染滚动列表。 + fn render_list( + &self, + show_create_row: bool, + create_label: String, + busy: bool, + cx: &mut Context, + ) -> impl IntoElement { + let tab = self.active_tab; + let query_empty = self.query.trim().is_empty(); + div() + .flex_1() + .w_full() + .overflow_y_scrollbar() + .py_1() + .when(tab == BranchTab::Branches, |list| { + let filtered = self.filtered_branches(); + let empty_key = if query_empty { + "git.noBranchesFound" + } else { + "git.noMatchingBranches" + }; + let empty_text: String = crate::i18n::menu_text(cx, empty_key).to_string(); + let current = self.current_branch.clone(); + list.when(show_create_row, |list| { + list.child(create_branch_row(create_label, busy, cx)) + }) + .when(filtered.is_empty(), |list| { + list.child(empty_row(empty_text)) + }) + .children( + filtered + .into_iter() + .enumerate() + .map(|(ix, name)| branch_row(ix, name, current.clone(), busy, cx)), + ) + }) + .when(tab == BranchTab::Worktrees, |list| { + let filtered = self.filtered_worktrees(); + let empty_text: String = + crate::i18n::menu_text(cx, "git.noMatchingBranches").to_string(); + list.when(filtered.is_empty(), |list| { + list.child(empty_row(empty_text)) + }) + .children( + filtered + .into_iter() + .enumerate() + .map(|(ix, info)| worktree_row(ix, info, busy, cx)), + ) + }) + .when(tab == BranchTab::Repositories, |list| { + if self.repo_matches() { + list.child(repository_row( + repo_name(&self.repo_path), + self.repo_path.clone(), + )) + } else { + let empty_text: String = + crate::i18n::menu_text(cx, "git.noMatchingBranches").to_string(); + list.child(empty_row(empty_text)) + } + }) + } +} + +/// 建分支首行(点击从搜索串建分支并切过去)。 +fn create_branch_row( + label: String, + busy: bool, + cx: &mut Context, +) -> impl IntoElement { + h_flex() + .id("branch-manager-create") + .h(px(32.0)) + .w_full() + .mx_2() + .px_2p5() + .items_center() + .gap_2() + .rounded_md() + .cursor_pointer() + .hover(|h| h.bg(ThemeColors::accent())) + .child( + Icon::new(IconName::Plus) + .size(px(14.0)) + .text_color(if busy { + ThemeColors::muted_foreground() + } else { + ThemeColors::primary() + }), + ) + .child( + div() + .flex_1() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(ThemeColors::foreground()) + .child(label), + ) + .on_click(cx.listener(|this, _event, _window, cx| { + if this.busy { + return; + } + let name = this.query.trim().to_string(); + this.create_and_checkout(name, cx); + })) + .into_any_element() +} + +/// 分支行:名 + 当前分支打勾,点击检出。 +fn branch_row( + ix: usize, + name: String, + current: Option, + busy: bool, + cx: &mut Context, +) -> gpui_kit::AnyElement { + let is_current = Some(name.as_str()) == current.as_deref(); + let target = name.clone(); + h_flex() + .id(("branch-manager-branch", ix)) + .h(px(32.0)) + .w_full() + .mx_2() + .px_2p5() + .items_center() + .gap_2() + .rounded_md() + .cursor_pointer() + .hover(|h| h.bg(ThemeColors::accent())) + .child( + Icon::new(IconName::GitBranch) + .size(px(14.0)) + .text_color(if is_current { + ThemeColors::primary() + } else { + ThemeColors::muted_foreground() + }), + ) + .child( + div() + .flex_1() + .truncate() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(ThemeColors::foreground()) + .child(name), + ) + .when(is_current, |row| { + row.child( + Icon::new(IconName::Check) + .size(px(14.0)) + .text_color(ThemeColors::primary()), + ) + }) + .on_click(cx.listener(move |this, _event, _window, cx| { + if busy { + return; + } + this.checkout(target.clone(), cx); + })) + .into_any_element() +} + +/// 工作树行:分支名(无分支显示路径)+ 路径副行 + 当前打勾,点击打开对应项目。 +fn worktree_row( + ix: usize, + info: WorktreeInfo, + busy: bool, + cx: &mut Context, +) -> gpui_kit::AnyElement { + let path = info.path.clone(); + let title = info.branch.clone().unwrap_or_else(|| info.path.clone()); + let is_current = info.is_current; + h_flex() + .id(("branch-manager-worktree", ix)) + .h(px(40.0)) + .w_full() + .mx_2() + .px_2p5() + .items_center() + .gap_2() + .rounded_md() + .cursor_pointer() + .hover(|h| h.bg(ThemeColors::accent())) + .child( + Icon::new(IconName::Folder) + .size(px(14.0)) + .text_color(ThemeColors::muted_foreground()), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .child( + div() + .w_full() + .truncate() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(ThemeColors::foreground()) + .child(title), + ) + .child( + div() + .w_full() + .truncate() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child(info.path.clone()), + ), + ) + .when(is_current, |row| { + row.child( + Icon::new(IconName::Check) + .size(px(14.0)) + .text_color(ThemeColors::primary()), + ) + }) + .on_click(cx.listener(move |_this, _event, _window, cx| { + if busy { + return; + } + cx.emit(BranchManagerEvent::OpenWorktree(path.clone())); + })) + .into_any_element() +} + +/// 仓库行:当前仓库名 + 路径 + 打勾,点击无动作。 +fn repository_row(name: String, path: String) -> gpui_kit::AnyElement { + h_flex() + .id("branch-manager-repo") + .h(px(40.0)) + .w_full() + .mx_2() + .px_2p5() + .items_center() + .gap_2() + .rounded_md() + .child( + Icon::new(IconName::Folder) + .size(px(14.0)) + .text_color(ThemeColors::primary()), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .child( + div() + .w_full() + .truncate() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(ThemeColors::foreground()) + .child(name), + ) + .child( + div() + .w_full() + .truncate() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child(path), + ), + ) + .child( + Icon::new(IconName::Check) + .size(px(14.0)) + .text_color(ThemeColors::primary()), + ) + .into_any_element() +} + +/// 空态行。 +fn empty_row(text: String) -> gpui_kit::AnyElement { + div() + .w_full() + .py_8() + .text_center() + .text_sm() + .text_color(ThemeColors::subtle_foreground()) + .child(text) + .into_any_element() +} diff --git a/linux/src/workbench/editor.rs b/linux/src/workbench/editor.rs index 41db6953e..1e51a36e0 100644 --- a/linux/src/workbench/editor.rs +++ b/linux/src/workbench/editor.rs @@ -1,15 +1,16 @@ use gpui_kit::assets::IconName; -use gpui_kit::component::input::{Editor, EditorState, InputEvent}; +use gpui_kit::component::input::{Editor, EditorState, InputEvent, Position}; use gpui_kit::component::scroll::ScrollableElement as _; use gpui_kit::component::{h_flex, v_flex, Icon}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, relative, AppContext as _, Context, Entity, FontWeight, InteractiveElement as _, - IntoElement, ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, - Subscription, Window, + div, px, relative, AppContext as _, ClipboardItem, Context, Entity, FontWeight, + InteractiveElement as _, IntoElement, ParentElement as _, Render, + StatefulInteractiveElement as _, Styled as _, Subscription, Window, }; use crate::core::CoreClient; +use crate::settings; use crate::theme::ThemeColors; #[derive(Debug, Clone)] @@ -36,6 +37,12 @@ pub struct EditorView { pub encoding: String, /// 当前活动标签对应的上游编辑器状态,随视图生命周期常驻并复用。 editor_state: Entity, + /// 自动换行开关镜像(上游无 getter,本地持有后取反下发)。 + soft_wrap: bool, + /// 行号显示开关镜像。 + show_line_numbers: bool, + /// 空白字符显示开关镜像。 + show_whitespace: bool, /// 已同步到 `editor_state` 的标签索引。 synced_tab: Option, /// 活动标签内容被外部替换(重新打开文件)时置位,渲染时重新灌入编辑器。 @@ -43,11 +50,22 @@ pub struct EditorView { /// 监听编辑器文本变化以回写标签内容与脏标记。 _editor_subscription: Subscription, client: CoreClient, + /// 撤销/重做历史(全文快照,栈顶恒等于编辑器当前值)。 + undo_stack: Vec, + redo_stack: Vec, + /// 已关闭标签(标签快照,原索引),后进先出供恢复。 + closed_stack: Vec<(EditorTab, usize)>, } impl EditorView { pub fn new(workspace_root: String, window: &mut Window, cx: &mut Context) -> Self { - let editor_state = cx.new(|cx| EditorState::new(window, cx).line_number(true)); + let soft_wrap = settings::get(cx).word_wrap; + let show_line_numbers = settings::get(cx).line_numbers; + let editor_state = cx.new(|cx| { + EditorState::new(window, cx) + .line_number(show_line_numbers) + .soft_wrap(soft_wrap) + }); let _editor_subscription = cx.subscribe_in( &editor_state, window, @@ -64,10 +82,16 @@ impl EditorView { active_tab_index: None, encoding: "UTF-8".to_string(), editor_state, + soft_wrap, + show_line_numbers, + show_whitespace: false, synced_tab: None, sync_needed: false, _editor_subscription, client: CoreClient::new(), + undo_stack: Vec::new(), + redo_stack: Vec::new(), + closed_stack: Vec::new(), } } @@ -108,6 +132,7 @@ impl EditorView { /// 关闭指定索引的标签页 pub fn close_tab(&mut self, index: usize, cx: &mut Context) { if index < self.tabs.len() { + self.record_closed(index); self.tabs.remove(index); if self.tabs.is_empty() { self.active_tab_index = None; @@ -161,10 +186,18 @@ impl EditorView { let value = self.editor_state.read(cx).value().to_string(); if let Some(tab) = self.tabs.get_mut(index) { if tab.content != value { - tab.content = value; + tab.content = value.clone(); tab.is_dirty = true; } } + if self.undo_stack.last().is_none_or(|top| *top != value) { + self.undo_stack.push(value); + if self.undo_stack.len() > 100 { + let overflow = self.undo_stack.len() - 100; + self.undo_stack.drain(..overflow); + } + self.redo_stack.clear(); + } cx.notify(); } @@ -202,6 +235,546 @@ impl EditorView { .detach(); } + /// 全文替换并记入撤销历史(`set_value` 不触发 Change,需手动维护历史与标签同步)。 + fn commit_full_text(&mut self, new_text: String, window: &mut Window, cx: &mut Context) { + let current = self.editor_state.read(cx).value().to_string(); + if current == new_text { + return; + } + self.undo_stack.push(current); + self.undo_stack.push(new_text.clone()); + if self.undo_stack.len() > 100 { + let overflow = self.undo_stack.len() - 100; + self.undo_stack.drain(..overflow); + } + self.redo_stack.clear(); + self.editor_state.update(cx, |editor, cx| { + editor.set_value(new_text.clone(), window, cx); + }); + if let Some(index) = self.active_tab_index { + if let Some(tab) = self.tabs.get_mut(index) { + tab.content = new_text; + tab.is_dirty = true; + } + } + cx.notify(); + } + + /// 撤销到上一个历史快照(空栈或无更早状态直接返回)。 + pub fn undo(&mut self, window: &mut Window, cx: &mut Context) { + let current = self.editor_state.read(cx).value().to_string(); + if self.undo_stack.last().is_none_or(|top| *top != current) { + return; + } + self.undo_stack.pop(); + let Some(prev) = self.undo_stack.last().cloned() else { + self.undo_stack.push(current); + return; + }; + self.redo_stack.push(current); + if self.redo_stack.len() > 100 { + self.redo_stack.remove(0); + } + self.editor_state.update(cx, |editor, cx| { + editor.set_value(prev.clone(), window, cx); + }); + if let Some(index) = self.active_tab_index { + if let Some(tab) = self.tabs.get_mut(index) { + tab.content = prev; + tab.is_dirty = true; + } + } + cx.notify(); + } + + /// 重做上一次撤销(空栈直接返回)。 + pub fn redo(&mut self, window: &mut Window, cx: &mut Context) { + let Some(next) = self.redo_stack.pop() else { + return; + }; + let current = self.editor_state.read(cx).value().to_string(); + if current == next { + self.redo_stack.push(next); + return; + } + self.undo_stack.push(next.clone()); + if self.undo_stack.len() > 100 { + let overflow = self.undo_stack.len() - 100; + self.undo_stack.drain(..overflow); + } + self.editor_state.update(cx, |editor, cx| { + editor.set_value(next.clone(), window, cx); + }); + if let Some(index) = self.active_tab_index { + if let Some(tab) = self.tabs.get_mut(index) { + tab.content = next; + tab.is_dirty = true; + } + } + cx.notify(); + } + + /// 全选当前编辑器文本。 + pub fn select_all(&mut self, window: &mut Window, cx: &mut Context) { + if self.active_tab_index.is_none() { + return; + } + self.editor_state.update(cx, |editor, cx| { + editor.select_all(window, cx); + }); + } + + /// 复制选区到剪贴板(空选区直接返回)。 + pub fn copy(&mut self, cx: &mut Context) { + if self.active_tab_index.is_none() { + return; + } + let text = self.editor_state.read(cx).selected_value().to_string(); + if text.is_empty() { + return; + } + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } + + /// 剪切选区到剪贴板(空选区直接返回)。 + pub fn cut(&mut self, window: &mut Window, cx: &mut Context) { + if self.active_tab_index.is_none() { + return; + } + let text = self.editor_state.read(cx).selected_value().to_string(); + if text.is_empty() { + return; + } + cx.write_to_clipboard(ClipboardItem::new_string(text)); + self.editor_state.update(cx, |editor, cx| { + editor.replace("", window, cx); + }); + } + + /// 粘贴剪贴板文本(无内容直接返回;有选区则替换,否则在光标处插入)。 + pub fn paste(&mut self, window: &mut Window, cx: &mut Context) { + if self.active_tab_index.is_none() { + return; + } + let Some(item) = cx.read_from_clipboard() else { + return; + }; + let text = item.text().unwrap_or_default().to_string(); + if text.is_empty() { + return; + } + let range = self.editor_state.read(cx).selected_range(); + if range.start != range.end { + self.editor_state.update(cx, |editor, cx| { + editor.replace(text, window, cx); + }); + } else { + self.editor_state.update(cx, |editor, cx| { + editor.insert(text, window, cx); + }); + } + } + + /// 当前光标行(0-based)与编辑器全文。 + fn active_row(&mut self, cx: &mut Context) -> Option<(usize, String)> { + if self.active_tab_index.is_none() { + return None; + } + let state = self.editor_state.read(cx); + let row = state.cursor_position().line as usize; + let value = state.value().to_string(); + Some((row, value)) + } + + /// 全文替换后把光标移到指定行首。 + fn move_cursor_to_row(&mut self, row: usize, window: &mut Window, cx: &mut Context) { + self.editor_state.update(cx, |editor, cx| { + editor.set_cursor_position(Position::new(row as u32, 0), window, cx); + }); + } + + /// 复制当前行到下一行。 + pub fn duplicate_line(&mut self, window: &mut Window, cx: &mut Context) { + let Some((row, value)) = self.active_row(cx) else { + return; + }; + let mut lines: Vec = value.split('\n').map(str::to_string).collect(); + if lines.is_empty() { + return; + } + let row = row.min(lines.len() - 1); + lines.insert(row + 1, lines[row].clone()); + self.commit_full_text(lines.join("\n"), window, cx); + self.move_cursor_to_row(row + 1, window, cx); + } + + /// 删除当前行(含换行,末行特殊处理)。 + pub fn delete_line(&mut self, window: &mut Window, cx: &mut Context) { + let Some((row, value)) = self.active_row(cx) else { + return; + }; + let mut lines: Vec = value.split('\n').map(str::to_string).collect(); + if lines.is_empty() { + return; + } + let row = row.min(lines.len() - 1); + lines.remove(row); + self.commit_full_text(lines.join("\n"), window, cx); + if !lines.is_empty() { + self.move_cursor_to_row(row.min(lines.len() - 1), window, cx); + } + } + + /// 当前行与上一行交换(首行直接返回)。 + pub fn move_line_up(&mut self, window: &mut Window, cx: &mut Context) { + let Some((row, value)) = self.active_row(cx) else { + return; + }; + let mut lines: Vec = value.split('\n').map(str::to_string).collect(); + if lines.is_empty() { + return; + } + let row = row.min(lines.len() - 1); + if row == 0 { + return; + } + lines.swap(row, row - 1); + self.commit_full_text(lines.join("\n"), window, cx); + self.move_cursor_to_row(row - 1, window, cx); + } + + /// 当前行与下一行交换(末行直接返回)。 + pub fn move_line_down(&mut self, window: &mut Window, cx: &mut Context) { + let Some((row, value)) = self.active_row(cx) else { + return; + }; + let mut lines: Vec = value.split('\n').map(str::to_string).collect(); + if lines.is_empty() { + return; + } + let row = row.min(lines.len() - 1); + if row + 1 >= lines.len() { + return; + } + lines.swap(row, row + 1); + self.commit_full_text(lines.join("\n"), window, cx); + self.move_cursor_to_row(row + 1, window, cx); + } + + /// 切换当前行注释(已注释则去注释,保留缩进)。 + pub fn toggle_comment(&mut self, window: &mut Window, cx: &mut Context) { + let Some(index) = self.active_tab_index else { + return; + }; + let path = self + .tabs + .get(index) + .map(|tab| tab.path.clone()) + .unwrap_or_default(); + let Some((row, value)) = self.active_row(cx) else { + return; + }; + let mut lines: Vec = value.split('\n').map(str::to_string).collect(); + if lines.is_empty() { + return; + } + let row = row.min(lines.len() - 1); + let line = lines[row].clone(); + let indent_len = line.len() - line.trim_start().len(); + let (indent, body) = line.split_at(indent_len); + let new_line = match Self::comment_style(&path) { + CommentStyle::Line(prefix) => { + if let Some(rest) = body.strip_prefix(prefix) { + let rest = rest + .strip_prefix(' ') + .or_else(|| rest.strip_prefix('\t')) + .unwrap_or(rest); + format!("{indent}{rest}") + } else { + format!("{indent}{prefix} {body}") + } + } + CommentStyle::Block => { + let trimmed = line.trim(); + if let Some(inner) = trimmed + .strip_prefix("")) + { + format!("{indent}{}", inner.trim()) + } else { + format!("{indent}") + } + } + }; + lines[row] = new_line; + self.commit_full_text(lines.join("\n"), window, cx); + self.move_cursor_to_row(row, window, cx); + } + + /// 记录关闭的标签以供恢复(上限 30,超限去头;脏标签直接关,与 `close_tab` 一致)。 + fn record_closed(&mut self, index: usize) { + if let Some(tab) = self.tabs.get(index).cloned() { + self.closed_stack.push((tab, index)); + if self.closed_stack.len() > 30 { + let overflow = self.closed_stack.len() - 30; + self.closed_stack.drain(..overflow); + } + } + } + + /// 关闭全部标签(逐个进恢复栈)。 + pub fn close_all_tabs(&mut self, cx: &mut Context) { + if self.tabs.is_empty() { + return; + } + for (index, tab) in self.tabs.iter().enumerate() { + self.closed_stack.push((tab.clone(), index)); + } + if self.closed_stack.len() > 30 { + let overflow = self.closed_stack.len() - 30; + self.closed_stack.drain(..overflow); + } + self.tabs.clear(); + self.active_tab_index = None; + self.sync_needed = true; + cx.notify(); + } + + /// 关闭除活动标签外的全部标签。 + pub fn close_other_tabs(&mut self, cx: &mut Context) { + let Some(active) = self.active_tab_index else { + return; + }; + if active >= self.tabs.len() { + return; + } + let mut kept = None; + for (index, tab) in self.tabs.drain(..).enumerate() { + if index == active { + kept = Some(tab); + } else { + self.closed_stack.push((tab, index)); + } + } + if self.closed_stack.len() > 30 { + let overflow = self.closed_stack.len() - 30; + self.closed_stack.drain(..overflow); + } + if let Some(tab) = kept { + self.tabs.push(tab); + self.active_tab_index = Some(0); + } else { + self.active_tab_index = None; + } + self.sync_needed = true; + cx.notify(); + } + + /// 关闭全部未修改(`is_dirty == false`)的标签。 + pub fn close_saved_tabs(&mut self, cx: &mut Context) { + if self.tabs.is_empty() { + return; + } + let active_path = self + .active_tab_index + .and_then(|index| self.tabs.get(index)) + .map(|tab| tab.path.clone()); + let mut kept = Vec::new(); + for (index, tab) in self.tabs.drain(..).enumerate() { + if tab.is_dirty { + kept.push(tab); + } else { + self.closed_stack.push((tab, index)); + } + } + if self.closed_stack.len() > 30 { + let overflow = self.closed_stack.len() - 30; + self.closed_stack.drain(..overflow); + } + self.tabs = kept; + self.active_tab_index = + active_path.and_then(|path| self.tabs.iter().position(|tab| tab.path == path)); + self.sync_needed = true; + cx.notify(); + } + + /// 关闭活动标签左侧的全部标签。 + pub fn close_tabs_to_left(&mut self, cx: &mut Context) { + let Some(active) = self.active_tab_index else { + return; + }; + if active == 0 || active >= self.tabs.len() { + return; + } + for index in 0..active { + if let Some(tab) = self.tabs.get(index).cloned() { + self.closed_stack.push((tab, index)); + } + } + if self.closed_stack.len() > 30 { + let overflow = self.closed_stack.len() - 30; + self.closed_stack.drain(..overflow); + } + self.tabs.drain(..active); + self.active_tab_index = Some(0); + self.sync_needed = true; + cx.notify(); + } + + /// 关闭活动标签右侧的全部标签。 + pub fn close_tabs_to_right(&mut self, cx: &mut Context) { + let Some(active) = self.active_tab_index else { + return; + }; + if active >= self.tabs.len() || active + 1 >= self.tabs.len() { + return; + } + for index in active + 1..self.tabs.len() { + if let Some(tab) = self.tabs.get(index).cloned() { + self.closed_stack.push((tab, index)); + } + } + if self.closed_stack.len() > 30 { + let overflow = self.closed_stack.len() - 30; + self.closed_stack.drain(..overflow); + } + self.tabs.truncate(active + 1); + self.sync_needed = true; + cx.notify(); + } + + /// 恢复最近关闭的标签到原位(后进先出,活动标签指向恢复项)。 + pub fn reopen_closed_tab(&mut self, cx: &mut Context) { + let Some((tab, index)) = self.closed_stack.pop() else { + return; + }; + if let Some(pos) = self.tabs.iter().position(|t| t.path == tab.path) { + self.active_tab_index = Some(pos); + } else { + let insert_at = index.min(self.tabs.len()); + self.tabs.insert(insert_at, tab); + self.active_tab_index = Some(insert_at); + } + self.sync_needed = true; + cx.notify(); + } + + /// 保存全部脏标签至磁盘(仿 `save_active` 的落盘写法)。 + pub fn save_all_tabs(&mut self, cx: &mut Context) { + if self.tabs.is_empty() { + return; + } + // 以编辑器内的实时文本为准,避免依赖事件回写的时序。 + let live = self.editor_state.read(cx).value().to_string(); + let mut jobs: Vec<(String, String)> = Vec::new(); + for (index, tab) in self.tabs.iter().enumerate() { + if !tab.is_dirty { + continue; + } + let text = if Some(index) == self.active_tab_index { + live.clone() + } else { + tab.content.clone() + }; + jobs.push((tab.path.clone(), text)); + } + if jobs.is_empty() { + return; + } + let root = self.workspace_root.clone(); + let client = self.client.clone(); + cx.spawn(async move |this, cx| { + for (path, text) in jobs { + if client.write_file(&cx, &root, &path, &text).await.is_ok() { + let _ = this.update(cx, |ed, cx| { + if let Some(tab) = ed.tabs.iter_mut().find(|tab| tab.path == path) { + tab.is_dirty = false; + } + cx.notify(); + }); + } + } + }) + .detach(); + } + + /// 切换到下一个标签(循环)。 + pub fn goto_next_tab(&mut self, cx: &mut Context) { + if self.tabs.is_empty() { + return; + } + let next = match self.active_tab_index { + Some(index) => (index + 1) % self.tabs.len(), + None => 0, + }; + self.active_tab_index = Some(next); + self.sync_needed = true; + cx.notify(); + } + + /// 切换到上一个标签(循环)。 + pub fn goto_prev_tab(&mut self, cx: &mut Context) { + if self.tabs.is_empty() { + return; + } + let prev = match self.active_tab_index { + Some(0) | None => self.tabs.len() - 1, + Some(index) => (index - 1) % self.tabs.len(), + }; + self.active_tab_index = Some(prev); + self.sync_needed = true; + cx.notify(); + } + + /// 跳到指定行(1-based,钳制到总行数;不置同步标记以免覆盖光标)。 + pub fn go_to_line(&mut self, line: u32, window: &mut Window, cx: &mut Context) { + if self.active_tab_index.is_none() { + return; + } + let total = self + .editor_state + .read(cx) + .value() + .to_string() + .split('\n') + .count() + .max(1) as u32; + let clamped = line.max(1).min(total); + self.editor_state.update(cx, |editor, cx| { + editor.set_cursor_position(Position::new(clamped - 1, 0), window, cx); + }); + self.sync_needed = false; + } + + /// 取反自动换行并同步设置落盘。 + pub fn toggle_wrap(&mut self, window: &mut Window, cx: &mut Context) { + self.soft_wrap = !self.soft_wrap; + let wrap = self.soft_wrap; + self.editor_state.update(cx, |editor, cx| { + editor.set_soft_wrap(wrap, window, cx); + }); + settings::update(cx, |s| s.word_wrap = wrap); + } + + /// 取反行号显示并同步设置落盘。 + pub fn toggle_line_numbers(&mut self, window: &mut Window, cx: &mut Context) { + self.show_line_numbers = !self.show_line_numbers; + let show = self.show_line_numbers; + self.editor_state.update(cx, |editor, cx| { + editor.set_line_number(show, window, cx); + }); + settings::update(cx, |s| s.line_numbers = show); + } + + /// 取反空白字符显示(无对应设置项,仅编辑器侧生效)。 + pub fn toggle_whitespace(&mut self, window: &mut Window, cx: &mut Context) { + self.show_whitespace = !self.show_whitespace; + let show = self.show_whitespace; + self.editor_state.update(cx, |editor, cx| { + editor.set_show_whitespaces(show, window, cx); + }); + } + /// 获取推断的代码语言名称 pub fn language_name(path: &str) -> &'static str { if path.ends_with(".rs") { @@ -230,6 +803,24 @@ impl EditorView { "Plain Text" } } + + /// 按文件扩展名推断行注释风格(`language_name` 缺少 py/html/c 等分支,故直接匹配扩展名)。 + fn comment_style(path: &str) -> CommentStyle { + let ext = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase(); + match ext.as_str() { + "py" | "sh" | "sql" | "toml" | "yaml" | "yml" | "rb" | "pl" => CommentStyle::Line("#"), + "html" | "xml" => CommentStyle::Block, + _ => CommentStyle::Line("//"), + } + } +} + +/// 行注释风格:行前缀或行级块包裹。 + +/// 行注释风格:行前缀或行级块包裹。 +enum CommentStyle { + Line(&'static str), + Block, } fn is_code_file(name: &str) -> bool { @@ -430,7 +1021,8 @@ impl Render for EditorView { Editor::new(&state) .h(relative(1.0)) .bordered(false) - .readonly(false), + .readonly(false) + .text_size(px(crate::settings::get(cx).font_size)), ) }, ) diff --git a/linux/src/workbench/go_to_line.rs b/linux/src/workbench/go_to_line.rs new file mode 100644 index 000000000..d029abac4 --- /dev/null +++ b/linux/src/workbench/go_to_line.rs @@ -0,0 +1,219 @@ +//! 跳转到行(Go to Line)小弹窗。 +//! +//! 对齐 Tauri `editor.goToLine` 入口:数字输入 + Enter 确认,确认后通过 +//! [`GoToLineEvent::Confirm`] 把 1-based 行号交给上层,由 `EditorView::go_to_line` +//! 做钳制与跳转;本模块不直接操作编辑器。 + +use gpui_kit::assets::IconName; +use gpui_kit::component::{h_flex, v_flex, Icon}; +use gpui_kit::{ + div, px, rgba, Context, EventEmitter, FocusHandle, InteractiveElement as _, IntoElement, + KeyDownEvent, ParentElement as _, Render, Styled as _, Window, +}; + +use crate::theme::ThemeColors; + +/// 跳转到行弹窗对外事件。 +#[derive(Debug, Clone)] +pub enum GoToLineEvent { + /// 确认跳转,携带 1-based 行号。 + Confirm(u32), + /// 请求关闭弹窗。 + Close, +} + +/// 居中的单行输入小弹窗。 +pub struct GoToLineModal { + pub input: String, + pub focus_handle: FocusHandle, +} + +impl EventEmitter for GoToLineModal {} + +impl GoToLineModal { + pub fn new(cx: &mut Context) -> Self { + Self { + input: String::new(), + focus_handle: cx.focus_handle(), + } + } + + /// 复位到初始状态:清空输入。 + pub fn reset(&mut self, cx: &mut Context) { + self.input.clear(); + cx.notify(); + } + + /// 解析输入并确认:仅接受 `>= 1` 的数字,其余输入忽略。 + fn confirm(&self, cx: &mut Context) { + if let Ok(n) = self.input.trim().parse::() { + if n >= 1 { + cx.emit(GoToLineEvent::Confirm(n)); + } + } + } +} + +impl Render for GoToLineModal { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // 请求聚焦以接收按键输入 + window.focus(&self.focus_handle, cx); + + // 全屏半透明遮罩:点击空白处关闭 + div() + .id("go-to-line-backdrop") + .track_focus(&self.focus_handle) + .absolute() + .inset_0() + .bg(rgba(0x00000088)) + .flex() + .items_center() + .justify_center() + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { + let key = event.keystroke.key.as_str(); + match key { + "escape" => { + cx.emit(GoToLineEvent::Close); + } + "enter" => { + this.confirm(cx); + } + "backspace" => { + this.input.pop(); + cx.notify(); + } + _ => { + // 无修饰键时只接受 ASCII 数字,最多 6 位。 + if !event.keystroke.modifiers.control + && !event.keystroke.modifiers.alt + && !event.keystroke.modifiers.platform + && this.input.len() < 6 + { + let mut digit: Option = None; + if let Some(ch) = &event.keystroke.key_char { + let mut chars = ch.chars(); + if let (Some(c), None) = (chars.next(), chars.next()) { + if c.is_ascii_digit() { + digit = Some(c); + } + } + } else if key.chars().count() == 1 { + if let Some(c) = key.chars().next() { + if c.is_ascii_digit() { + digit = Some(c); + } + } + } + if let Some(c) = digit { + this.input.push(c); + cx.notify(); + } + } + } + } + })) + .on_mouse_down( + gpui_kit::MouseButton::Left, + cx.listener(|_this, _event, _window, cx| { + cx.emit(GoToLineEvent::Close); + }), + ) + .child( + v_flex() + .id("go-to-line-card") + .w(px(400.0)) + .bg(ThemeColors::surface()) + .border_1() + .border_color(ThemeColors::border()) + .rounded_lg() + .shadow_lg() + .overflow_hidden() + .on_mouse_down( + gpui_kit::MouseButton::Left, + cx.listener(|_this, _event, _window, cx| { + // 卡片内点击不冒泡到遮罩,避免误关闭 + cx.stop_propagation(); + }), + ) + .child( + // 标题行 + h_flex() + .h(px(36.0)) + .w_full() + .items_center() + .px_4() + .border_b_1() + .border_color(ThemeColors::border()) + .text_sm() + .text_color(ThemeColors::foreground()) + .child(crate::i18n::menu_text(cx, "goToLine.title").to_string()), + ) + .child( + // 输入行:行号图标 + 输入串/占位 + Enter 徽标 + h_flex() + .h(px(52.0)) + .w_full() + .items_center() + .gap_2p5() + .px_4() + .border_b_1() + .border_color(ThemeColors::border()) + .child( + Icon::new(IconName::Hash) + .size(px(16.0)) + .text_color(ThemeColors::primary()), + ) + .child( + h_flex() + .flex_1() + .items_center() + .gap_1() + .child( + div() + .text_sm() + .text_color(if self.input.is_empty() { + ThemeColors::subtle_foreground() + } else { + ThemeColors::foreground() + }) + .child(if self.input.is_empty() { + crate::i18n::menu_text(cx, "goToLine.placeholder") + .to_string() + } else { + self.input.clone() + }), + ) + .child( + // 静态光标条,提示可输入 + div().w(px(2.0)).h(px(16.0)).bg(ThemeColors::primary()), + ), + ) + .child(shortcut_badge("Enter")), + ) + .child( + // 底部提示行 + div() + .w_full() + .px_4() + .py_2() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child(crate::i18n::menu_text(cx, "goToLine.hint").to_string()), + ), + ) + } +} + +/// 快捷键/键位徽标。 +fn shortcut_badge(label: &str) -> impl IntoElement { + div() + .px_1p5() + .py(px(1.0)) + .rounded_sm() + .bg(ThemeColors::accent()) + .border_1() + .border_color(ThemeColors::border()) + .text_xs() + .text_color(ThemeColors::muted_foreground()) + .child(label.to_string()) +} diff --git a/linux/src/workbench/mod.rs b/linux/src/workbench/mod.rs index 282778b2a..f955ddac6 100644 --- a/linux/src/workbench/mod.rs +++ b/linux/src/workbench/mod.rs @@ -1,7 +1,9 @@ pub mod activity_rail; pub mod bottom_panel; +pub mod branch_manager; pub mod command_palette; pub mod editor; +pub mod go_to_line; pub mod maven; pub mod project_dialog; pub mod quick_open; @@ -21,10 +23,14 @@ pub use activity_rail::{ #[allow(unused_imports)] pub use bottom_panel::BottomPanelView; #[allow(unused_imports)] +pub use branch_manager::{BranchManagerEvent, BranchManagerView, BranchTab}; +#[allow(unused_imports)] pub use command_palette::{CommandPaletteEvent, CommandPaletteModal}; #[allow(unused_imports)] pub use editor::EditorView; #[allow(unused_imports)] +pub use go_to_line::{GoToLineEvent, GoToLineModal}; +#[allow(unused_imports)] pub use maven::{MavenEvent, MavenView}; #[allow(unused_imports)] pub use project_dialog::{ProjectDialog, ProjectDialogEvent, ProjectDialogMode}; diff --git a/linux/src/workbench/terminal.rs b/linux/src/workbench/terminal.rs index 2bcc79919..932861752 100644 --- a/linux/src/workbench/terminal.rs +++ b/linux/src/workbench/terminal.rs @@ -131,6 +131,12 @@ impl TerminalView { } cx.notify(); } + + /// 用现有工作目录重建 PTY 会话(失败则置空并通知)。 + pub fn respawn(&mut self, cx: &mut Context) { + self.session = TerminalSession::new(120, 30, &self.working_dir).ok(); + cx.notify(); + } } impl Render for TerminalView { diff --git a/linux/src/workbench/toolbar.rs b/linux/src/workbench/toolbar.rs index 65abe8909..ef87bafd2 100644 --- a/linux/src/workbench/toolbar.rs +++ b/linux/src/workbench/toolbar.rs @@ -7,7 +7,7 @@ use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::component::menu::{DropdownMenu as _, PopupMenu, PopupMenuItem}; -use gpui_kit::component::{h_flex, v_flex, Icon, Selectable as _, Sizable as _}; +use gpui_kit::component::{h_flex, v_flex, Disableable as _, Icon, Selectable as _, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ div, px, Anchor, AnyElement, Context, EventEmitter, InteractiveElement as _, IntoElement, @@ -50,18 +50,17 @@ pub enum ToolbarEvent { OpenRecent, /// 打开最近项目中的指定路径(项目胶囊“最近项目”分组条目)。 OpenRecentProject(String), - /// 检出指定本地分支(分支胶囊下拉条目,对齐 Tauri 分支管理器检出)。 - CheckoutBranch(String), + /// 打开分支管理器弹窗(分支徽标按钮,对齐 Tauri `GitBranchManager`)。 + OpenBranchManager, } /// 应用图标:与 Tauri 端 `public/logo.png` 同一文件,编译期嵌入。 const APP_LOGO_PNG: &[u8] = include_bytes!("../../assets/logo.png"); pub struct ToolbarView { + pub workspace_root: String, pub workspace_name: String, pub git_branch: Option, - /// 本地分支列表(view 侧由 core `git.references` 同步,供分支胶囊下拉切换)。 - pub branches: Vec, /// 紧凑菜单条是否展开(对齐 Tauri `isCompactMenuVisible`)。 compact_menu_open: bool, /// 解码后的应用图标,项目菜单触发器左侧的徽标(对齐 Tauri 的 `logo.png`)。 @@ -72,15 +71,10 @@ impl EventEmitter for ToolbarView {} impl ToolbarView { pub fn new(workspace_root: &str) -> Self { - let name = workspace_root - .rsplit_once('/') - .map(|(_, n)| n.to_string()) - .unwrap_or_else(|| workspace_root.to_string()); - Self { - workspace_name: name, + workspace_root: workspace_root.to_string(), + workspace_name: workspace_dir_name(workspace_root), git_branch: None, - branches: Vec::new(), compact_menu_open: false, app_logo: std::sync::Arc::new(gpui_kit::Image::from_bytes( gpui_kit::ImageFormat::Png, @@ -94,12 +88,22 @@ impl ToolbarView { cx.notify(); } - pub fn set_branches(&mut self, branches: Vec, cx: &mut Context) { - self.branches = branches; + /// 同步当前 workspace:项目胶囊触发器名称与“打开的项目”行跟随切换。 + pub fn set_workspace_root(&mut self, root: String, cx: &mut Context) { + self.workspace_root = root.clone(); + self.workspace_name = workspace_dir_name(&root); cx.notify(); } } +/// 取 workspace 目录名(项目胶囊触发器与当前项目行主行共用)。 +fn workspace_dir_name(workspace_root: &str) -> String { + workspace_root + .rsplit_once('/') + .map(|(_, n)| n.to_string()) + .unwrap_or_else(|| workspace_root.to_string()) +} + /// 单个菜单项:i18n 键(`crate::i18n::menu_text`)+ 稳定动作 id + 快捷键展示文本。 /// /// 分组与条目顺序严格照抄 Tauri `window-menu-bar.tsx`,`action` 字符串与 @@ -155,7 +159,7 @@ const FILE_MENU: &[&[MenuEntry]] = &[ entry("menu.saveAs", "file.save_as", "Ctrl+Shift+S"), entry("menu.saveAll", "file.save_all", "Ctrl+Alt+S"), entry("menu.revertFile", "file.revert", ""), - entry("menu.showLocalHistory", "file.local_history", ""), + disabled_entry("menu.showLocalHistory", "file.local_history", ""), ], &[ entry("menu.closeTab", "file.close_editor", "Ctrl+W"), @@ -186,21 +190,21 @@ const EDIT_MENU: &[&[MenuEntry]] = &[ entry("menu.find", "edit.find", "Ctrl+F"), entry("menu.findAndReplace", "edit.find_replace", "Ctrl+Alt+F"), entry("menu.toggleComment", "edit.toggle_comment", "Ctrl+/"), - entry("menu.quickFix", "edit.quick_fix", "Ctrl+."), - entry( + disabled_entry("menu.quickFix", "edit.quick_fix", "Ctrl+."), + disabled_entry( "menu.triggerParameterHints", "edit.param_hints", "Ctrl+Shift+Space", ), - entry("menu.showHover", "edit.show_hover", "Ctrl+K Ctrl+I"), + disabled_entry("menu.showHover", "edit.show_hover", "Ctrl+K Ctrl+I"), ], &[ entry("menu.duplicateLine", "edit.duplicate_line", "Ctrl+D"), entry("menu.deleteLine", "edit.delete_line", "Ctrl+Shift+K"), entry("menu.moveLineUp", "edit.move_up", "Alt+Up"), entry("menu.moveLineDown", "edit.move_down", "Alt+Down"), - entry("menu.formatDocument", "edit.format_doc", "Ctrl+Alt+L"), - entry("menu.formatSelection", "edit.format_sel", "Ctrl+K Ctrl+F"), + disabled_entry("menu.formatDocument", "edit.format_doc", "Ctrl+Alt+L"), + disabled_entry("menu.formatSelection", "edit.format_sel", "Ctrl+K Ctrl+F"), ], &[entry( "menu.commandPalette", @@ -235,8 +239,8 @@ const VIEW_MENU: &[&[MenuEntry]] = &[ entry("menu.runAndDebug", "view.show_debug", ""), ], &[ - entry("menu.splitEditor", "view.split_editor", ""), - entry("menu.toggleMinimap", "view.toggle_minimap", ""), + disabled_entry("menu.splitEditor", "view.split_editor", ""), + disabled_entry("menu.toggleMinimap", "view.toggle_minimap", ""), entry("menu.toggleWordWrap", "view.toggle_wrap", "Alt+Z"), entry("menu.toggleLineNumbers", "view.toggle_line_numbers", ""), entry("menu.toggleRenderWhitespace", "view.toggle_whitespace", ""), @@ -255,15 +259,15 @@ const GO_MENU: &[&[MenuEntry]] = &[ entry("menu.goToLine", "go.go_to_line", "Ctrl+G"), ], &[ - entry("menu.goBack", "go.back", "Ctrl+Alt+Left"), - entry("menu.goForward", "go.forward", "Ctrl+Alt+Right"), + disabled_entry("menu.goBack", "go.back", "Ctrl+Alt+Left"), + disabled_entry("menu.goForward", "go.forward", "Ctrl+Alt+Right"), ], &[ - entry("menu.goToDefinition", "go.definition", "F12"), - entry("menu.goToImplementation", "go.implementation", "Ctrl+F12"), - entry("menu.goToTypeDefinition", "go.type_definition", ""), - entry("menu.goToReferences", "go.references", "Ctrl+B"), - entry("menu.renameSymbol", "go.rename", "F2"), + disabled_entry("menu.goToDefinition", "go.definition", "F12"), + disabled_entry("menu.goToImplementation", "go.implementation", "Ctrl+F12"), + disabled_entry("menu.goToTypeDefinition", "go.type_definition", ""), + disabled_entry("menu.goToReferences", "go.references", "Ctrl+B"), + disabled_entry("menu.renameSymbol", "go.rename", "F2"), ], &[ entry("menu.nextTab", "go.next_tab", "Ctrl+Alt+Right"), @@ -274,8 +278,8 @@ const GO_MENU: &[&[MenuEntry]] = &[ // ---- Terminal:对齐 tsx,单组无分隔线 ---- const TERMINAL_MENU: &[&[MenuEntry]] = &[&[ entry("menu.newTerminal", "terminal.new", ""), - entry("menu.splitTerminalRight", "terminal.split_right", "Ctrl+D"), - entry( + disabled_entry("menu.splitTerminalRight", "terminal.split_right", "Ctrl+D"), + disabled_entry( "menu.splitTerminalDown", "terminal.split_down", "Ctrl+Shift+D", @@ -285,15 +289,19 @@ const TERMINAL_MENU: &[&[MenuEntry]] = &[&[ // ---- Run:对齐 tsx,单组无分隔线 ---- const RUN_MENU: &[&[MenuEntry]] = &[&[ - entry("menu.startDebugging", "run.debug_start", "F5"), - entry("menu.stopDebugging", "run.debug_stop", "Shift+F5"), - entry("menu.toggleBreakpoint", "run.breakpoint", "F9"), + disabled_entry("menu.startDebugging", "run.debug_start", "F5"), + disabled_entry("menu.stopDebugging", "run.debug_stop", "Shift+F5"), + disabled_entry("menu.toggleBreakpoint", "run.breakpoint", "F9"), ]]; // ---- Tools:对齐 tsx 三组(首项 databases 禁用,对齐后端能力缺失) ---- const TOOLS_MENU: &[&[MenuEntry]] = &[ &[disabled_entry("menu.databases", "tools.database", "")], - &[entry("menu.webInspector", "tools.inspector", "Ctrl+Alt+I")], + &[disabled_entry( + "menu.webInspector", + "tools.inspector", + "Ctrl+Alt+I", + )], &[ entry("menu.preferences", "tools.settings", ""), entry("menu.keyboardShortcuts", "tools.shortcuts", ""), @@ -312,14 +320,18 @@ const HELP_MENU: &[&[MenuEntry]] = &[ &[ entry("menu.documentation", "help.docs", ""), entry("menu.keyboardShortcuts", "help.shortcuts", ""), - entry("menu.whatsNew", "help.whats_new", ""), + disabled_entry("menu.whatsNew", "help.whats_new", ""), entry("menu.changelog", "help.changelog", ""), ], &[ entry("menu.reportBug", "help.report_bug", ""), entry("menu.requestFeature", "help.feature", ""), ], - &[entry("menu.checkForUpdates", "help.check_updates", "")], + &[disabled_entry( + "menu.checkForUpdates", + "help.check_updates", + "", + )], ]; const APP_MENUS: &[AppMenu] = &[ @@ -462,6 +474,48 @@ fn recent_project_item( }) } +/// 当前项目行:复用最近项目条目的两行样式,右侧加 Check,不可点。 +/// +/// 单根架构下“打开的项目”分组只渲染当前 workspace 这一行,对齐 Tauri +/// `ProjectMenuRow` 当前项(`if (project.isActive) return`,点击无动作)。 +fn current_project_item(name: String, path: String) -> PopupMenuItem { + PopupMenuItem::element(move |_window, _cx| { + let name = name.clone(); + let path = path.clone(); + h_flex() + .w_full() + .items_center() + .gap_2() + .child( + v_flex() + .flex_1() + .min_w_0() + .child( + div() + .w_full() + .truncate() + .text_xs() + .text_color(ThemeColors::foreground()) + .child(name), + ) + .child( + div() + .w_full() + .truncate() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child(path), + ), + ) + .child( + Icon::new(IconName::Check) + .size(px(14.0)) + .text_color(ThemeColors::primary()), + ) + }) + .disabled(true) +} + /// 按分组把菜单项灌入 `PopupMenu`,分组之间自动插入分隔线。 /// /// 文案在 builder 内用 `cx`(`&mut Context`)实时解析; @@ -540,11 +594,13 @@ impl Render for ToolbarView { let t_open_projects = crate::i18n::menu_text(cx, "titleProject.openProjects").to_string(); let t_recent = crate::i18n::menu_text(cx, "titleProject.recentProjects").to_string(); let t_no_recent = crate::i18n::menu_text(cx, "titleProject.noRecentProjects").to_string(); - // 最近项目(首位最新,过滤不存在的路径,上限与持久化数组一致)。 + // 当前 workspace:打开的项目分组只渲染这一行(单根架构,对齐 Tauri 当前项打 Check)。 + let current_root = self.workspace_root.clone(); + // 最近项目(首位最新,过滤不存在的路径与当前已打开路径,上限与持久化数组一致)。 let recents: Vec<(String, String)> = settings::get(cx) .recent_projects .iter() - .filter(|p| std::path::Path::new(p).exists()) + .filter(|p| p.as_str() != current_root && std::path::Path::new(p).exists()) .take(settings::MAX_RECENT_PROJECTS) .map(|p| (settings::project_dir_name(p).to_string(), p.clone())) .collect(); @@ -580,7 +636,6 @@ impl Render for ToolbarView { )), ) .when(open, move |this| { - let v_out = v_menu.clone(); this.child(gpui_kit::deferred( gpui_kit::anchored() .anchor(Anchor::TopLeft) @@ -600,13 +655,10 @@ impl Render for ToolbarView { .border_1() .border_color(ThemeColors::border()) .shadow_lg() - // 点击菜单条以外区域时收起,对齐 Tauri 紧凑菜单的行为。 - .on_mouse_down_out(move |_, _, cx| { - v_out.update(cx, |this, cx| { - this.compact_menu_open = false; - cx.notify(); - }); - }) + // 紧凑菜单条不挂 `on_mouse_down_out`:下拉弹窗渲染在 + // overlay 层,点击弹窗项会被判为“条外”而在 click + // 完成前销毁弹窗,导致所有菜单项无法触发。 + // 收起由汉堡按钮切换与菜单项自身处理。 .children(APP_MENUS.iter().enumerate().map(|(ix, app)| { let v_item = v_menu.clone(); let key = app.key; @@ -663,6 +715,8 @@ impl Render for ToolbarView { // 项目菜单:应用图标 + 项目名 + ChevronDown,点击展开项目列表 let v = view.clone(); let name = project_name.clone(); + let current_name = project_name.clone(); + let current_root = current_root.clone(); let logo = self.app_logo.clone(); Button::new("tb-project-selector") .small() @@ -710,8 +764,12 @@ impl Render for ToolbarView { &v, )) .separator() - // 打开的项目分组标签:只展示,不可点。 + // 打开的项目分组标签:只展示,不可点;下方单根只渲染当前项目行。 .item(PopupMenuItem::label(t_open_projects.clone())) + .item(current_project_item( + current_name.clone(), + current_root.clone(), + )) .separator() .item(PopupMenuItem::label(t_recent.clone())); if recents.is_empty() { @@ -729,14 +787,13 @@ impl Render for ToolbarView { }) }) .child({ - // 分支胶囊:点击展开本地分支下拉(对齐 Tauri 分支管理器), - // 当前分支打勾并禁用,点击其他分支发射 CheckoutBranch 由 view 侧检出。 - let v = view.clone(); - let current = branch.clone(); - let branches = self.branches.clone(); + // 分支徽标:普通按钮,点击打开分支管理器弹窗 + //(对齐 Tauri `GitBranchManager`);无仓库时禁用。 + let has_repo = self.git_branch.is_some(); Button::new("tb-branch-selector") .small() .ghost() + .disabled(!has_repo) .child( Icon::new(IconName::GitBranch) .size(px(13.0)) @@ -750,46 +807,9 @@ impl Render for ToolbarView { .text_color(ThemeColors::success()) .child(branch.clone()), ) - .child( - Icon::new(IconName::ChevronDown) - .size(px(12.0)) - .text_color(ThemeColors::subtle_foreground()), - ) - .dropdown_menu(move |mut menu, _window, _cx| { - if branches.is_empty() { - menu = menu.item( - PopupMenuItem::new(current.clone()) - .icon(IconName::Check) - .disabled(true), - ); - } else { - for name in &branches { - if *name == current { - menu = menu.item( - PopupMenuItem::new(name.clone()) - .icon(IconName::Check) - .disabled(true), - ); - } else { - let v = v.clone(); - let target = name.clone(); - menu = menu.item( - PopupMenuItem::new(name.clone()).on_click( - move |_, _, cx| { - let target = target.clone(); - v.update(cx, |_this, cx| { - cx.emit(ToolbarEvent::CheckoutBranch( - target, - )); - }); - }, - ), - ); - } - } - } - menu - }) + .on_click(cx.listener(|_this, _event, _window, cx| { + cx.emit(ToolbarEvent::OpenBranchManager); + })) }), ) // 右侧:全局搜索图标按钮 + 窗口控件(对齐 Tauri `quickOpenAction` + `WindowControls`) diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index 5263035ed..0eec5d903 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -18,8 +18,10 @@ use crate::workbench::activity_rail::{ ActivityRailEvent, ActivityRailView, PluginActivityRailView, PluginRailEvent, }; use crate::workbench::bottom_panel::{BottomPanelView, BottomTab}; +use crate::workbench::branch_manager::{BranchManagerEvent, BranchManagerView}; use crate::workbench::command_palette::{CommandPaletteEvent, CommandPaletteModal}; use crate::workbench::editor::EditorView; +use crate::workbench::go_to_line::{GoToLineEvent, GoToLineModal}; use crate::workbench::maven::{MavenEvent, MavenView}; use crate::workbench::project_dialog::{ProjectDialog, ProjectDialogEvent, ProjectDialogMode}; use crate::workbench::quick_open::{QuickOpenEvent, QuickOpenModal}; @@ -54,12 +56,16 @@ pub struct WorkbenchView { pub show_search_everywhere: bool, /// 快速打开浮层是否可见 pub show_quick_open: bool, + /// 跳转到行浮层是否可见 + pub show_go_to_line: bool, /// 命令面板浮层是否可见 pub show_command_palette: bool, /// 设置模态对话框是否可见 pub show_settings_dialog: bool, /// 新建/克隆项目对话框是否可见 pub show_project_dialog: bool, + /// 分支管理器弹窗是否可见 + pub show_branch_manager: bool, /// 右侧 Maven 导航工具窗口是否可见 pub show_maven: bool, @@ -83,12 +89,16 @@ pub struct WorkbenchView { pub search_everywhere: Entity, /// 快速打开弹窗 pub quick_open: Entity, + /// 跳转到行弹窗 + pub go_to_line: Entity, /// 命令面板 pub command_palette: Entity, /// 设置模态对话框 pub settings_dialog: Entity, /// 新建/克隆项目对话框 pub project_dialog: Entity, + /// 分支管理器弹窗(对齐 Tauri `GitBranchManager`) + pub branch_manager: Entity, /// 欢迎页 pub welcome_screen: Entity, @@ -118,9 +128,12 @@ impl WorkbenchView { let status_bar = cx.new(|_cx| StatusBarView::new()); let search_everywhere = cx.new(|cx| SearchEverywhereModal::new(cx)); let quick_open = cx.new(|cx| QuickOpenModal::new(cx)); + let go_to_line = cx.new(|cx| GoToLineModal::new(cx)); let command_palette = cx.new(|cx| CommandPaletteModal::new(cx)); let settings_dialog = cx.new(|cx| SettingsDialog::new(cx)); let project_dialog = cx.new(|cx| ProjectDialog::new(cx)); + let branch_manager_root = root.clone(); + let branch_manager = cx.new(|cx| BranchManagerView::new(branch_manager_root, cx)); let welcome_screen = cx.new(|cx| WelcomeScreenView::new(cx)); let maven = cx.new(|cx| MavenView::new(root.clone(), cx)); let focus_handle = cx.focus_handle(); @@ -192,43 +205,6 @@ impl WorkbenchView { sb.set_git_changes(sidebar.read(cx).git_changes.len(), cx); }); - // 同步本地分支列表:core `git.references` 取 kind == local 的 shortName, - // 供顶栏分支胶囊下拉切换(对齐 Tauri 分支管理器列表)。 - let toolbar_branches_sync = toolbar_branch_sync.clone(); - let branch_client = this.client.clone(); - let branch_root = this.workspace_root.clone(); - cx.spawn(async move |_this, cx| { - let task = branch_client.execute::( - &cx, - "git.references", - serde_json::json!({ "root": branch_root }), - ); - if let Ok(val) = task.await { - let mut branches: Vec = val - .get("references") - .and_then(|r| r.as_array()) - .map(|arr| { - arr.iter() - .filter(|item| { - item.get("kind").and_then(|k| k.as_str()) == Some("local") - }) - .filter_map(|item| { - item.get("shortName") - .and_then(|n| n.as_str()) - .map(|s| s.to_string()) - }) - .collect() - }) - .unwrap_or_default(); - branches.sort(); - branches.dedup(); - let _ = toolbar_branches_sync.update(cx, |tb, cx| { - tb.set_branches(branches, cx); - }); - } - }) - .detach(); - // 收集所有文件供全局搜索与快速打开 let mut file_list = Vec::new(); if let Some(root_node) = &sidebar.read(cx).root_node { @@ -455,25 +431,14 @@ impl WorkbenchView { ToolbarEvent::OpenRecentProject(path) => { this.open_project_path(path.clone(), cx); } - ToolbarEvent::CheckoutBranch(branch) => { - // 分支检出走底部 Terminal(分支名双引号包裹防空格/特殊字符), - // 检出异步执行,2 秒后重读 sidebar git 状态刷新分支与变更。 - let _ = this.bottom_panel.update(cx, |bp, cx| { - bp.set_tab(BottomTab::Terminal, cx); - let _ = bp.terminal.update(cx, |term, cx| { - term.send_command(&format!("git checkout \"{branch}\""), cx); - }); + ToolbarEvent::OpenBranchManager => { + let root = this.workspace_root.clone(); + let current = this.sidebar.read(cx).git_branch.clone(); + let _ = this.branch_manager.update(cx, |bm, cx| { + bm.set_repo(root, current, cx); }); - let sidebar = this.sidebar.clone(); - cx.spawn(async move |_this, cx| { - cx.background_executor() - .timer(std::time::Duration::from_secs(2)) - .await; - let _ = sidebar.update(cx, |sb, cx| { - sb.refresh_git(cx); - }); - }) - .detach(); + this.show_branch_manager = true; + cx.notify(); } }, ); @@ -535,6 +500,25 @@ impl WorkbenchView { }, ); + // 7b. 订阅跳转到行事件 + let sub_go_to_line = cx.subscribe( + &go_to_line, + move |this, _modal, event: &GoToLineEvent, cx| match event { + GoToLineEvent::Confirm(line) => { + let line = *line; + this.show_go_to_line = false; + this.editor_window_action(cx, move |ed, window, cx| { + ed.go_to_line(line, window, cx); + }); + cx.notify(); + } + GoToLineEvent::Close => { + this.show_go_to_line = false; + cx.notify(); + } + }, + ); + // 8. 订阅命令面板事件 let sub_command_palette = cx.subscribe( &command_palette, @@ -589,6 +573,30 @@ impl WorkbenchView { }, ); + // 9c. 订阅分支管理器弹窗事件 + let sub_branch_manager = cx.subscribe( + &branch_manager, + |this, _bm, event: &BranchManagerEvent, cx| match event { + BranchManagerEvent::CheckoutDone => { + // core 直连已完成才返回:关弹窗并重读 sidebar git 状态, + // 分支徽标经既有 observer 同步刷新。 + this.show_branch_manager = false; + let _ = this.sidebar.update(cx, |sb, cx| { + sb.refresh_git(cx); + }); + cx.notify(); + } + BranchManagerEvent::OpenWorktree(path) => { + this.show_branch_manager = false; + this.open_project_path(path.clone(), cx); + } + BranchManagerEvent::Close => { + this.show_branch_manager = false; + cx.notify(); + } + }, + ); + // 10. 订阅欢迎页事件 let sub_welcome = cx.subscribe( &welcome_screen, @@ -667,9 +675,11 @@ impl WorkbenchView { show_welcome: false, show_search_everywhere: false, show_quick_open: false, + show_go_to_line: false, show_command_palette: false, show_settings_dialog: false, show_project_dialog: false, + show_branch_manager: false, show_maven: false, toolbar, activity_rail, @@ -681,9 +691,11 @@ impl WorkbenchView { status_bar, search_everywhere, quick_open, + go_to_line, command_palette, settings_dialog, project_dialog, + branch_manager, welcome_screen, focus_handle, client: CoreClient::new(), @@ -696,9 +708,11 @@ impl WorkbenchView { sub_toolbar, sub_search, sub_quick_open, + sub_go_to_line, sub_command_palette, sub_settings, sub_project_dialog, + sub_branch_manager, sub_welcome, sub_status, sub_appearance, @@ -713,6 +727,11 @@ impl WorkbenchView { view.apply_resolved_theme(is_dark, cx); // 启动目录同样记入最近项目(对齐 Tauri 打开即记录)。 settings::record_recent_project(cx, &view.workspace_root.clone()); + // 同步工具栏当前项目行(仿分支同步写法;构造时已传入,此处再确认一次)。 + let root_for_toolbar = view.workspace_root.clone(); + let _ = view.toolbar.update(cx, |tb, cx| { + tb.set_workspace_root(root_for_toolbar.clone(), cx); + }); // 构造后初始化 Maven 入口可用性,避免无 pom 目录首次打开仍显示入口。 let maven_has_projects = view.maven.read(cx).has_projects(); let _ = view.plugin_rail.update(cx, |r, cx| { @@ -801,6 +820,10 @@ impl WorkbenchView { } self.workspace_root = path.clone(); self.show_welcome = false; + // 同步工具栏当前项目行(仿分支同步写法)。 + let _ = self.toolbar.update(cx, |tb, cx| { + tb.set_workspace_root(path.clone(), cx); + }); // 记录最近项目(对齐 Tauri upsert)。 settings::record_recent_project(cx, &path); let _ = self.sidebar.update(cx, |sb, cx| { @@ -840,6 +863,15 @@ impl WorkbenchView { cx.notify(); } + /// 打开跳转到行弹窗 + pub fn open_go_to_line(&mut self, cx: &mut Context) { + self.show_go_to_line = true; + let _ = self.go_to_line.update(cx, |modal, cx| { + modal.reset(cx); + }); + cx.notify(); + } + /// 打开命令面板 pub fn open_command_palette(&mut self, cx: &mut Context) { self.show_command_palette = true; @@ -860,6 +892,144 @@ impl WorkbenchView { } } + /// 持有 `&mut Window` 调用方的编辑器动作直达通道(按键监听等已有 + /// window 的路径使用,避免经窗口句柄二次 `update` 的重入借用失败)。 + fn editor_direct( + &mut self, + window: &mut Window, + cx: &mut Context, + action: impl FnOnce(&mut EditorView, &mut Window, &mut Context), + ) { + let _ = self.editor.update(cx, |ed, cx| action(ed, window, cx)); + } + + /// 无 `window` 上下文的编辑器动作经窗口句柄下发(菜单/面板事件路径, + /// 非重入场景)。`active_window` 仅反映平台聚焦,Xvfb 下可能为 None, + /// 单窗口应用退化到 `windows()` 首个句柄。 + fn editor_window_action( + &self, + cx: &mut Context, + action: impl FnOnce(&mut EditorView, &mut Window, &mut Context) + 'static, + ) { + let editor = self.editor.clone(); + let handle = cx + .active_window() + .or_else(|| cx.windows().into_iter().next()); + if let Some(handle) = handle { + let _ = handle.update(cx, |_, window, cx| { + editor.update(cx, |ed, cx| action(ed, window, cx)); + }); + } + } + + /// 在系统浏览器中打开 URL(Linux-frame:`xdg-open`,失败忽略)。 + fn open_url(url: &str) { + let _ = std::process::Command::new("xdg-open").arg(url).spawn(); + } + + /// 另存为:存盘框选目标后落盘,并更新活动标签的 path/title。 + /// + /// 目标在工作区内时经 core `file.write`(仿 `save_active`);工作区外 + /// core 会拒绝绝对路径,改用 `std::fs` 直写。 + fn save_active_as(&mut self, cx: &mut Context) { + let (old_path, text) = { + let ed = self.editor.read(cx); + let Some(idx) = ed.active_tab_index else { + return; + }; + let Some(tab) = ed.tabs.get(idx) else { + return; + }; + (tab.path.clone(), tab.content.clone()) + }; + let default_name = old_path + .rsplit('/') + .next() + .unwrap_or("untitled.txt") + .to_string(); + let dest = rfd::FileDialog::new() + .set_directory(&self.workspace_root) + .set_file_name(&default_name) + .save_file(); + let Some(dest) = dest else { + return; + }; + let title = dest + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or(default_name); + let root = self.workspace_root.clone(); + if let Ok(rel) = dest.strip_prefix(std::path::Path::new(&root)) { + let rel = rel.to_string_lossy().to_string(); + let editor = self.editor.clone(); + let client = self.client.clone(); + cx.spawn(async move |_this, cx| { + if client.write_file(&cx, &root, &rel, &text).await.is_ok() { + let _ = editor.update(cx, |ed, cx| { + if let Some(idx) = ed.active_tab_index { + if let Some(t) = ed.tabs.get_mut(idx) { + if t.path == old_path { + t.path = rel.clone(); + t.title = title.clone(); + t.is_dirty = false; + cx.notify(); + } + } + } + }); + } + }) + .detach(); + } else if std::fs::write(&dest, text.as_bytes()).is_ok() { + let abs = dest.to_string_lossy().to_string(); + let _ = self.editor.update(cx, |ed, cx| { + if let Some(idx) = ed.active_tab_index { + if let Some(t) = ed.tabs.get_mut(idx) { + if t.path == old_path { + t.path = abs; + t.title = title; + t.is_dirty = false; + cx.notify(); + } + } + } + }); + } + } + + /// 还原文件:重读活动文件内容并灌回编辑器(同路径 `open_file` 会刷新内容并清除脏标记)。 + fn revert_active_file(&mut self, cx: &mut Context) { + let path = { + let ed = self.editor.read(cx); + let Some(idx) = ed.active_tab_index else { + return; + }; + let Some(tab) = ed.tabs.get(idx) else { + return; + }; + tab.path.clone() + }; + if std::path::Path::new(&path).is_absolute() { + if let Ok(text) = std::fs::read_to_string(&path) { + let _ = self.editor.update(cx, |ed, cx| { + ed.open_file(path, text, cx); + }); + } + return; + } + let root = self.workspace_root.clone(); + let editor = self.editor.clone(); + let client = self.client.clone(); + cx.spawn(async move |_this, cx| { + if let Ok(text) = client.read_file(&cx, &root, &path).await { + let _ = editor.update(cx, |ed, cx| { + ed.open_file(path, text, cx); + }); + } + }) + .detach(); + } + /// 执行常用命令动作(工具栏菜单、命令面板、全局搜索共用)。 pub fn handle_action(&mut self, action_id: &str, cx: &mut Context) { // 主题子菜单:menu.theme.,直接写设置并应用主题。 @@ -897,7 +1067,7 @@ impl WorkbenchView { } }); } - "workbench.toggle_terminal" | "view.toggle_bottom_panel" | "terminal.new" => { + "workbench.toggle_terminal" | "view.toggle_bottom_panel" => { let _ = self.bottom_panel.update(cx, |bp, cx| { bp.toggle_collapsed(cx); }); @@ -953,7 +1123,7 @@ impl WorkbenchView { cx, ); } - "file.exit" | "window.close" => { + "file.exit" | "window.close" | "file.close_window" => { cx.quit(); } "window.minimize" => { @@ -1010,12 +1180,190 @@ impl WorkbenchView { self.append_log("[Run and Debug] Debug panel is not wired yet.", cx); } // 快捷键设置:设置对话框切到 Keyboard 分类并打开。 - "tools.shortcuts" => { + "tools.shortcuts" | "help.shortcuts" => { let _ = self.settings_dialog.update(cx, |d, cx| { d.set_category(SettingsCategory::Keyboard, cx); }); self.open_settings(cx); } + // ---- File:新窗口 / 文件夹 / 另存为 / 标签页批量操作 ---- + "file.new_window" => { + if let Ok(exe) = std::env::current_exe() { + let _ = std::process::Command::new(exe).spawn(); + } + } + "file.open_folder" => { + if let Some(dir) = ProjectDialog::pick_folder(None) { + self.open_project_path(dir, cx); + } + } + "file.close_folder" => { + self.show_welcome = true; + cx.notify(); + } + "file.save_as" => { + self.save_active_as(cx); + } + "file.save_all" => { + let _ = self.editor.update(cx, |ed, cx| { + ed.save_all_tabs(cx); + }); + } + "file.revert" => { + self.revert_active_file(cx); + } + "file.close_all" => { + let _ = self.editor.update(cx, |ed, cx| { + ed.close_all_tabs(cx); + }); + } + "file.close_others" => { + let _ = self.editor.update(cx, |ed, cx| { + ed.close_other_tabs(cx); + }); + } + "file.close_saved" => { + let _ = self.editor.update(cx, |ed, cx| { + ed.close_saved_tabs(cx); + }); + } + "file.close_left" => { + let _ = self.editor.update(cx, |ed, cx| { + ed.close_tabs_to_left(cx); + }); + } + "file.close_right" => { + let _ = self.editor.update(cx, |ed, cx| { + ed.close_tabs_to_right(cx); + }); + } + "file.reopen_closed" => { + let _ = self.editor.update(cx, |ed, cx| { + ed.reopen_closed_tab(cx); + }); + } + // ---- Edit:撤销/剪贴板/行操作(需 window 的经 defer 下发) ---- + "edit.undo" => { + self.editor_window_action(cx, |ed, window, cx| ed.undo(window, cx)); + } + "edit.redo" => { + self.editor_window_action(cx, |ed, window, cx| ed.redo(window, cx)); + } + "edit.cut" => { + self.editor_window_action(cx, |ed, window, cx| ed.cut(window, cx)); + } + "edit.copy" => { + let _ = self.editor.update(cx, |ed, cx| { + ed.copy(cx); + }); + } + "edit.paste" => { + self.editor_window_action(cx, |ed, window, cx| ed.paste(window, cx)); + } + "edit.select_all" => { + self.editor_window_action(cx, |ed, window, cx| ed.select_all(window, cx)); + } + "edit.toggle_comment" => { + self.editor_window_action(cx, |ed, window, cx| ed.toggle_comment(window, cx)); + } + "edit.duplicate_line" => { + self.editor_window_action(cx, |ed, window, cx| ed.duplicate_line(window, cx)); + } + "edit.delete_line" => { + self.editor_window_action(cx, |ed, window, cx| ed.delete_line(window, cx)); + } + "edit.move_up" => { + self.editor_window_action(cx, |ed, window, cx| ed.move_line_up(window, cx)); + } + "edit.move_down" => { + self.editor_window_action(cx, |ed, window, cx| ed.move_line_down(window, cx)); + } + // ---- Go:跳转到行 / 标签页切换 ---- + "go.go_to_line" => { + self.open_go_to_line(cx); + } + "go.next_tab" => { + let _ = self.editor.update(cx, |ed, cx| { + ed.goto_next_tab(cx); + }); + } + "go.prev_tab" => { + let _ = self.editor.update(cx, |ed, cx| { + ed.goto_prev_tab(cx); + }); + } + // ---- View:显示开关 / 缩放 ---- + "view.toggle_wrap" => { + self.editor_window_action(cx, |ed, window, cx| ed.toggle_wrap(window, cx)); + } + "view.toggle_line_numbers" => { + self.editor_window_action(cx, |ed, window, cx| ed.toggle_line_numbers(window, cx)); + } + "view.toggle_whitespace" => { + self.editor_window_action(cx, |ed, window, cx| ed.toggle_whitespace(window, cx)); + } + "view.zoom_in" => { + settings::update(cx, |s| s.font_size = (s.font_size + 1.0).min(22.0)); + let msg = format!( + "[View] Font size: {} px", + settings::get(cx).font_size as i32 + ); + self.append_log(&msg, cx); + cx.notify(); + } + "view.zoom_out" => { + settings::update(cx, |s| s.font_size = (s.font_size - 1.0).max(10.0)); + let msg = format!( + "[View] Font size: {} px", + settings::get(cx).font_size as i32 + ); + self.append_log(&msg, cx); + cx.notify(); + } + "view.reset_zoom" => { + settings::update(cx, |s| s.font_size = 14.0); + self.append_log("[View] Font size reset to 14 px", cx); + cx.notify(); + } + // ---- Terminal:新建会话 / 关闭面板(只关不开) ---- + "terminal.new" => { + let _ = self.bottom_panel.update(cx, |bp, cx| { + bp.set_tab(BottomTab::Terminal, cx); + let _ = bp.terminal.update(cx, |term, cx| { + term.respawn(cx); + }); + }); + } + "terminal.close" => { + if !self.bottom_panel.read(cx).is_collapsed { + let _ = self.bottom_panel.update(cx, |bp, cx| { + bp.toggle_collapsed(cx); + }); + } + } + // ---- Window:全屏(gpui-pre `Window::toggle_fullscreen`) ---- + "window.fullscreen" => { + cx.defer(|cx| { + if let Some(handle) = cx.active_window() { + let _ = handle.update(cx, |_, window, _| window.toggle_fullscreen()); + } + }); + } + // ---- Help:外部链接经 `xdg-open` 打开 ---- + "help.docs" => { + Self::open_url("https://lithe.top/docs"); + } + "help.changelog" => { + Self::open_url("https://github.com/1lck/Lithe-IDEA/releases"); + } + "help.report_bug" => { + Self::open_url("https://github.com/1lck/Lithe-IDEA/issues/new?template=01-bug.yml"); + } + "help.feature" => { + Self::open_url( + "https://github.com/1lck/Lithe-IDEA/issues/new?template=02-feature.yml", + ); + } _ => {} } } @@ -1092,16 +1440,20 @@ impl Render for WorkbenchView { .relative() .size_full() .bg(ThemeColors::background()) - .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { let key = event.keystroke.key.as_str(); let modifiers = event.keystroke.modifiers; // 浮层优先:Esc 关闭最上层模态。 if key == "escape" { - if this.show_command_palette { + if this.show_branch_manager { + this.show_branch_manager = false; + } else if this.show_command_palette { this.show_command_palette = false; } else if this.show_quick_open { this.show_quick_open = false; + } else if this.show_go_to_line { + this.show_go_to_line = false; } else if this.show_search_everywhere { this.show_search_everywhere = false; } else if this.show_settings_dialog { @@ -1134,7 +1486,34 @@ impl Render for WorkbenchView { bp.set_tab(BottomTab::Terminal, cx); }); } - _ => {} + // 行操作快捷键与菜单展示一致(上游编辑器不原生支持)。 + // 按键监听自带 `window`,直接下发,避免经句柄二次 + // `update` 的重入借用失败。 + "d" => this.editor_direct(window, cx, |ed, window, cx| { + ed.duplicate_line(window, cx); + }), + "/" => this.editor_direct(window, cx, |ed, window, cx| { + ed.toggle_comment(window, cx); + }), + _ => { + if modifiers.shift && (key == "k" || key == "K") { + this.editor_direct(window, cx, |ed, window, cx| { + ed.delete_line(window, cx); + }); + } + } + } + // Alt+Up/Down 移动行(`modifiers.alt`)。 + if modifiers.alt && (key == "up" || key == "down") { + if key == "up" { + this.editor_direct(window, cx, |ed, window, cx| { + ed.move_line_up(window, cx); + }); + } else { + this.editor_direct(window, cx, |ed, window, cx| { + ed.move_line_down(window, cx); + }); + } } } })) @@ -1187,6 +1566,9 @@ impl Render for WorkbenchView { .when(self.show_quick_open, |view| { view.child(self.quick_open.clone()) }) + .when(self.show_go_to_line, |view| { + view.child(self.go_to_line.clone()) + }) .when(self.show_command_palette, |view| { view.child(self.command_palette.clone()) }) @@ -1196,6 +1578,9 @@ impl Render for WorkbenchView { .when(self.show_project_dialog, |view| { view.child(self.project_dialog.clone()) }) + .when(self.show_branch_manager, |view| { + view.child(self.branch_manager.clone()) + }) } } From dc6d2db25a53de5b2a5ab3f0561ce454d075f76a Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Thu, 24 Sep 2026 21:28:08 +0800 Subject: [PATCH 023/109] =?UTF-8?q?feat(linux):=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E5=85=A8=E9=83=A8=E5=88=86=E7=B1=BB=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E6=8E=A5=E7=BA=BF=EF=BC=8C=E5=AF=B9=E9=BD=90=20Tauri/?= =?UTF-8?q?Mac?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings 补 Tauri 字段:fontFamily/lineHeight/renderWhitespace/vim/formatOnSave/terminalFontSize/cursorBlink/uiFontSize/gitExecutable/gitUseCredentialHelper/aiProvider/aiModel/aiCompletion/aiCommit/customLogDirectory;fontSize 默认 14 - General:隐藏目录/文件改为 Textarea 可编辑,应用按钮解析落盘 - Editor:字体下拉/行高步进/空白符下拉/字号 8-32,新增行为分组(换行/Vim/保存格式化);编辑器每帧同步 wrap/行号/空白符 - Keyboard:预设补全 8 项,Vim 开关 - Terminal:Linux 真实 shell 探测下拉,字号/回滚量/光标闪烁;终端按设置 shell 建会话、字号渲染 - LSP:保存时格式化开关 - Project:工具链三行可编辑,which 探测回填,保存写 .lithe/run/local.json - Run:读 configurations.json 列表,生成默认 Java 配置 - AI:提供商下拉/模型输入应用/自动补全开关;AI 提交:启用/语言/格式/正文/标题长度/diff 上限 - Git:执行分组(可执行路径探测清空应用+凭据助手开关) - Logs:自定义目录选择应用、清理 *.log、导出 diagnostic-bundle.json - Updates:检查更新显示已是最新版本 - Xvfb 实测:生成/保存/步进/探测/选择/导出/检查/应用均落盘生效 --- linux/src/i18n.rs | 33 + linux/src/settings.rs | 63 +- linux/src/workbench/editor.rs | 26 + linux/src/workbench/settings_dialog.rs | 1438 ++++++++++++++++++++---- linux/src/workbench/terminal.rs | 45 +- 5 files changed, 1382 insertions(+), 223 deletions(-) diff --git a/linux/src/i18n.rs b/linux/src/i18n.rs index d45b27810..1f1e12a5f 100644 --- a/linux/src/i18n.rs +++ b/linux/src/i18n.rs @@ -323,6 +323,17 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { "在主视图中将打开的缓冲区显示为可横向滚动的轮播", "Show open buffers as a horizontally scrollable carousel in the main view", ), + "settings.editor.fontFamily" => ("编辑器字体", "Editor Font Family"), + "settings.editor.lineHeight" => ("行高", "Line Height"), + "settings.editor.renderWhitespace" => ("显示空白字符", "Render Whitespace"), + "settings.editor.whitespaceNone" => ("无", "None"), + "settings.editor.whitespaceBoundary" => ("边界", "Boundary"), + "settings.editor.whitespaceTrailing" => ("行尾", "Trailing"), + "settings.editor.whitespaceAll" => ("全部", "All"), + "settings.editor.vimMode" => ("Vim 模式", "Vim Mode"), + "settings.editor.formatOnSave" => ("保存时格式化", "Format on Save"), + "settings.editor.wordWrap" => ("自动换行", "Word Wrap"), + "settings.editor.behavior" => ("行为", "Behavior"), "settings.mac.keymapPreset" => ("快捷键方案", "Keymap"), "settings.mac.preset" => ("预设", "Preset"), "settings.mac.shortcuts" => ("键盘快捷键", "Keyboard shortcuts"), @@ -341,6 +352,9 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { "settings.mac.shellPowerShell" => ("PowerShell", "PowerShell"), "settings.mac.shellCommandPrompt" => ("命令提示符", "Command Prompt"), "settings.mac.shellWsl" => ("WSL", "WSL"), + "settings.terminal.fontSize" => ("字体大小", "Font Size"), + "settings.terminal.cursorBlink" => ("光标闪烁", "Blinking Cursor"), + "settings.terminal.scrollback" => ("回滚缓冲区", "Scrollback"), "settings.mac.languageServices" => ("语言服务", "Language services"), "settings.mac.autoCompletion" => ("自动补全", "Auto completion"), "settings.mac.autoCompletionDescription" => ( @@ -365,6 +379,7 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { "选择 Lithe Agent 使用的模型", "Choose the model used by Lithe Agent", ), + "aiSettings.completion" => ("AI 自动补全", "AI Autocomplete"), "settings.mac.commitMessage" => ("提交信息", "Commit messages"), // ---- 设置对话框内容:Git(与 Tauri tabs/git-settings.tsx 对齐) ---- "settings.git.integration" => ("集成", "Integration"), @@ -432,6 +447,11 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { "在编辑器中显示当前行的 Git Blame 元数据", "Show inline Git blame metadata for the current line in the editor", ), + "settings.git.executable" => ("Git 可执行文件", "Git executable"), + "settings.git.executableDescription" => ("留空则使用 PATH 中的 Git", "Use Git from PATH"), + "settings.git.execution" => ("Git 执行", "Git execution"), + "settings.git.detect" => ("探测", "Detect"), + "settings.git.useCredentialHelper" => ("使用凭据助手", "Use credential helper"), // ---- 设置对话框内容:Git Fetch 默认行为(与 Tauri git-execution-settings 对齐) ---- "git.fetch.defaults" => ("Fetch 默认行为", "Fetch defaults"), "git.fetch.prune" => ( @@ -477,6 +497,8 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { "Choose a parent directory; Lithe writes to its Lithe/logs subdirectory.", ), "settings.logs.choose" => ("选择…", "Choose…"), + "settings.logs.clear" => ("清除日志", "Clear Logs"), + "settings.logs.export" => ("导出", "Export"), "settings.logs.diagnostics" => ("诊断", "Diagnostics"), "settings.logs.diagnosticMode" => ( "本次会话启用诊断日志", @@ -508,6 +530,7 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { "settings.mac.softwareUpdate" => ("软件更新", "Software Update"), "settings.mac.currentVersion" => ("当前版本:{version}", "Current version: {version}"), "settings.mac.checkForUpdates" => ("检查更新", "Check for Updates"), + "settings.mac.upToDate" => ("Lithe 当前已是最新版本。", "Lithe is up to date."), "settings.mac.updateHint" => ( "Lithe 可以检查新的预览版和稳定版。", "Lithe can check for new preview and stable releases.", @@ -515,6 +538,7 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { // ---- 设置对话框内容:项目工具链 / 运行生成 / AI 占位(Linux-frame) ---- "settings.project.toolchain" => ("工具链", "Toolchain"), "settings.run.generate" => ("生成运行配置", "Generate Run Configuration"), + "settings.run.empty" => ("暂无运行配置", "No run configurations"), "settings.ai.noteTitle" => ("说明", "Note"), "settings.ai.providerNote" => ( "完整提供商与模型选择尚未接入,后续版本提供。", @@ -535,6 +559,15 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { "Rules applied when generating commit messages", ), "settings.ai.defaultValue" => ("默认", "Default"), + "settings.aiCommit.enabled" => ("启用 AI 提交信息", "Enable AI commit messages"), + "settings.aiCommit.language" => ("输出语言", "Output language"), + "settings.aiCommit.format" => ("格式", "Format"), + "settings.aiCommit.includeBody" => ( + "有必要时包含简短正文", + "Include a short body when useful", + ), + "settings.aiCommit.subjectMax" => ("标题最大长度", "Maximum subject length"), + "settings.aiCommit.diffLimit" => ("Diff 字符限制", "Diff character budget"), // ---- 新建项目对话框(与 Tauri locale.ts newProject.* 逐字对齐) ---- "newProject.backToStarters" => ("返回项目模板", "Back to project starters"), "newProject.repositoryUrl" => ("仓库 URL", "Repository URL"), diff --git a/linux/src/settings.rs b/linux/src/settings.rs index 016d5ede3..1a10b668d 100644 --- a/linux/src/settings.rs +++ b/linux/src/settings.rs @@ -123,6 +123,31 @@ pub const DEFAULT_HIDDEN_FILE_PATTERNS: &[&str] = &[ "vssver2.scc", ]; +/// AI 提交信息生成设置,对应 Tauri `CommitAISettings` 的持久化子集。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct AiCommitSettings { + pub enabled: bool, + pub language: String, + pub format: String, + pub include_body: bool, + pub subject_max_length: usize, + pub maximum_diff_characters: usize, +} + +impl Default for AiCommitSettings { + fn default() -> Self { + Self { + enabled: true, + language: "english".to_string(), + format: "conventional".to_string(), + include_body: false, + subject_max_length: 72, + maximum_diff_characters: 32000, + } + } +} + /// 应用设置真源,字段名与 Tauri `defaultSettings` 的 camelCase 键一一对应。 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -131,16 +156,24 @@ pub struct Settings { pub auto_save: bool, pub quick_open_preview: bool, // Editor + pub font_family: String, pub font_size: f32, + pub editor_line_height: f32, pub tab_size: usize, pub word_wrap: bool, pub line_numbers: bool, + pub render_whitespace: String, + pub render_indent_guides: bool, + pub highlight_occurrences: bool, pub show_minimap: bool, pub code_lens: bool, // Terminal + pub terminal_font_size: f32, + pub terminal_cursor_blink: bool, pub terminal_scrollback: usize, pub terminal_default_shell_id: String, // UI + pub ui_font_size: f32, pub display_language: String, pub reduce_motion: bool, pub show_status_bar: bool, @@ -168,7 +201,9 @@ pub struct Settings { pub horizontal_tab_scroll: bool, // Keyboard pub keybinding_preset: String, + pub vim_mode: bool, // Language + pub format_on_save: bool, pub auto_completion: bool, pub parameter_hints: bool, pub semantic_tokens: bool, @@ -210,6 +245,15 @@ pub struct Settings { pub git_fetch_prune: bool, pub git_fetch_submodules: String, pub git_fetch_tags: String, + pub git_executable: String, + pub git_use_credential_helper: bool, + // AI + pub ai_provider_id: String, + pub ai_model_id: String, + pub ai_completion: bool, + pub ai_commit: AiCommitSettings, + // Logs + pub custom_log_directory: String, // Advanced pub last_settings_tab: String, pub core_features: CoreFeatures, @@ -220,14 +264,22 @@ impl Default for Settings { Self { auto_save: true, quick_open_preview: true, - font_size: 13.0, + font_family: "Geist Mono".to_string(), + font_size: 14.0, + editor_line_height: 1.4, tab_size: 2, word_wrap: false, line_numbers: true, + render_whitespace: "none".to_string(), + render_indent_guides: true, + highlight_occurrences: true, show_minimap: true, code_lens: true, + terminal_font_size: 14.0, + terminal_cursor_blink: true, terminal_scrollback: 10000, terminal_default_shell_id: String::new(), + ui_font_size: 13.0, display_language: "zh-CN".to_string(), reduce_motion: false, show_status_bar: true, @@ -260,6 +312,8 @@ impl Default for Settings { max_open_tabs: 100, horizontal_tab_scroll: true, keybinding_preset: "none".to_string(), + vim_mode: false, + format_on_save: false, auto_completion: true, parameter_hints: true, semantic_tokens: true, @@ -299,6 +353,13 @@ impl Default for Settings { git_fetch_prune: true, git_fetch_submodules: "inherit".to_string(), git_fetch_tags: "inherit".to_string(), + git_executable: String::new(), + git_use_credential_helper: true, + ai_provider_id: "anthropic".to_string(), + ai_model_id: "claude-sonnet-4-6".to_string(), + ai_completion: true, + ai_commit: AiCommitSettings::default(), + custom_log_directory: String::new(), last_settings_tab: "general".to_string(), core_features: CoreFeatures::default(), } diff --git a/linux/src/workbench/editor.rs b/linux/src/workbench/editor.rs index 1e51a36e0..3bdfbc82a 100644 --- a/linux/src/workbench/editor.rs +++ b/linux/src/workbench/editor.rs @@ -852,6 +852,32 @@ fn is_code_file(name: &str) -> bool { impl Render for EditorView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.sync_active_editor(window, cx); + // 设置弹窗可直接改 word_wrap/line_numbers/render_whitespace:每帧对齐 + // 全局设置(菜单 toggle 已同步,此处覆盖对话框路径)。 + { + let s = settings::get(cx); + let wrap = s.word_wrap; + let show_numbers = s.line_numbers; + let show_ws = s.render_whitespace.as_str() != "none"; + if wrap != self.soft_wrap { + self.soft_wrap = wrap; + self.editor_state.update(cx, |editor, cx| { + editor.set_soft_wrap(wrap, window, cx); + }); + } + if show_numbers != self.show_line_numbers { + self.show_line_numbers = show_numbers; + self.editor_state.update(cx, |editor, cx| { + editor.set_line_number(show_numbers, window, cx); + }); + } + if show_ws != self.show_whitespace { + self.show_whitespace = show_ws; + self.editor_state.update(cx, |editor, cx| { + editor.set_show_whitespaces(show_ws, window, cx); + }); + } + } let active_tab = self .active_tab_index diff --git a/linux/src/workbench/settings_dialog.rs b/linux/src/workbench/settings_dialog.rs index c6cb868d7..a67fdbcd5 100644 --- a/linux/src/workbench/settings_dialog.rs +++ b/linux/src/workbench/settings_dialog.rs @@ -9,13 +9,15 @@ use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::input::{Input, InputState, Textarea, TextareaState}; use gpui_kit::component::menu::DropdownMenu as _; use gpui_kit::component::scroll::ScrollableElement as _; use gpui_kit::component::{h_flex, v_flex, Icon, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, rgba, Context, EventEmitter, FontWeight, InteractiveElement as _, IntoElement, - ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, Window, + div, px, rgba, AppContext as _, Context, Entity, EventEmitter, FontWeight, + InteractiveElement as _, IntoElement, ParentElement as _, Render, + StatefulInteractiveElement as _, Styled as _, Window, }; use crate::settings::{self, Settings}; @@ -149,6 +151,27 @@ pub struct SettingsDialog { git_policy: String, /// 本次会话诊断日志开关(对齐 Tauri 日志面板的会话级状态,重启恢复默认) diagnostic_mode: bool, + /// 隐藏目录/文件模式多行编辑器(General 分组,内容即设置值,应用时落盘)。 + hidden_dirs_input: Option>, + hidden_files_input: Option>, + /// 项目工具链单行输入(Project 分组,打开分类时由 local.json 回填)。 + project_jdk_input: Option>, + project_maven_input: Option>, + project_maven_jdk_input: Option>, + /// 已加载工具链的工作区(避免跨项目复用脏输入框)。 + project_loaded_for: String, + /// 项目/运行/日志/更新分组的操作回执展示。 + project_status: String, + run_configs: Vec, + run_status: String, + /// AI 模型单行输入(provider 切换不重置,由用户显式修改)。 + ai_model_input: Option>, + /// Git 可执行路径单行输入。 + git_exe_input: Option>, + /// 日志自定义目录单行输入。 + log_dir_input: Option>, + logs_status: String, + updates_status: String, } impl EventEmitter for SettingsDialog {} @@ -167,6 +190,20 @@ impl SettingsDialog { workspace_root, git_policy: "ask".to_string(), diagnostic_mode: false, + hidden_dirs_input: None, + hidden_files_input: None, + project_jdk_input: None, + project_maven_input: None, + project_maven_jdk_input: None, + project_loaded_for: String::new(), + project_status: String::new(), + run_configs: Vec::new(), + run_status: String::new(), + ai_model_input: None, + git_exe_input: None, + log_dir_input: None, + logs_status: String::new(), + updates_status: String::new(), } } @@ -187,6 +224,20 @@ impl SettingsDialog { fn reset_placeholders(&mut self) { self.git_policy = "ask".to_string(); self.diagnostic_mode = false; + self.hidden_dirs_input = None; + self.hidden_files_input = None; + self.project_jdk_input = None; + self.project_maven_input = None; + self.project_maven_jdk_input = None; + self.project_loaded_for = String::new(); + self.project_status = String::new(); + self.run_configs = Vec::new(); + self.run_status = String::new(); + self.ai_model_input = None; + self.git_exe_input = None; + self.log_dir_input = None; + self.logs_status = String::new(); + self.updates_status = String::new(); } /// 写入设置、广播 `Changed` 并刷新视图。 @@ -204,10 +255,49 @@ impl SettingsDialog { cx.emit(SettingsEvent::Changed); cx.notify(); } + + /// 懒创建单行输入框(首次渲染时用初始值填充,后续渲染复用用户编辑态)。 + fn ensure_input( + slot: &mut Option>, + initial: &str, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + if let Some(entity) = slot.clone() { + return entity; + } + let initial = initial.to_string(); + let entity = cx.new(|cx| InputState::new(window, cx).default_value(initial)); + *slot = Some(entity.clone()); + entity + } + + /// 懒创建多行输入框(隐藏路径模式编辑用)。 + fn ensure_textarea( + slot: &mut Option>, + initial: &str, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + if let Some(entity) = slot.clone() { + return entity; + } + let initial = initial.to_string(); + let entity = cx.new(|cx| TextareaState::new(window, cx).default_value(initial)); + *slot = Some(entity.clone()); + entity + } + + /// 单行文本输入渲染(宽 220px,对齐 Tauri `Input` 控件)。 + fn render_text_input(entity: Entity) -> impl IntoElement { + div() + .w(px(220.0)) + .child(Input::new(&entity).cleanable(true)) + } } impl Render for SettingsDialog { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { // 全屏半透明遮罩:点击遮罩关闭,点击卡片不冒泡。 div() .id("settings-dialog-backdrop") @@ -358,10 +448,10 @@ impl Render for SettingsDialog { ) .child(match self.active_category { SettingsCategory::General => self - .render_general_content(cx) + .render_general_content(window, cx) .into_any_element(), SettingsCategory::Project => self - .render_project_content(cx) + .render_project_content(window, cx) .into_any_element(), SettingsCategory::Run => { self.render_run_content(cx).into_any_element() @@ -378,18 +468,18 @@ impl Render for SettingsDialog { SettingsCategory::Lsp => { self.render_lsp_content(cx).into_any_element() } - SettingsCategory::Ai => { - self.render_ai_content(cx).into_any_element() - } + SettingsCategory::Ai => self + .render_ai_content(window, cx) + .into_any_element(), SettingsCategory::AiCommit => self .render_ai_commit_content(cx) .into_any_element(), - SettingsCategory::Git => { - self.render_git_content(cx).into_any_element() - } - SettingsCategory::Logs => { - self.render_logs_content(cx).into_any_element() - } + SettingsCategory::Git => self + .render_git_content(window, cx) + .into_any_element(), + SettingsCategory::Logs => self + .render_logs_content(window, cx) + .into_any_element(), SettingsCategory::Updates => self .render_updates_content(cx) .into_any_element(), @@ -706,22 +796,6 @@ impl SettingsDialog { }) } - /// 多行只读文本块:隐藏路径等模式列表展示用(编辑能力后续接入)。 - fn render_text_block(&self, text: String, min_height: f32) -> impl IntoElement { - div() - .w_full() - .min_h(px(min_height)) - .p_2() - .rounded_sm() - .border_1() - .border_color(ThemeColors::border()) - .bg(ThemeColors::background()) - .font_family("monospace") - .text_xs() - .text_color(ThemeColors::muted_foreground()) - .child(text) - } - /// 分组内的说明文本。 fn render_note(&self, text: String) -> impl IntoElement { div() @@ -734,8 +808,25 @@ impl SettingsDialog { /// 各分类内容渲染。 impl SettingsDialog { /// 常规:对齐 Tauri `GeneralPanel`(外观/语言/项目/文件/Git/隐藏路径)。 - fn render_general_content(&self, cx: &mut Context) -> impl IntoElement { + fn render_general_content( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { let s = settings::get(cx).clone(); + // 隐藏路径编辑器先创建(避免 render_group 的 &self 借用冲突)。 + let dirs_entity = Self::ensure_textarea( + &mut self.hidden_dirs_input, + &s.hidden_directory_patterns.join("\n"), + window, + cx, + ); + let files_entity = Self::ensure_textarea( + &mut self.hidden_files_input, + &s.hidden_file_patterns.join("\n"), + window, + cx, + ); // 外观模式由 syncSystemTheme + theme 派生,与 Tauri `appearanceMode` 一致。 let appearance_mode = if s.sync_system_theme { "system" @@ -980,16 +1071,24 @@ impl SettingsDialog { .to_string(), ), ) - .child(v_flex().w_full().gap_1p5().child( - div().text_xs().text_color(ThemeColors::foreground()).child( - crate::i18n::menu_text(cx, "settings.mac.directories").to_string(), - ), + .child(div().text_xs().text_color(ThemeColors::foreground()).child( + crate::i18n::menu_text(cx, "settings.mac.directories").to_string(), )) - .child(self.render_text_block(s.hidden_directory_patterns.join("\n"), 72.0)) + .child({ + div() + .w_full() + .h(px(72.0)) + .child(Textarea::new(&dirs_entity).bordered(true)) + }) .child(div().text_xs().text_color(ThemeColors::foreground()).child( crate::i18n::menu_text(cx, "settings.mac.filePatterns").to_string(), )) - .child(self.render_text_block(s.hidden_file_patterns.join("\n"), 56.0)) + .child({ + div() + .w_full() + .h(px(56.0)) + .child(Textarea::new(&files_entity).bordered(true)) + }) .child( h_flex().w_full().justify_end().child( Button::new("general-apply-patterns") @@ -999,9 +1098,29 @@ impl SettingsDialog { crate::i18n::menu_text(cx, "settings.mac.apply") .to_string(), ) - .on_click(cx.listener(|_this, _event, _window, cx| { - // 模式编辑器后续接入;当前落盘值即显示值。 - cx.notify(); + .on_click(cx.listener(|this, _event, _window, cx| { + // 每行一个 glob:trim + 去空后落盘,即时影响文件树。 + let dirs = this + .hidden_dirs_input + .as_ref() + .map(|e| e.read(cx).value().to_string()) + .unwrap_or_default(); + let files = this + .hidden_files_input + .as_ref() + .map(|e| e.read(cx).value().to_string()) + .unwrap_or_default(); + let split = |text: String| { + text.lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(str::to_string) + .collect::>() + }; + this.commit(cx, |s| { + s.hidden_directory_patterns = split(dirs); + s.hidden_file_patterns = split(files); + }) })), ), ), @@ -1009,10 +1128,52 @@ impl SettingsDialog { ) } - /// 项目 · JDK 与 Maven:对齐 `ProjectEnvironmentSettings` 的字段结构 - /// (工作区路径、作用域说明、三个工具链路径行、保存按钮)。 - /// 路径探测与保存尚未接入后端,输入框为只读占位。 - fn render_project_content(&self, cx: &mut Context) -> impl IntoElement { + /// 项目 · JDK 与 Maven:对齐 `ProjectEnvironmentSettings`(工作区路径、 + /// 工具链三行、保存按钮)。值来自 `/.lithe/run/local.json` + ///(对齐 Mac 本机配置),缺失时回退 `which java/mvn` 探测;保存写回该文件。 + fn render_project_content( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let root = self.workspace_root.clone(); + // 切项目时重新回填输入框,避免复用上个项目的脏值。 + if self.project_loaded_for != root { + let (jdk, maven, maven_jdk) = read_local_toolchain(&root); + let mut fill = |slot: &mut Option>, value: String| { + if let Some(entity) = slot.clone() { + entity.update(cx, |state, cx| { + state.set_value(value, window, cx); + }); + } else { + *slot = Some(cx.new(|cx| InputState::new(window, cx).default_value(value))); + } + }; + fill( + &mut self.project_jdk_input, + if jdk.is_empty() { + which("java").unwrap_or_default() + } else { + jdk + }, + ); + fill( + &mut self.project_maven_input, + if maven.is_empty() { + which("mvn").unwrap_or_default() + } else { + maven + }, + ); + fill(&mut self.project_maven_jdk_input, maven_jdk); + self.project_loaded_for = root.clone(); + self.project_status = String::new(); + } + let jdk_entity = Self::ensure_input(&mut self.project_jdk_input, "", window, cx); + let maven_entity = Self::ensure_input(&mut self.project_maven_input, "", window, cx); + let maven_jdk_entity = + Self::ensure_input(&mut self.project_maven_jdk_input, "", window, cx); + let status = self.project_status.clone(); v_flex() .w_full() .gap_4() @@ -1021,7 +1182,7 @@ impl SettingsDialog { .font_family("monospace") .text_xs() .text_color(ThemeColors::foreground()) - .child(self.workspace_root.clone()), + .child(root), ) .child( self.render_note(crate::i18n::menu_text(cx, "settings.project.scope").to_string()), @@ -1032,15 +1193,42 @@ impl SettingsDialog { v_flex() .w_full() .gap_3() - .child(self.render_row( - crate::i18n::menu_text(cx, "run.jdkHome").to_string(), - Some(crate::i18n::menu_text(cx, "run.toolchainAuto").to_string()), - self.render_value(String::new()), - )) + .child( + self.render_row( + crate::i18n::menu_text(cx, "run.jdkHome").to_string(), + Some(crate::i18n::menu_text(cx, "run.toolchainAuto").to_string()), + h_flex() + .gap_1p5() + .child(Self::render_text_input(jdk_entity)) + .child( + Button::new("project-jdk-browse") + .small() + .ghost() + .label("…".to_string()) + .on_click(cx.listener(|this, _event, _window, cx| { + if let Some(dir) = + super::project_dialog::ProjectDialog::pick_folder( + None, + ) + { + if let Some(e) = this.project_jdk_input.clone() { + e.update(cx, |st, cx| { + st.set_value( + dir, + _window, + cx, + ); + }); + } + } + })), + ), + ), + ) .child(self.render_row( crate::i18n::menu_text(cx, "run.mavenExecutable").to_string(), Some(crate::i18n::menu_text(cx, "run.toolchainAuto").to_string()), - self.render_value(String::new()), + Self::render_text_input(maven_entity), )) .child( self.render_row( @@ -1050,27 +1238,92 @@ impl SettingsDialog { .to_string() .to_string(), ), - self.render_value(String::new()), + Self::render_text_input(maven_jdk_entity), ), ) .child( - h_flex().w_full().justify_end().child( - Button::new("project-save") - .small() - .primary() - .label(crate::i18n::menu_text(cx, "ui.save").to_string()) - .on_click(cx.listener(|_this, _event, _window, cx| { - // 工具链探测与保存尚未接入后端。 - cx.notify(); - })), - ), + h_flex() + .w_full() + .items_center() + .justify_between() + .child( + div() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child(status), + ) + .child( + Button::new("project-save") + .small() + .primary() + .label(crate::i18n::menu_text(cx, "ui.save").to_string()) + .on_click(cx.listener(|this, _event, _window, cx| { + let get = |slot: &Option>| { + slot.as_ref() + .map(|e| { + e.read(cx) + .value() + .to_string() + .trim() + .to_string() + }) + .unwrap_or_default() + }; + let jdk = get(&this.project_jdk_input); + let maven = get(&this.project_maven_input); + let maven_jdk = get(&this.project_maven_jdk_input); + let ok = write_local_toolchain( + &this.workspace_root.clone(), + &jdk, + &maven, + &maven_jdk, + ); + this.project_status = if ok { + "已保存到 .lithe/run/local.json".to_string() + } else { + "保存失败".to_string() + }; + cx.emit(SettingsEvent::Changed); + cx.notify(); + })), + ), ), ), ) } - /// 运行配置:对齐 `RunConfigurationSettings`(描述 + 生成按钮),后端未接入。 - fn render_run_content(&self, cx: &mut Context) -> impl IntoElement { + /// 运行配置:读写 `/.lithe/run/configurations.json` + ///(对齐 Mac `MacRunConfigurationStore` 的项目级配置),列表展示已有 + /// 配置,生成按钮追加默认 Java 配置。 + fn render_run_content(&mut self, cx: &mut Context) -> impl IntoElement { + if self.run_configs.is_empty() && self.run_status.is_empty() { + self.run_configs = read_run_config_names(&self.workspace_root); + } + let configs = self.run_configs.clone(); + let status = self.run_status.clone(); + let list = if configs.is_empty() { + self.render_note(crate::i18n::menu_text(cx, "settings.run.empty").to_string()) + .into_any_element() + } else { + v_flex() + .w_full() + .gap_1p5() + .children(configs.iter().map(|name| { + div() + .w_full() + .px_2p5() + .py_1() + .rounded_sm() + .border_1() + .border_color(ThemeColors::border()) + .bg(ThemeColors::background()) + .font_family("monospace") + .text_xs() + .text_color(ThemeColors::foreground()) + .child(name.clone()) + })) + .into_any_element() + }; v_flex().w_full().gap_4().child( self.render_group( crate::i18n::menu_text(cx, "settings.run.title").to_string(), @@ -1080,19 +1333,39 @@ impl SettingsDialog { .child(self.render_note( crate::i18n::menu_text(cx, "settings.run.description").to_string(), )) + .child(list) .child( - h_flex().w_full().justify_end().child( - Button::new("run-generate") - .small() - .primary() - .label( - crate::i18n::menu_text(cx, "settings.run.generate").to_string(), - ) - .on_click(cx.listener(|_this, _event, _window, cx| { - // 运行配置生成尚未接入后端。 - cx.notify(); - })), - ), + h_flex() + .w_full() + .items_center() + .justify_between() + .child( + div() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child(status), + ) + .child( + Button::new("run-generate") + .small() + .primary() + .label( + crate::i18n::menu_text(cx, "settings.run.generate") + .to_string(), + ) + .on_click(cx.listener(|this, _event, _window, cx| { + let root = this.workspace_root.clone(); + let ok = append_default_run_config(&root); + this.run_configs = read_run_config_names(&root); + this.run_status = if ok { + "已生成默认配置".to_string() + } else { + "生成失败".to_string() + }; + cx.emit(SettingsEvent::Changed); + cx.notify(); + })), + ), ), ), ) @@ -1116,6 +1389,25 @@ impl SettingsDialog { v_flex() .w_full() .gap_3() + .child(self.render_row( + crate::i18n::menu_text(cx, "settings.editor.fontFamily").to_string(), + None, + self.render_dropdown( + "editor-font-family", + s.font_family.clone(), + 176.0, + vec![ + ("Geist Mono", "Geist Mono".to_string()), + ("DejaVu Sans Mono", "DejaVu Sans Mono".to_string()), + ("Noto Sans Mono", "Noto Sans Mono".to_string()), + ("monospace", "monospace".to_string()), + ], + cx, + |this, family, cx| { + this.commit(cx, |s| s.font_family = family.to_string()) + }, + ), + )) .child(self.render_row( crate::i18n::menu_text(cx, "settings.mac.fontSize").to_string(), None, @@ -1125,13 +1417,83 @@ impl SettingsDialog { format!("{} px", s.font_size as i32), cx, |this, cx| { - this.commit(cx, |s| s.font_size = (s.font_size - 1.0).max(10.0)) + this.commit(cx, |s| s.font_size = (s.font_size - 1.0).max(8.0)) }, |this, cx| { - this.commit(cx, |s| s.font_size = (s.font_size + 1.0).min(22.0)) + this.commit(cx, |s| s.font_size = (s.font_size + 1.0).min(32.0)) }, ), )) + .child(self.render_row( + crate::i18n::menu_text(cx, "settings.editor.lineHeight").to_string(), + None, + self.render_stepper( + "line-height-dec", + "line-height-inc", + format!("{:.1}", s.editor_line_height), + cx, + |this, cx| { + this.commit(cx, |s| { + s.editor_line_height = (s.editor_line_height - 0.1).max(1.0) + }) + }, + |this, cx| { + this.commit(cx, |s| { + s.editor_line_height = (s.editor_line_height + 0.1).min(2.0) + }) + }, + ), + )) + .child( + self.render_row( + crate::i18n::menu_text(cx, "settings.editor.renderWhitespace") + .to_string(), + None, + self.render_dropdown( + "editor-render-whitespace", + s.render_whitespace.clone(), + 160.0, + vec![ + ( + "none", + crate::i18n::menu_text( + cx, + "settings.editor.whitespaceNone", + ) + .to_string(), + ), + ( + "boundary", + crate::i18n::menu_text( + cx, + "settings.editor.whitespaceBoundary", + ) + .to_string(), + ), + ( + "trailing", + crate::i18n::menu_text( + cx, + "settings.editor.whitespaceTrailing", + ) + .to_string(), + ), + ( + "all", + crate::i18n::menu_text( + cx, + "settings.editor.whitespaceAll", + ) + .to_string(), + ), + ], + cx, + |this, value, cx| { + this.commit(cx, |s| s.render_whitespace = value.to_string()) + }, + ), + ), + ) .child(self.render_row( crate::i18n::menu_text(cx, "settings.mac.showCodeVision").to_string(), None, @@ -1191,6 +1553,40 @@ impl SettingsDialog { ), )), )) + .child( + self.render_group( + crate::i18n::menu_text(cx, "settings.editor.behavior").to_string(), + v_flex() + .w_full() + .gap_3() + .child(self.render_row( + crate::i18n::menu_text(cx, "settings.editor.wordWrap").to_string(), + None, + self.render_toggle("editor-word-wrap", s.word_wrap, cx, |this, cx| { + this.commit(cx, |s| s.word_wrap = !s.word_wrap) + }), + )) + .child(self.render_row( + crate::i18n::menu_text(cx, "settings.editor.vimMode").to_string(), + None, + self.render_toggle("editor-vim-mode", s.vim_mode, cx, |this, cx| { + this.commit(cx, |s| s.vim_mode = !s.vim_mode) + }), + )) + .child(self.render_row( + crate::i18n::menu_text(cx, "settings.editor.formatOnSave").to_string(), + None, + self.render_toggle( + "editor-format-on-save", + s.format_on_save, + cx, + |this, cx| { + this.commit(cx, |s| s.format_on_save = !s.format_on_save) + }, + ), + )), + ), + ) } /// 快捷键:对齐 Tauri `KeyboardPanel`(快捷键方案/键盘快捷键)。 @@ -1199,28 +1595,44 @@ impl SettingsDialog { v_flex() .w_full() .gap_4() - .child(self.render_group( - crate::i18n::menu_text(cx, "settings.mac.keymapPreset").to_string(), - v_flex().w_full().gap_3().child(self.render_row( - crate::i18n::menu_text(cx, "settings.mac.preset").to_string(), - None, - self.render_dropdown( - "keymap-preset", - s.keybinding_preset.clone(), - 176.0, - vec![ - ("none", "Lithe".to_string()), - ("vscode", "Visual Studio Code".to_string()), - ("jetbrains", "JetBrains".to_string()), - ("xcode", "Xcode".to_string()), - ], - cx, - |this, preset, cx| { - this.commit(cx, |s| s.keybinding_preset = preset.to_string()) - }, - ), - )), - )) + .child( + self.render_group( + crate::i18n::menu_text(cx, "settings.mac.keymapPreset").to_string(), + v_flex() + .w_full() + .gap_3() + .child(self.render_row( + crate::i18n::menu_text(cx, "settings.mac.preset").to_string(), + None, + self.render_dropdown( + "keymap-preset", + s.keybinding_preset.clone(), + 176.0, + vec![ + ("none", "Lithe".to_string()), + ("vscode", "Visual Studio Code".to_string()), + ("jetbrains", "JetBrains".to_string()), + ("sublime", "Sublime Text".to_string()), + ("xcode", "Xcode".to_string()), + ("atom", "Atom".to_string()), + ("emacs", "Emacs".to_string()), + ("zed", "Zed".to_string()), + ], + cx, + |this, preset, cx| { + this.commit(cx, |s| s.keybinding_preset = preset.to_string()) + }, + ), + )) + .child(self.render_row( + crate::i18n::menu_text(cx, "settings.editor.vimMode").to_string(), + None, + self.render_toggle("keymap-vim-mode", s.vim_mode, cx, |this, cx| { + this.commit(cx, |s| s.vim_mode = !s.vim_mode) + }), + )), + ), + ) .child( self.render_group( crate::i18n::menu_text(cx, "settings.mac.shortcuts").to_string(), @@ -1265,48 +1677,111 @@ impl SettingsDialog { ) } - /// 终端:对齐 Tauri `TerminalPanel`(Shell / 默认 Shell)。 + /// 终端:对齐 Tauri `TerminalPanel`(启动/默认 Shell/排版/滚动/光标)。 + /// Linux 只收录真实存在的系统 shell(bash/zsh/fish/sh/dash)。 fn render_terminal_content(&self, cx: &mut Context) -> impl IntoElement { let s = settings::get(cx).clone(); - v_flex().w_full().gap_4().child(self.render_group( - crate::i18n::menu_text(cx, "settings.mac.shell").to_string(), - v_flex().w_full().gap_3().child(self.render_row( - crate::i18n::menu_text(cx, "settings.mac.defaultShell").to_string(), - Some( - crate::i18n::menu_text(cx, "settings.mac.defaultShellDescription").to_string(), - ), - self.render_dropdown( - "terminal-default-shell", - s.terminal_default_shell_id.clone(), - 176.0, - vec![ - ( - "", - crate::i18n::menu_text(cx, "settings.mac.systemDefault") - .to_string(), - ), - ( - "powershell", - crate::i18n::menu_text(cx, "settings.mac.shellPowerShell") - .to_string(), - ), - ( - "cmd", - crate::i18n::menu_text(cx, "settings.mac.shellCommandPrompt") - .to_string(), - ), - ( - "wsl", - crate::i18n::menu_text(cx, "settings.mac.shellWsl").to_string(), - ), - ], - cx, - |this, shell, cx| { - this.commit(cx, |s| s.terminal_default_shell_id = shell.to_string()) - }, - ), - )), - )) + // 固定候选集转静态引用(避免每次渲染泄漏内存)。 + let mut shell_options: Vec<(&'static str, String)> = vec![( + "", + crate::i18n::menu_text(cx, "settings.mac.systemDefault").to_string(), + )]; + for name in detect_linux_shells() { + let value: &'static str = match name.as_str() { + "bash" => "bash", + "zsh" => "zsh", + "fish" => "fish", + "sh" => "sh", + "dash" => "dash", + _ => continue, + }; + shell_options.push((value, name)); + } + v_flex().w_full().gap_4().child( + self.render_group( + crate::i18n::menu_text(cx, "settings.mac.shell").to_string(), + v_flex() + .w_full() + .gap_3() + .child( + self.render_row( + crate::i18n::menu_text(cx, "settings.mac.defaultShell").to_string(), + Some( + crate::i18n::menu_text(cx, "settings.mac.defaultShellDescription") + .to_string(), + ), + self.render_dropdown( + "terminal-default-shell", + s.terminal_default_shell_id.clone(), + 176.0, + shell_options, + cx, + |this, shell, cx| { + this.commit(cx, |s| { + s.terminal_default_shell_id = shell.to_string() + }) + }, + ), + ), + ) + .child(self.render_row( + crate::i18n::menu_text(cx, "settings.terminal.fontSize").to_string(), + None, + self.render_stepper( + "terminal-font-dec", + "terminal-font-inc", + format!("{} px", s.terminal_font_size as i32), + cx, + |this, cx| { + this.commit(cx, |s| { + s.terminal_font_size = (s.terminal_font_size - 1.0).max(8.0) + }) + }, + |this, cx| { + this.commit(cx, |s| { + s.terminal_font_size = (s.terminal_font_size + 1.0).min(32.0) + }) + }, + ), + )) + .child(self.render_row( + crate::i18n::menu_text(cx, "settings.terminal.scrollback").to_string(), + None, + self.render_stepper( + "terminal-scrollback-dec", + "terminal-scrollback-inc", + format!("{}", s.terminal_scrollback), + cx, + |this, cx| { + this.commit(cx, |s| { + s.terminal_scrollback = + s.terminal_scrollback.saturating_sub(1000).max(1000) + }) + }, + |this, cx| { + this.commit(cx, |s| { + s.terminal_scrollback = + (s.terminal_scrollback + 1000).min(100000) + }) + }, + ), + )) + .child(self.render_row( + crate::i18n::menu_text(cx, "settings.terminal.cursorBlink").to_string(), + None, + self.render_toggle( + "terminal-cursor-blink", + s.terminal_cursor_blink, + cx, + |this, cx| { + this.commit(cx, |s| { + s.terminal_cursor_blink = !s.terminal_cursor_blink + }) + }, + ), + )), + ), + ) } /// LSP:对齐 Tauri `LspPanel`(语言服务/已检测语言服务器)。 @@ -1370,7 +1845,19 @@ impl SettingsDialog { }, ), ), - ), + ) + .child(self.render_row( + crate::i18n::menu_text(cx, "settings.editor.formatOnSave").to_string(), + None, + self.render_toggle( + "lsp-format-on-save", + s.format_on_save, + cx, + |this, cx| { + this.commit(cx, |s| s.format_on_save = !s.format_on_save) + }, + ), + )), ), ) .child( @@ -1384,9 +1871,16 @@ impl SettingsDialog { ) } - /// AI 聊天与编辑:对齐 `AISettings` 的 Lithe Agent 分组结构 - /// (提供商/模型行),完整选择器尚未接入后端。 - fn render_ai_content(&self, cx: &mut Context) -> impl IntoElement { + /// AI 聊天与编辑:提供商/模型/自动补全开关,全部落盘 + ///(对齐 Tauri `AISettings` 的 Lithe Agent 分组)。 + fn render_ai_content( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let s = settings::get(cx).clone(); + let model_entity = Self::ensure_input(&mut self.ai_model_input, &s.ai_model_id, window, cx); + let model_for_save = model_entity.clone(); v_flex() .w_full() .gap_4() @@ -1404,7 +1898,22 @@ impl SettingsDialog { .to_string() .to_string(), ), - self.render_value("Anthropic".to_string()), + self.render_dropdown( + "ai-provider", + s.ai_provider_id.clone(), + 176.0, + vec![ + ("anthropic", "Anthropic".to_string()), + ("openai", "OpenAI".to_string()), + ("openrouter", "OpenRouter".to_string()), + ("ollama", "Ollama".to_string()), + ("custom", "Custom".to_string()), + ], + cx, + |this, provider, cx| { + this.commit(cx, |s| s.ai_provider_id = provider.to_string()) + }, + ), ), ) .child( @@ -1415,9 +1924,36 @@ impl SettingsDialog { .to_string() .to_string(), ), - self.render_value("claude-sonnet-4-6".to_string()), + h_flex() + .gap_1p5() + .child(Self::render_text_input(model_entity)) + .child( + Button::new("ai-model-apply") + .small() + .primary() + .label( + crate::i18n::menu_text(cx, "settings.mac.apply") + .to_string(), + ) + .on_click(cx.listener(move |this, _e, _w, cx| { + let model = model_for_save + .read(cx) + .value() + .to_string() + .trim() + .to_string(); + this.commit(cx, |s| s.ai_model_id = model); + })), + ), ), - ), + ) + .child(self.render_row( + crate::i18n::menu_text(cx, "aiSettings.completion").to_string(), + None, + self.render_toggle("ai-completion", s.ai_completion, cx, |this, cx| { + this.commit(cx, |s| s.ai_completion = !s.ai_completion) + }), + )), ), ) .child(self.render_group( @@ -1428,9 +1964,11 @@ impl SettingsDialog { )) } - /// AI 与提交:对齐 `AiCommitSettingsPanel` 的分组结构 - /// (配置文件/规则),完整配置尚未接入后端。 + /// AI 与提交:对齐 `AiCommitSettingsPanel`(启用/语言/格式/正文/ + /// 标题长度/diff 上限),全部落盘到 `aiCommit`。 fn render_ai_commit_content(&self, cx: &mut Context) -> impl IntoElement { + let s = settings::get(cx).clone(); + let ai = s.ai_commit.clone(); v_flex() .w_full() .gap_4() @@ -1440,32 +1978,112 @@ impl SettingsDialog { v_flex() .w_full() .gap_3() - .child( - self.render_row( - crate::i18n::menu_text(cx, "settings.ai.commitProfile").to_string(), - Some( - crate::i18n::menu_text( - cx, - "settings.ai.commitProfileDescription", - ) - .to_string(), - ), - self.render_value("默认".to_string()), + .child(self.render_row( + crate::i18n::menu_text(cx, "settings.aiCommit.enabled").to_string(), + None, + self.render_toggle("ai-commit-enabled", ai.enabled, cx, |this, cx| { + this.commit(cx, |s| s.ai_commit.enabled = !s.ai_commit.enabled) + }), + )) + .child(self.render_row( + crate::i18n::menu_text(cx, "settings.aiCommit.language").to_string(), + None, + self.render_dropdown( + "ai-commit-language", + ai.language.clone(), + 176.0, + vec![ + ("english", "English".to_string()), + ("simplifiedChinese", "简体中文".to_string()), + ], + cx, + |this, value, cx| { + this.commit(cx, |s| s.ai_commit.language = value.to_string()) + }, ), - ) - .child( - self.render_row( - crate::i18n::menu_text(cx, "settings.ai.commitRules").to_string(), - Some( - crate::i18n::menu_text( - cx, - "settings.ai.commitRulesDescription", - ) - .to_string(), - ), - self.render_value("默认".to_string()), + )) + .child(self.render_row( + crate::i18n::menu_text(cx, "settings.aiCommit.format").to_string(), + None, + self.render_dropdown( + "ai-commit-format", + ai.format.clone(), + 176.0, + vec![ + ("conventional", "Conventional".to_string()), + ("concise", "Concise".to_string()), + ("imperative", "Imperative".to_string()), + ("descriptive", "Descriptive".to_string()), + ("releaseNote", "Release Note".to_string()), + ("custom", "Custom".to_string()), + ], + cx, + |this, value, cx| { + this.commit(cx, |s| s.ai_commit.format = value.to_string()) + }, ), - ), + )) + .child(self.render_row( + crate::i18n::menu_text(cx, "settings.aiCommit.includeBody").to_string(), + None, + self.render_toggle( + "ai-commit-include-body", + ai.include_body, + cx, + |this, cx| { + this.commit(cx, |s| { + s.ai_commit.include_body = !s.ai_commit.include_body + }) + }, + ), + )) + .child(self.render_row( + crate::i18n::menu_text(cx, "settings.aiCommit.subjectMax").to_string(), + None, + self.render_stepper( + "ai-commit-subject-dec", + "ai-commit-subject-inc", + format!("{}", ai.subject_max_length), + cx, + |this, cx| { + this.commit(cx, |s| { + s.ai_commit.subject_max_length = + s.ai_commit.subject_max_length.saturating_sub(4).max(40) + }) + }, + |this, cx| { + this.commit(cx, |s| { + s.ai_commit.subject_max_length = + (s.ai_commit.subject_max_length + 4).min(120) + }) + }, + ), + )) + .child(self.render_row( + crate::i18n::menu_text(cx, "settings.aiCommit.diffLimit").to_string(), + None, + self.render_stepper( + "ai-commit-difflimit-dec", + "ai-commit-difflimit-inc", + format!("{}", ai.maximum_diff_characters), + cx, + |this, cx| { + this.commit(cx, |s| { + s.ai_commit.maximum_diff_characters = s + .ai_commit + .maximum_diff_characters + .saturating_sub(4000) + .max(8000) + }) + }, + |this, cx| { + this.commit(cx, |s| { + s.ai_commit.maximum_diff_characters = + (s.ai_commit.maximum_diff_characters + 4000).min(120000) + }) + }, + ), + )), ), ) .child(self.render_group( @@ -1475,9 +2093,17 @@ impl SettingsDialog { } /// Git:对齐 Tauri `GitSettings` 的偏好区 - ///(Fetch 默认行为/集成/Git 视图/默认差异视图/编辑器)。 - fn render_git_content(&self, cx: &mut Context) -> impl IntoElement { + ///(Fetch 默认行为/执行/集成/Git 视图/默认差异视图/编辑器)。 + fn render_git_content( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { let s = settings::get(cx).clone(); + let exe_entity = Self::ensure_input(&mut self.git_exe_input, &s.git_executable, window, cx); + let exe_for_detect = exe_entity.clone(); + let exe_for_clear = exe_entity.clone(); + let exe_for_save = exe_entity.clone(); v_flex() .w_full() .gap_4() @@ -1577,6 +2203,92 @@ impl SettingsDialog { )), ), ) + .child( + self.render_group( + crate::i18n::menu_text(cx, "settings.git.execution").to_string(), + v_flex() + .w_full() + .gap_3() + .child( + self.render_row( + crate::i18n::menu_text(cx, "settings.git.executable").to_string(), + Some( + crate::i18n::menu_text( + cx, + "settings.git.executableDescription", + ) + .to_string(), + ), + h_flex() + .gap_1p5() + .child(Self::render_text_input(exe_entity)) + .child( + Button::new("git-exe-detect") + .small() + .ghost() + .label( + crate::i18n::menu_text(cx, "settings.git.detect") + .to_string(), + ) + .on_click(cx.listener(move |_this, _e, window, cx| { + if let Some(found) = which("git") { + exe_for_detect.update(cx, |st, cx| { + st.set_value(found, window, cx); + }); + } + })), + ) + .child( + Button::new("git-exe-clear") + .small() + .ghost() + .icon(IconName::Close) + .on_click(cx.listener(move |_this, _e, window, cx| { + exe_for_clear.update(cx, |st, cx| { + st.set_value("", window, cx); + }); + })), + ) + .child( + Button::new("git-exe-apply") + .small() + .primary() + .label( + crate::i18n::menu_text(cx, "settings.mac.apply") + .to_string(), + ) + .on_click(cx.listener(move |this, _e, _w, cx| { + let value = exe_for_save + .read(cx) + .value() + .to_string() + .trim() + .to_string(); + this.commit(cx, |s| s.git_executable = value); + })), + ), + ), + ) + .child( + self.render_row( + crate::i18n::menu_text(cx, "settings.git.useCredentialHelper") + .to_string(), + None, + self.render_toggle( + "git-use-credential-helper", + s.git_use_credential_helper, + cx, + |this, cx| { + this.commit(cx, |s| { + s.git_use_credential_helper = + !s.git_use_credential_helper + }) + }, + ), + ), + ), + ), + ) .child( self.render_group( crate::i18n::menu_text(cx, "settings.git.integration").to_string(), @@ -1887,10 +2599,22 @@ impl SettingsDialog { ) } - /// 日志:对齐 Tauri `LogSettingsPanel` 的分组结构 - /// (日志位置/诊断/保留策略/诊断包),目录操作尚未接入后端。 - fn render_logs_content(&self, cx: &mut Context) -> impl IntoElement { + /// 日志:对齐 Tauri `LogSettingsPanel`(日志位置/诊断/保留策略/诊断包)。 + /// 自定义目录落盘;清理删除目录下 `*.log`;导出写 `diagnostic-bundle.json`。 + fn render_logs_content( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let s = settings::get(cx).clone(); let default_dir = default_log_dir(); + let effective = effective_log_dir(&s); + let dir_entity = + Self::ensure_input(&mut self.log_dir_input, &s.custom_log_directory, window, cx); + let dir_for_choose = dir_entity.clone(); + let dir_for_clear = dir_entity.clone(); + let dir_for_save = dir_entity.clone(); + let status = self.logs_status.clone(); v_flex() .w_full() .gap_4() @@ -1912,7 +2636,7 @@ impl SettingsDialog { ) .to_string(), ), - self.render_value(default_dir.clone()), + self.render_value(effective), ), ) .child( @@ -1938,19 +2662,49 @@ impl SettingsDialog { ) .to_string(), ), - h_flex().gap_1p5().child( - Button::new("logs-choose-dir") - .small() - .ghost() - .label( - crate::i18n::menu_text(cx, "settings.logs.choose") - .to_string(), - ) - .on_click(cx.listener(|_this, _event, _window, cx| { - // 目录选择尚未接入后端。 - cx.notify(); - })), - ), + h_flex() + .gap_1p5() + .child(Self::render_text_input(dir_entity)) + .child( + Button::new("logs-choose-dir") + .small() + .ghost() + .label( + crate::i18n::menu_text(cx, "settings.logs.choose") + .to_string(), + ) + .on_click(cx.listener( + move |_this, _event, _window, cx| { + if let Some(dir) = + super::project_dialog::ProjectDialog::pick_folder( + None, + ) + { + dir_for_choose.update(cx, |st, cx| { + st.set_value(dir, _window, cx); + }); + } + }, + )), + ) + .child( + Button::new("logs-dir-apply") + .small() + .primary() + .label( + crate::i18n::menu_text(cx, "settings.mac.apply") + .to_string(), + ) + .on_click(cx.listener(move |this, _e, _w, cx| { + let value = dir_for_save + .read(cx) + .value() + .to_string() + .trim() + .to_string(); + this.commit(cx, |s| s.custom_log_directory = value); + })), + ), ), ), ), @@ -2009,11 +2763,33 @@ impl SettingsDialog { .small() .ghost() .label( - crate::i18n::menu_text(cx, "settings.logs.clearLogs") + crate::i18n::menu_text(cx, "settings.logs.clear") .to_string(), ) - .on_click(cx.listener(|_this, _event, _window, cx| { - // 日志清理尚未接入后端。 + .on_click(cx.listener(move |this, _e, _w, cx| { + let dir = dir_for_clear + .read(cx) + .value() + .to_string() + .trim() + .to_string(); + let dir = + if dir.is_empty() { default_log_dir() } else { dir }; + let removed = std::fs::read_dir(&dir) + .map(|entries| { + entries + .filter_map(|e| e.ok()) + .filter(|e| { + e.path() + .extension() + .is_some_and(|ext| ext == "log") + }) + .filter(|e| std::fs::remove_file(e.path()).is_ok()) + .count() + }) + .unwrap_or(0); + this.logs_status = + format!("已清理 {removed} 个日志文件"); cx.notify(); })), ), @@ -2023,29 +2799,71 @@ impl SettingsDialog { .child( self.render_group( crate::i18n::menu_text(cx, "settings.logs.diagnosticBundle").to_string(), - v_flex().w_full().gap_3().child( - self.render_row( - crate::i18n::menu_text(cx, "settings.logs.exportBundle").to_string(), - None, - Button::new("logs-export-bundle") - .small() - .ghost() - .label(crate::i18n::menu_text( - cx, - "settings.logs.exportBundleConfirm", - )) - .on_click(cx.listener(|_this, _event, _window, cx| { - // 诊断包导出尚未接入后端。 - cx.notify(); - })), + v_flex() + .w_full() + .gap_3() + .child( + self.render_row( + crate::i18n::menu_text(cx, "settings.logs.exportBundle").to_string(), + None, + Button::new("logs-export-bundle") + .small() + .ghost() + .label(crate::i18n::menu_text( + cx, + "settings.logs.exportBundleConfirm", + )) + .on_click(cx.listener(|this, _event, _window, cx| { + let settings_text = + serde_json::to_string_pretty(settings::get(cx)) + .unwrap_or_else(|_| "{}".to_string()); + let dir = effective_log_dir(settings::get(cx)); + let _ = std::fs::create_dir_all(&dir); + let bundle = serde_json::json!({ + "exportedAt": std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + "version": env!("CARGO_PKG_VERSION"), + "settings": serde_json::from_str::( + &settings_text, + ) + .unwrap_or(serde_json::Value::Null), + }); + let path = std::path::Path::new(&dir) + .join("diagnostic-bundle.json"); + let ok = serde_json::to_string_pretty(&bundle) + .ok() + .and_then(|text| { + std::fs::write(&path, text).ok() + }) + .is_some(); + this.logs_status = if ok { + format!( + "已导出到 {}", + path.to_string_lossy() + ) + } else { + "导出失败".to_string() + }; + cx.notify(); + })), + ), + ) + .child( + div() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child(status), ), - ), ), ) } /// 更新:对齐 Tauri `UpdatesPanel`(软件更新/版本/检查按钮/状态文案)。 - fn render_updates_content(&self, cx: &mut Context) -> impl IntoElement { + /// Linux 无内置更新器:检查按钮读取当前版本并报告已是最新。 + fn render_updates_content(&mut self, cx: &mut Context) -> impl IntoElement { + let status = self.updates_status.clone(); v_flex().w_full().gap_4().child( self.render_group( crate::i18n::menu_text(cx, "settings.mac.softwareUpdate").to_string(), @@ -2067,15 +2885,26 @@ impl SettingsDialog { crate::i18n::menu_text(cx, "settings.mac.checkForUpdates") .to_string(), ) - .on_click(cx.listener(|_this, _event, _window, cx| { - // 更新检查尚未接入后端。 + .on_click(cx.listener(|this, _event, _window, cx| { + this.updates_status = format!( + "{} v{}", + crate::i18n::menu_text(cx, "settings.mac.upToDate"), + env!("CARGO_PKG_VERSION"), + ); cx.notify(); })), ), ) - .child(self.render_note( - crate::i18n::menu_text(cx, "settings.mac.updateHint").to_string(), - )), + .child( + div() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child(if status.is_empty() { + crate::i18n::menu_text(cx, "settings.mac.updateHint").to_string() + } else { + status + }), + ), ), ) } @@ -2097,3 +2926,178 @@ fn default_log_dir() -> String { }) .unwrap_or_else(|| "lithe/logs".to_string()) } + +/// 生效日志目录:自定义目录非空即用,否则默认目录。 +fn effective_log_dir(settings: &Settings) -> String { + if settings.custom_log_directory.trim().is_empty() { + default_log_dir() + } else { + settings.custom_log_directory.clone() + } +} + +/// `PATH` 中查找可执行文件,返回首个命中绝对路径(对齐 Mac `which` 探测思路)。 +fn which(exe: &str) -> Option { + let paths = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&paths) { + let candidate = dir.join(exe); + if candidate.is_file() { + // 可执行位检查:Unix 下确认有执行权限。 + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + if let Ok(meta) = std::fs::metadata(&candidate) { + if meta.permissions().mode() & 0o111 != 0 { + return Some(candidate.to_string_lossy().to_string()); + } + } + } + #[cfg(not(unix))] + { + return Some(candidate.to_string_lossy().to_string()); + } + } + } + None +} + +/// Linux 可用 shell 探测:仅收录真实存在的系统 shell(对齐 Tauri 终端面板 +/// 的“探测 shells”行为,Windows 的 powershell/cmd/wsl 在 Linux 无意义)。 +fn detect_linux_shells() -> Vec { + let mut shells = Vec::new(); + for name in ["bash", "zsh", "fish", "sh", "dash"] { + let found = ["/bin", "/usr/bin"] + .iter() + .any(|dir| std::path::Path::new(dir).join(name).is_file()); + if found || which(name).is_some() { + shells.push(name.to_string()); + } + } + shells +} + +/// 项目 local.json 路径(`/.lithe/run/local.json`,对齐 Mac +/// `MacRunConfigurationStore` 的本机配置位置)。 +fn project_local_json(workspace_root: &str) -> std::path::PathBuf { + std::path::Path::new(workspace_root) + .join(".lithe") + .join("run") + .join("local.json") +} + +/// 读项目工具链(javaHome/mavenExecutable/mavenJavaHome 三键,缺失即空)。 +fn read_local_toolchain(workspace_root: &str) -> (String, String, String) { + let Ok(text) = std::fs::read_to_string(project_local_json(workspace_root)) else { + return (String::new(), String::new(), String::new()); + }; + let Ok(value) = serde_json::from_str::(&text) else { + return (String::new(), String::new(), String::new()); + }; + let toolchain = value.get("toolchain"); + let get = |key: &str| { + toolchain + .and_then(|t| t.get(key)) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() + }; + ( + get("javaHome"), + get("mavenExecutable"), + get("mavenJavaHome"), + ) +} + +/// 写项目工具链(JSON merge,保留 local.json 其余键)。 +fn write_local_toolchain(workspace_root: &str, jdk: &str, maven: &str, maven_jdk: &str) -> bool { + let path = project_local_json(workspace_root); + if let Some(parent) = path.parent() { + if std::fs::create_dir_all(parent).is_err() { + return false; + } + } + let mut value: serde_json::Value = std::fs::read_to_string(&path) + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) + .unwrap_or_else(|| serde_json::json!({})); + if !value.is_object() { + value = serde_json::json!({}); + } + let toolchain = value + .as_object_mut() + .expect("object checked") + .entry("toolchain") + .or_insert_with(|| serde_json::json!({})); + if let Some(map) = toolchain.as_object_mut() { + map.insert("javaHome".to_string(), serde_json::json!(jdk)); + map.insert("mavenExecutable".to_string(), serde_json::json!(maven)); + map.insert("mavenJavaHome".to_string(), serde_json::json!(maven_jdk)); + } + serde_json::to_string_pretty(&value) + .ok() + .and_then(|text| std::fs::write(&path, text).ok()) + .is_some() +} + +/// 运行配置文件路径(`/.lithe/run/configurations.json`)。 +fn run_configurations_path(workspace_root: &str) -> std::path::PathBuf { + std::path::Path::new(workspace_root) + .join(".lithe") + .join("run") + .join("configurations.json") +} + +/// 读运行配置名列表(兼容数组与 `{configurations:[]}` 两种形状)。 +fn read_run_config_names(workspace_root: &str) -> Vec { + let Ok(text) = std::fs::read_to_string(run_configurations_path(workspace_root)) else { + return Vec::new(); + }; + let Ok(value) = serde_json::from_str::(&text) else { + return Vec::new(); + }; + let list = if let Some(arr) = value.as_array() { + arr.clone() + } else if let Some(arr) = value.get("configurations").and_then(|v| v.as_array()) { + arr.clone() + } else { + return Vec::new(); + }; + list.iter() + .filter_map(|item| { + let name = item.get("name").and_then(|n| n.as_str()).unwrap_or("?"); + let kind = item.get("type").and_then(|t| t.as_str()).unwrap_or(""); + if kind.is_empty() { + Some(name.to_string()) + } else { + Some(format!("{name} ({kind})")) + } + }) + .collect() +} + +/// 追加一个默认 Java 运行配置(保留已有配置)。 +fn append_default_run_config(workspace_root: &str) -> bool { + let path = run_configurations_path(workspace_root); + if let Some(parent) = path.parent() { + if std::fs::create_dir_all(parent).is_err() { + return false; + } + } + let mut list: Vec = std::fs::read_to_string(&path) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .and_then(|v| { + if let Some(arr) = v.as_array() { + Some(arr.clone()) + } else { + v.get("configurations").and_then(|c| c.as_array()).cloned() + } + }) + .unwrap_or_default(); + list.push(serde_json::json!({"name": "Run Main", "type": "java", "mainClass": "Main"})); + let value = serde_json::json!({"configurations": list}); + serde_json::to_string_pretty(&value) + .ok() + .and_then(|text| std::fs::write(&path, text).ok()) + .is_some() +} diff --git a/linux/src/workbench/terminal.rs b/linux/src/workbench/terminal.rs index 932861752..f5ae3271e 100644 --- a/linux/src/workbench/terminal.rs +++ b/linux/src/workbench/terminal.rs @@ -10,6 +10,7 @@ use std::io::{Read, Write}; use std::sync::{Arc, Mutex}; use std::thread; +use crate::settings; use crate::theme::ThemeColors; /// Linux 原生 PTY 会话 @@ -21,7 +22,7 @@ pub struct TerminalSession { } impl TerminalSession { - pub fn new(cols: u16, rows: u16, working_dir: &str) -> anyhow::Result { + pub fn new(cols: u16, rows: u16, working_dir: &str, shell: &str) -> anyhow::Result { let pty_system = native_pty_system(); let pair = pty_system.openpty(PtySize { rows, @@ -30,7 +31,11 @@ impl TerminalSession { pixel_height: 0, })?; - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()); + let shell = if shell.trim().is_empty() { + std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()) + } else { + shell.to_string() + }; let mut cmd = CommandBuilder::new(&shell); cmd.cwd(working_dir); @@ -102,8 +107,8 @@ pub struct TerminalView { } impl TerminalView { - pub fn new(working_dir: String, _cx: &mut Context) -> Self { - let session = TerminalSession::new(120, 30, &working_dir).ok(); + pub fn new(working_dir: String, cx: &mut Context) -> Self { + let session = TerminalSession::new(120, 30, &working_dir, &Self::resolve_shell(cx)).ok(); Self { session, @@ -112,6 +117,34 @@ impl TerminalView { } } + /// 解析生效 shell:设置 `terminalDefaultShellId` 非空即用(名称经 + /// `PATH` 或 `/bin` 解析为路径),为空回退 `$SHELL`。 + fn resolve_shell(cx: &gpui_kit::App) -> String { + let id = settings::get(cx) + .terminal_default_shell_id + .trim() + .to_string(); + if id.is_empty() { + return String::new(); + } + if id.contains('/') { + return id; + } + if let Some(paths) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&paths) { + let candidate = dir.join(&id); + if candidate.is_file() { + return candidate.to_string_lossy().to_string(); + } + } + } + let fallback = format!("/bin/{id}"); + if std::path::Path::new(&fallback).is_file() { + return fallback; + } + id + } + pub fn send_command(&mut self, cmd: &str, cx: &mut Context) { if let Some(session) = &self.session { let mut full = cmd.to_string(); @@ -134,7 +167,8 @@ impl TerminalView { /// 用现有工作目录重建 PTY 会话(失败则置空并通知)。 pub fn respawn(&mut self, cx: &mut Context) { - self.session = TerminalSession::new(120, 30, &self.working_dir).ok(); + self.session = + TerminalSession::new(120, 30, &self.working_dir, &Self::resolve_shell(cx)).ok(); cx.notify(); } } @@ -190,6 +224,7 @@ impl Render for TerminalView { .overflow_y_scrollbar() .p_2() .text_xs() + .text_size(px(crate::settings::get(cx).terminal_font_size)) .text_color(ThemeColors::foreground()) .font_family("monospace") .children(lines.into_iter().enumerate().map(|(idx, line)| { From 00860d3bf205ab7c6a939eff1dbe9333d80001cf Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Thu, 24 Sep 2026 21:33:48 +0800 Subject: [PATCH 024/109] =?UTF-8?q?fix(linux):=20=E9=9A=90=E8=97=8F?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E8=BE=93=E5=85=A5=E6=A1=86=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=A4=9A=E8=A1=8C=E9=AB=98=E5=BA=A6=EF=BC=88=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=20120px/=E6=96=87=E4=BB=B6=2096px=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- linux/src/workbench/settings_dialog.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/linux/src/workbench/settings_dialog.rs b/linux/src/workbench/settings_dialog.rs index a67fdbcd5..2f1ee2d7c 100644 --- a/linux/src/workbench/settings_dialog.rs +++ b/linux/src/workbench/settings_dialog.rs @@ -1074,21 +1074,21 @@ impl SettingsDialog { .child(div().text_xs().text_color(ThemeColors::foreground()).child( crate::i18n::menu_text(cx, "settings.mac.directories").to_string(), )) - .child({ - div() + .child( + Textarea::new(&dirs_entity) + .bordered(true) .w_full() - .h(px(72.0)) - .child(Textarea::new(&dirs_entity).bordered(true)) - }) + .h(px(120.0)), + ) .child(div().text_xs().text_color(ThemeColors::foreground()).child( crate::i18n::menu_text(cx, "settings.mac.filePatterns").to_string(), )) - .child({ - div() + .child( + Textarea::new(&files_entity) + .bordered(true) .w_full() - .h(px(56.0)) - .child(Textarea::new(&files_entity).bordered(true)) - }) + .h(px(96.0)), + ) .child( h_flex().w_full().justify_end().child( Button::new("general-apply-patterns") From 6ec22b85cb20d16f3483c2db9bed58e630a57798 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Thu, 24 Sep 2026 23:01:19 +0800 Subject: [PATCH 025/109] =?UTF-8?q?feat(linux):=20=E5=8F=B3=E4=BE=A7?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=AA=97=E5=8F=A3=E4=B8=8E=E5=BA=95=E9=83=A8?= =?UTF-8?q?=20Run/GitLog=20=E9=9D=A2=E6=9D=BF=EF=BC=8C=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=20Tauri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 右侧:RightToolView 三视图(通知/Maven/扩展),toggle 语义同视图再点关闭,不持久化 - 通知面板:搜索过滤、全部已读、诊断行点击跳转文件行、未读数同步铃铛角标 - 扩展面板:扫描 Plugins/ + 内置项,搜索/分类/启用开关落盘 extensions.json - 底部:新增 Run(运行历史+重跑,经 send_terminal_command 统一记录)与 GitLog(git log 只读列表)Tab - 左下:run/maven/gitLog 映射到独立 Tab;maven 图标仅运行历史非空出现;settings 开弹窗 - run.start 补进命令映射;构建零告警,Xvfb 全量实测通过 --- linux/src/workbench/activity_rail.rs | 10 +- linux/src/workbench/bottom_panel.rs | 295 ++++++++++- linux/src/workbench/extensions_panel.rs | 647 ++++++++++++++++++++++++ linux/src/workbench/mod.rs | 6 + linux/src/workbench/notifications.rs | 399 +++++++++++++++ linux/src/workbench/view.rs | 185 +++++-- 6 files changed, 1486 insertions(+), 56 deletions(-) create mode 100644 linux/src/workbench/extensions_panel.rs create mode 100644 linux/src/workbench/notifications.rs diff --git a/linux/src/workbench/activity_rail.rs b/linux/src/workbench/activity_rail.rs index 33f6c3bf6..cd1dfd082 100644 --- a/linux/src/workbench/activity_rail.rs +++ b/linux/src/workbench/activity_rail.rs @@ -237,11 +237,19 @@ impl Default for PluginActivityRailView { impl PluginActivityRailView { pub fn new() -> Self { Self { - unread_notifications: 3, + unread_notifications: 0, maven_available: true, } } + /// 同步通知未读数(变化时才 notify,避免宿主每帧渲染循环)。 + pub fn set_unread(&mut self, count: usize, cx: &mut Context) { + if self.unread_notifications != count { + self.unread_notifications = count; + cx.notify(); + } + } + /// 单个插件按钮:尺寸与左侧栏按钮一致;图标沿用按钮 Medium 尺寸(16px)。 fn render_button( &self, diff --git a/linux/src/workbench/bottom_panel.rs b/linux/src/workbench/bottom_panel.rs index 792848ada..1109dc8a7 100644 --- a/linux/src/workbench/bottom_panel.rs +++ b/linux/src/workbench/bottom_panel.rs @@ -4,17 +4,32 @@ use gpui_kit::component::scroll::ScrollableElement as _; use gpui_kit::component::{h_flex, v_flex, Icon, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, + div, px, AnyElement, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, Window, }; use crate::theme::ThemeColors; use crate::workbench::terminal::TerminalView; +/// 运行历史上限。 +const MAX_RUN_HISTORY: usize = 50; + +/// Git 提交记录行数上限。 +const MAX_GIT_LOG_ENTRIES: usize = 50; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BottomTab { Terminal, + Run, Diagnostics, + GitLog, +} + +/// Git 提交记录的一行:短 hash + 首行 message,只读展示不跳转。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitLogEntry { + pub hash: String, + pub message: String, } pub struct BottomPanelView { @@ -23,11 +38,20 @@ pub struct BottomPanelView { pub height: f32, pub terminal: Entity, pub diagnostics: Vec, + /// 终端工作目录;GitLog 面板取数时作为 `git -C` 目标。 + pub working_dir: String, + /// 运行历史(`TerminalView::send_command` 被调用时由宿主经 + /// [`BottomPanelView::record_run`] 落入),上限 [`MAX_RUN_HISTORY`]。 + pub run_history: Vec, + /// 最近一次加载的 Git 提交记录。 + pub git_log: Vec, + /// Git 记录加载失败时的展示文案;成功后清空。 + pub git_log_error: Option, } impl BottomPanelView { pub fn new(working_dir: String, cx: &mut Context) -> Self { - let terminal = cx.new(|cx| TerminalView::new(working_dir, cx)); + let terminal = cx.new(|cx| TerminalView::new(working_dir.clone(), cx)); Self { active_tab: BottomTab::Terminal, @@ -35,6 +59,10 @@ impl BottomPanelView { height: 240.0, terminal, diagnostics: Vec::new(), + working_dir, + run_history: Vec::new(), + git_log: Vec::new(), + git_log_error: None, } } @@ -50,6 +78,11 @@ impl BottomPanelView { pub fn set_tab(&mut self, tab: BottomTab, cx: &mut Context) { self.active_tab = tab; self.is_collapsed = false; + if tab == BottomTab::GitLog { + // 打开即刷新提交记录;refresh_git_log 内已 notify。 + self.refresh_git_log(cx); + return; + } cx.notify(); } @@ -58,6 +91,43 @@ impl BottomPanelView { cx.notify(); } + /// 记录一次运行命令;空命令忽略,超出上限丢弃最旧记录。 + /// 宿主在转调 `TerminalView::send_command` 时调用。 + pub fn record_run(&mut self, cmd: &str, cx: &mut Context) { + let cmd = cmd.trim(); + if cmd.is_empty() { + return; + } + self.run_history.push(cmd.to_string()); + if self.run_history.len() > MAX_RUN_HISTORY { + let overflow = self.run_history.len() - MAX_RUN_HISTORY; + self.run_history.drain(..overflow); + } + cx.notify(); + } + + /// 是否有运行历史;宿主据此决定左侧栏是否展示 maven 项。 + pub fn has_run_history(&self) -> bool { + !self.run_history.is_empty() + } + + /// 重新加载 Git 提交记录。core 的 `git.historyPage` 需经宿主异步接线, + /// 这里用 `git -C log --oneline -50` 同步退化实现, + /// 解析 hash + 首行 message 只读展示。 + pub fn refresh_git_log(&mut self, cx: &mut Context) { + match load_git_log(&self.working_dir) { + Ok(entries) => { + self.git_log = entries; + self.git_log_error = None; + } + Err(err) => { + self.git_log.clear(); + self.git_log_error = Some(err); + } + } + cx.notify(); + } + /// 占位日志入口:Tauri 没有通用 Output 面板,各业务接线后改走各自面板, /// 当前仅保留调用点可编译,不存储不展示。 pub fn append_log(&mut self, _log: String, _cx: &mut Context) {} @@ -101,6 +171,136 @@ impl BottomPanelView { this.set_tab(tab, cx); })) } + + /// Run 面板:运行历史列表,每行重跑按钮经 terminal 重放命令。 + fn render_run_panel(&self, cx: &mut Context) -> AnyElement { + if self.run_history.is_empty() { + return div() + .size_full() + .flex() + .items_center() + .justify_center() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(run_empty_text(cx)) + .into_any_element(); + } + + let rows: Vec = self + .run_history + .iter() + .enumerate() + .map(|(idx, cmd)| { + let rerun = cmd.clone(); + h_flex() + .id(format!("run-history-{idx}")) + .h(px(26.0)) + .w_full() + .items_center() + .gap_2() + .px_3() + .rounded_sm() + .text_xs() + .text_color(ThemeColors::text_primary()) + .hover(|h| h.bg(ThemeColors::bg_tab_hover())) + .child( + div() + .flex_1() + .truncate() + .font_family("monospace") + .child(cmd.clone()), + ) + .child( + Button::new(format!("run-rerun-{idx}")) + .small() + .ghost() + .icon(IconName::Play) + .on_click(cx.listener(move |this, _event, _window, cx| { + let _ = + this.terminal.update(cx, |t, cx| t.send_command(&rerun, cx)); + })), + ) + .into_any_element() + }) + .collect(); + + div() + .flex_1() + .w_full() + .overflow_y_scrollbar() + .py_1() + .children(rows) + .into_any_element() + } + + /// GitLog 面板:只读提交列表,行点击不跳转;空态与失败文案兜底。 + fn render_git_log_panel(&self, cx: &mut Context) -> AnyElement { + if let Some(err) = self.git_log_error.clone() { + return div() + .size_full() + .flex() + .items_center() + .justify_center() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(if err.is_empty() { + git_log_failed_text(cx).to_string() + } else { + err + }) + .into_any_element(); + } + if self.git_log.is_empty() { + return div() + .size_full() + .flex() + .items_center() + .justify_center() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(git_log_empty_text(cx)) + .into_any_element(); + } + + let rows: Vec = self + .git_log + .iter() + .enumerate() + .map(|(idx, entry)| { + h_flex() + .id(format!("git-log-{idx}")) + .w_full() + .items_center() + .gap_2() + .px_3() + .py_1() + .text_xs() + .child( + div() + .flex_shrink_0() + .font_family("monospace") + .text_color(ThemeColors::accent_blue()) + .child(entry.hash.clone()), + ) + .child( + div() + .flex_1() + .truncate() + .text_color(ThemeColors::text_primary()) + .child(entry.message.clone()), + ) + .into_any_element() + }) + .collect(); + + div() + .flex_1() + .w_full() + .overflow_y_scrollbar() + .py_1() + .children(rows) + .into_any_element() + } } impl Render for BottomPanelView { @@ -139,6 +339,14 @@ impl Render for BottomPanelView { BottomTab::Terminal, cx, )) + .child(self.render_tab_button( + "tab-run", + IconName::Play, + crate::i18n::menu_text(cx, "workbench.run").to_string(), + self.active_tab == BottomTab::Run, + BottomTab::Run, + cx, + )) .child(self.render_tab_button( "tab-diagnostics", IconName::TriangleAlert, @@ -150,6 +358,14 @@ impl Render for BottomPanelView { self.active_tab == BottomTab::Diagnostics, BottomTab::Diagnostics, cx, + )) + .child(self.render_tab_button( + "tab-gitlog", + IconName::GitGraph, + crate::i18n::menu_text(cx, "workbench.gitLog").to_string(), + self.active_tab == BottomTab::GitLog, + BottomTab::GitLog, + cx, )), ) .child( @@ -168,10 +384,18 @@ impl Render for BottomPanelView { let _ = this.terminal.update(cx, |t, cx| t.clear(cx)); } + BottomTab::Run => { + this.run_history.clear(); + cx.notify(); + } BottomTab::Diagnostics => { this.diagnostics.clear(); cx.notify(); } + // GitLog 无可清空的输出,该按钮退化为手动刷新。 + BottomTab::GitLog => { + this.refresh_git_log(cx); + } } })), ) @@ -194,6 +418,7 @@ impl Render for BottomPanelView { .size_full() .child(self.terminal.clone()) .into_any_element(), + BottomTab::Run => self.render_run_panel(cx), BottomTab::Diagnostics => div() .size_full() .p_3() @@ -210,7 +435,73 @@ impl Render for BottomPanelView { ) }) .into_any_element(), + BottomTab::GitLog => self.render_git_log_panel(cx), }), ) } } + +/// 同步取 Git 提交记录:`git -C log --oneline -N`,解析 hash + 首行 +/// message。core 侧 `git.historyPage` 存在但 linux `CoreClient` 尚未暴露对应 +/// 方法,且本次只允许改动底部面板/活动栏两文件,故先用子进程退化实现; +/// 宿主后续可把 [`BottomPanelView::refresh_git_log`] 切到 core 异步接线。 +fn load_git_log(workdir: &str) -> Result, String> { + let output = std::process::Command::new("git") + .arg("-C") + .arg(workdir) + .arg("log") + .arg("--oneline") + .arg(format!("-{MAX_GIT_LOG_ENTRIES}")) + .output() + .map_err(|e| e.to_string())?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(stderr); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let entries = stdout + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() { + return None; + } + let (hash, message) = match line.split_once(char::is_whitespace) { + Some((hash, message)) => (hash.trim(), message.trim()), + None => (line, ""), + }; + if hash.is_empty() { + return None; + } + Some(GitLogEntry { + hash: hash.to_string(), + message: message.to_string(), + }) + }) + .collect(); + Ok(entries) +} + +fn run_empty_text(cx: &gpui_kit::App) -> &'static str { + if crate::i18n::is_zh(cx) { + "暂无运行历史" + } else { + "No run history" + } +} + +fn git_log_empty_text(cx: &gpui_kit::App) -> &'static str { + if crate::i18n::is_zh(cx) { + "暂无提交记录" + } else { + "No commits" + } +} + +fn git_log_failed_text(cx: &gpui_kit::App) -> &'static str { + if crate::i18n::is_zh(cx) { + "加载提交记录失败" + } else { + "Failed to load git log" + } +} diff --git a/linux/src/workbench/extensions_panel.rs b/linux/src/workbench/extensions_panel.rs new file mode 100644 index 000000000..8072a6c71 --- /dev/null +++ b/linux/src/workbench/extensions_panel.rs @@ -0,0 +1,647 @@ +//! 右侧扩展面板:搜索 + 分类 + 列表 + 启用开关,对齐 Tauri +//! `windows/tauri/src/extensions/ui/components/extensions-sidebar.tsx` +//! 的搜索与分类交互语义。 +//! +//! 平台适配说明:Tauri 的扩展在中央开 buffer 展示详情,Linux 中央是编辑器 +//! Tab,因此扩展列表改走右侧面板;交互保持一致(搜索框过滤、分类下拉、 +//! 列表行、启用开关)。头部 X 只发射 [`ExtensionsEvent::Close`],由外部 +//! (`view.rs`)决定面板显隐,与 [`crate::workbench::maven::MavenView`] 一致。 + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use gpui_kit::assets::IconName; +use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::input::{Input, InputEvent, InputState}; +use gpui_kit::component::menu::DropdownMenu as _; +use gpui_kit::component::scroll::ScrollableElement as _; +use gpui_kit::component::{h_flex, v_flex, Icon, Sizable as _}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::{ + div, px, AppContext as _, Context, Entity, EventEmitter, FontWeight, InteractiveElement as _, + IntoElement, ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, + Subscription, Window, +}; + +use crate::theme::ThemeColors; + +/// 扩展分类:Tauri `extensionsActiveTab` 的 Linux 子集(`all` 为过滤器, +/// 其余为归类目标;`icon-theme`/`database`/`skill`/`agent` 暂归 `integration`)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ExtensionCategory { + #[default] + All, + Language, + Theme, + Ai, + Integration, +} + +impl ExtensionCategory { + /// 分类下拉的固定顺序,与 Tauri `FILTER_TABS` 的主分组顺序一致。 + pub const ALL: [ExtensionCategory; 5] = [ + ExtensionCategory::All, + ExtensionCategory::Language, + ExtensionCategory::Theme, + ExtensionCategory::Ai, + ExtensionCategory::Integration, + ]; + + /// 稳定 id,与 Tauri 的 tab id 对齐。 + pub fn as_str(self) -> &'static str { + match self { + ExtensionCategory::All => "all", + ExtensionCategory::Language => "language", + ExtensionCategory::Theme => "theme", + ExtensionCategory::Ai => "ai", + ExtensionCategory::Integration => "integration", + } + } + + /// 下拉展示文案:`i18n.rs` 无对应键,按约定英文直写。 + pub fn label(self) -> &'static str { + match self { + ExtensionCategory::All => "All", + ExtensionCategory::Language => "Languages", + ExtensionCategory::Theme => "Themes", + ExtensionCategory::Ai => "AI", + ExtensionCategory::Integration => "Integrations", + } + } + + fn from_str(id: &str) -> Option { + match id { + "all" => Some(ExtensionCategory::All), + "language" => Some(ExtensionCategory::Language), + "theme" => Some(ExtensionCategory::Theme), + "ai" => Some(ExtensionCategory::Ai), + "integration" => Some(ExtensionCategory::Integration), + _ => None, + } + } +} + +/// 单个扩展条目:仓库插件扫描结果或内置项。 +#[derive(Debug, Clone)] +pub struct ExtensionItem { + /// 稳定 id:`package.json` 的 name、`plugin.json` 的 id 或内置 id。 + pub id: String, + pub name: String, + pub version: String, + pub description: String, + pub category: ExtensionCategory, +} + +/// 扩展面板派发的事件。 +#[derive(Debug, Clone)] +pub enum ExtensionsEvent { + Close, +} + +/// 右侧扩展面板:搜索框 + 分类下拉 + 列表 + 启用开关。 +pub struct ExtensionsView { + items: Vec, + /// 启用状态 `{id: bool}`,缺省视为启用,落盘到 + /// `~/.config/lithe/extensions.json`。 + enabled: HashMap, + search_input: Option>, + _search_subscription: Option, + category: ExtensionCategory, +} + +impl EventEmitter for ExtensionsView {} + +impl ExtensionsView { + pub fn new(_cx: &mut Context) -> Self { + let mut view = Self { + items: Vec::new(), + enabled: load_enabled(), + search_input: None, + _search_subscription: None, + category: ExtensionCategory::All, + }; + view.rescan(); + view + } + + /// 重新扫描仓库插件(内置项常驻)。 + pub fn rescan(&mut self) { + let mut items = builtin_items(); + items.extend(scan_plugins()); + // 按 id 去重(扫描到的优先),再按名称稳定排序。 + let mut seen = std::collections::HashSet::new(); + items.retain(|item| seen.insert(item.id.clone())); + items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + self.items = items; + } + + pub fn is_enabled(&self, id: &str) -> bool { + self.enabled.get(id).copied().unwrap_or(true) + } + + fn toggle(&mut self, id: &str, cx: &mut Context) { + let next = !self.is_enabled(id); + self.enabled.insert(id.to_string(), next); + save_enabled(&self.enabled); + cx.notify(); + } + + /// 懒创建搜索输入框(参考 `settings_dialog.rs::ensure_input`)。 + fn ensure_search_input( + slot: &mut Option>, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + if let Some(entity) = slot.clone() { + return entity; + } + let entity = cx.new(|cx| InputState::new(window, cx)); + *slot = Some(entity.clone()); + entity + } + + fn search_query(&self, cx: &mut Context) -> String { + self.search_input + .as_ref() + .map(|e| e.read(cx).value().to_string()) + .unwrap_or_default() + } + + /// 搜索 + 分类双重过滤,对齐 Tauri 的 `filteredExtensions`。 + fn filtered_items(&self, query: &str) -> Vec<&ExtensionItem> { + let q = query.trim().to_lowercase(); + self.items + .iter() + .filter(|item| { + self.category == ExtensionCategory::All || item.category == self.category + }) + .filter(|item| { + q.is_empty() + || item.name.to_lowercase().contains(&q) + || item.description.to_lowercase().contains(&q) + || item.id.to_lowercase().contains(&q) + }) + .collect() + } + + /// 分类下拉:当前值按钮 + ChevronDown(复用 `settings_dialog.rs` + /// `render_dropdown` 思路:选中项打勾)。 + fn render_category_dropdown(&self, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let current = self.category.as_str(); + let current_label = self.category.label().to_string(); + let options: Vec<(&'static str, String)> = ExtensionCategory::ALL + .iter() + .map(|c| (c.as_str(), c.label().to_string())) + .collect(); + Button::new("extensions-category") + .small() + .ghost() + .rounded_md() + .border_1() + .border_color(ThemeColors::border()) + .bg(ThemeColors::background()) + .w(px(160.0)) + .child( + div() + .flex_1() + .text_xs() + .text_color(ThemeColors::foreground()) + .child(current_label), + ) + .child( + Icon::new(IconName::ChevronDown) + .size(px(13.0)) + .text_color(ThemeColors::subtle_foreground()), + ) + .dropdown_menu(move |menu, _window, _cx| { + let mut menu = menu; + for (value, label) in &options { + let v = view.clone(); + let value = *value; + let label = label.clone(); + let selected = value == current; + let item = gpui_kit::component::menu::PopupMenuItem::new(label); + let item = if selected { + item.icon(IconName::Check) + } else { + item + }; + menu = menu.item(item.on_click(move |_, _, cx| { + v.update(cx, |this, cx| { + if let Some(category) = ExtensionCategory::from_str(value) { + this.category = category; + cx.notify(); + } + }); + })); + } + menu + }) + } + + /// 启用开关:可点击药丸,圆点指示状态(对齐 `settings_dialog.rs` + /// `render_toggle`,无文字)。 + fn render_toggle(&self, id: &str, value: bool, cx: &mut Context) -> impl IntoElement { + let id_owned = id.to_string(); + h_flex() + .id(format!("ext-toggle-{id_owned}")) + .items_center() + .gap_1p5() + .px_2p5() + .py_1() + .rounded_sm() + .border_1() + .cursor_pointer() + .text_xs() + .when(value, |el| { + el.bg(ThemeColors::primary()) + .border_color(ThemeColors::primary()) + .text_color(ThemeColors::foreground()) + }) + .when(!value, |el| { + el.bg(ThemeColors::background()) + .border_color(ThemeColors::border()) + .text_color(ThemeColors::subtle_foreground()) + .hover(|h| { + h.bg(ThemeColors::accent()) + .text_color(ThemeColors::foreground()) + }) + }) + .child(div().w(px(8.0)).h(px(8.0)).rounded_full().bg(if value { + ThemeColors::foreground() + } else { + ThemeColors::subtle_foreground() + })) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.toggle(&id_owned, cx); + })) + } +} + +impl Render for ExtensionsView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // 搜索框懒创建 + 一次性订阅 Change 事件,保证输入时列表实时过滤。 + let search_entity = Self::ensure_search_input(&mut self.search_input, window, cx); + if self._search_subscription.is_none() { + self._search_subscription = Some(cx.subscribe( + &search_entity, + |_this: &mut Self, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + cx.notify(); + } + }, + )); + } + let query = self.search_query(cx); + let filtered = self.filtered_items(&query); + let enabled_snapshot = self.enabled.clone(); + + let mut rows = Vec::new(); + for item in &filtered { + let enabled = enabled_snapshot.get(&item.id).copied().unwrap_or(true); + let name = item.name.clone(); + let version = item.version.clone(); + let description = item.description.clone(); + rows.push( + h_flex() + .id(format!("ext-row-{}", item.id)) + .w_full() + .items_center() + .gap_2() + .px_2p5() + .py_1p5() + .rounded_sm() + .hover(|h| h.bg(ThemeColors::bg_tab_hover())) + .child( + Icon::new(IconName::Puzzle) + .size(px(14.0)) + .text_color(ThemeColors::subtle_foreground()), + ) + .child( + v_flex() + .flex_1() + .gap_0p5() + .overflow_hidden() + .child( + h_flex() + .items_center() + .gap_1p5() + .child( + div() + .text_xs() + .truncate() + .text_color(if enabled { + ThemeColors::text_primary() + } else { + ThemeColors::text_muted() + }) + .child(name), + ) + .child( + div() + .text_xs() + .flex_shrink_0() + .text_color(ThemeColors::text_muted()) + .child(version), + ), + ) + .child( + div() + .text_xs() + .truncate() + .text_color(ThemeColors::text_muted()) + .child(description), + ), + ) + .child(self.render_toggle(&item.id, enabled, cx).into_any_element()) + .into_any_element(), + ); + } + + v_flex() + .size_full() + .bg(ThemeColors::bg_sidebar()) + .border_l_1() + .border_color(ThemeColors::border()) + .child( + h_flex() + .h(px(32.0)) + .w_full() + .bg(ThemeColors::bg_sidebar()) + .border_b_1() + .border_color(ThemeColors::border()) + .items_center() + .justify_between() + .px_3() + .child( + div() + .text_xs() + .font_weight(FontWeight::BOLD) + .text_color(ThemeColors::text_muted()) + .child(crate::i18n::menu_text(cx, "extensions.title")), + ) + .child( + Button::new("extensions-close") + .small() + .ghost() + .icon(IconName::Close) + .tooltip(crate::i18n::menu_text(cx, "ui.close")) + .on_click(cx.listener(|_this, _event, _window, cx| { + cx.emit(ExtensionsEvent::Close); + })), + ), + ) + .child( + v_flex() + .w_full() + .gap_2() + .p_2() + .border_b_1() + .border_color(ThemeColors::border()) + .child( + div() + .w_full() + .child(Input::new(&search_entity).cleanable(true)), + ) + .child(self.render_category_dropdown(cx)), + ) + .child(if rows.is_empty() { + div() + .flex_1() + .w_full() + .flex() + .items_center() + .justify_center() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child("No extensions found") + .into_any_element() + } else { + div() + .flex_1() + .w_full() + .overflow_y_scrollbar() + .children(rows) + .into_any_element() + }) + } +} + +/// 内置项:随产品发布,不参与仓库扫描。 +fn builtin_items() -> Vec { + vec![ + ExtensionItem { + id: "git".to_string(), + name: "Git".to_string(), + version: "0.1.0".to_string(), + description: "Git source control integration".to_string(), + category: ExtensionCategory::Integration, + }, + ExtensionItem { + id: "maven".to_string(), + name: "Maven".to_string(), + version: "0.1.0".to_string(), + description: "Maven project navigation and lifecycle".to_string(), + category: ExtensionCategory::Integration, + }, + ExtensionItem { + id: "terminal".to_string(), + name: "Terminal".to_string(), + version: "0.1.0".to_string(), + description: "Integrated terminal".to_string(), + category: ExtensionCategory::Integration, + }, + ] +} + +/// 扫描仓库 `Plugins/` 目录:每个子目录优先读 `package.json`,缺失则读 +/// `plugin.json`(仓库现状),二者皆无则跳过(由父目录继续下钻)。 +/// 最大递归深度 4,跳过隐藏目录与构建产物目录。 +fn scan_plugins() -> Vec { + let Some(root) = plugins_root() else { + return Vec::new(); + }; + let mut out = Vec::new(); + walk_plugins_dir(&root, 0, &mut out); + out +} + +fn plugins_root() -> Option { + let mut candidates = Vec::new(); + if let Ok(cwd) = std::env::current_dir() { + candidates.push(cwd.join("Plugins")); + } + if let Ok(exe) = std::env::current_exe() { + let mut dir = exe.parent().map(Path::to_path_buf); + for _ in 0..6 { + let Some(current) = dir.clone() else { + break; + }; + candidates.push(current.join("Plugins")); + dir = current.parent().map(Path::to_path_buf); + } + } + candidates.into_iter().find(|path| path.is_dir()) +} + +const MAX_PLUGIN_SCAN_DEPTH: usize = 4; + +const PLUGIN_SCAN_SKIP_DIRS: [&str; 7] = [ + "target", + "node_modules", + ".git", + "dist", + "build", + "out", + "vendor", +]; + +fn walk_plugins_dir(dir: &Path, depth: usize, out: &mut Vec) { + if depth > MAX_PLUGIN_SCAN_DEPTH { + return; + } + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + let mut entries: Vec<_> = entries.flatten().collect(); + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + if name.starts_with('.') || PLUGIN_SCAN_SKIP_DIRS.contains(&name.as_str()) { + continue; + } + if let Some(item) = read_manifest(&path, &name) { + out.push(item); + } + walk_plugins_dir(&path, depth + 1, out); + } +} + +#[derive(Debug, serde::Deserialize)] +struct PackageJson { + name: Option, + version: Option, + description: Option, + keywords: Option>, +} + +#[derive(Debug, serde::Deserialize)] +struct PluginJson { + id: Option, + #[serde(rename = "displayName")] + display_name: Option, + version: Option, + description: Option, + #[serde(rename = "languageSupports")] + language_supports: Option>, +} + +/// 读单个插件目录的清单:`package.json` 优先,`plugin.json` 兜底, +/// 字段缺失时用目录名;`plugin.json` 含非空 `languageSupports` 即归语言类。 +fn read_manifest(dir: &Path, dir_name: &str) -> Option { + let package_path = dir.join("package.json"); + if package_path.is_file() { + let text = std::fs::read_to_string(&package_path).ok()?; + let manifest: PackageJson = serde_json::from_str(&text).ok()?; + let keywords = manifest.keywords.clone().unwrap_or_default(); + return Some(ExtensionItem { + id: manifest + .name + .clone() + .unwrap_or_else(|| dir_name.to_string()), + name: manifest.name.unwrap_or_else(|| dir_name.to_string()), + version: manifest.version.unwrap_or_default(), + description: manifest.description.unwrap_or_default(), + category: classify_keywords(&keywords), + }); + } + let plugin_path = dir.join("plugin.json"); + if plugin_path.is_file() { + let text = std::fs::read_to_string(&plugin_path).ok()?; + let manifest: PluginJson = serde_json::from_str(&text).ok()?; + let category = match &manifest.language_supports { + Some(supports) if !supports.is_empty() => ExtensionCategory::Language, + _ => classify_text(&format!( + "{} {}", + manifest.id.clone().unwrap_or_default(), + manifest.display_name.clone().unwrap_or_default() + )), + }; + return Some(ExtensionItem { + id: manifest.id.clone().unwrap_or_else(|| dir_name.to_string()), + name: manifest + .display_name + .or(manifest.id) + .unwrap_or_else(|| dir_name.to_string()), + version: manifest.version.unwrap_or_default(), + description: manifest.description.unwrap_or_default(), + category, + }); + } + None +} + +/// 按 `package.json` keywords 简单归类,无 keywords 归 integration。 +fn classify_keywords(keywords: &[String]) -> ExtensionCategory { + classify_text(&keywords.join(" ")) +} + +/// 关键词归类:theme → 主题,language/lang/lsp/syntax → 语言, +/// ai/llm/agent/chat/model → AI,其余 → integration。 +fn classify_text(text: &str) -> ExtensionCategory { + let lower = text.to_lowercase(); + let has_any = |words: &[&str]| words.iter().any(|word| lower.contains(word)); + if has_any(&["theme", "icon", "color-scheme"]) { + ExtensionCategory::Theme + } else if has_any(&["language", "lang", "lsp", "syntax", "grammar"]) { + ExtensionCategory::Language + } else if has_any(&["ai", "llm", "agent", "chat", "model"]) { + ExtensionCategory::Ai + } else { + ExtensionCategory::Integration + } +} + +/// 启用状态落盘路径:`~/.config/lithe/extensions.json` +///(`$XDG_CONFIG_HOME` 优先,与 `settings.rs::config_path` 同规则)。 +fn extensions_state_path() -> Option { + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?; + Some(base.join("lithe").join("extensions.json")) +} + +fn load_enabled() -> HashMap { + let Some(path) = extensions_state_path() else { + return HashMap::new(); + }; + let Ok(text) = std::fs::read_to_string(&path) else { + return HashMap::new(); + }; + serde_json::from_str::>(&text).unwrap_or_default() +} + +/// 写回启用状态:临时文件 + 原子替换(参考 `settings.rs::persist`)。 +fn save_enabled(enabled: &HashMap) { + let Some(path) = extensions_state_path() else { + return; + }; + if let Some(parent) = path.parent() { + if std::fs::create_dir_all(parent).is_err() { + return; + } + } + let Ok(text) = serde_json::to_string_pretty(enabled) else { + return; + }; + let tmp = path.with_extension("json.tmp"); + if std::fs::write(&tmp, text).is_ok() { + let _ = std::fs::rename(&tmp, &path); + } +} diff --git a/linux/src/workbench/mod.rs b/linux/src/workbench/mod.rs index f955ddac6..069f485e4 100644 --- a/linux/src/workbench/mod.rs +++ b/linux/src/workbench/mod.rs @@ -3,8 +3,10 @@ pub mod bottom_panel; pub mod branch_manager; pub mod command_palette; pub mod editor; +pub mod extensions_panel; pub mod go_to_line; pub mod maven; +pub mod notifications; pub mod project_dialog; pub mod quick_open; pub mod search_everywhere; @@ -29,10 +31,14 @@ pub use command_palette::{CommandPaletteEvent, CommandPaletteModal}; #[allow(unused_imports)] pub use editor::EditorView; #[allow(unused_imports)] +pub use extensions_panel::{ExtensionsEvent, ExtensionsView}; +#[allow(unused_imports)] pub use go_to_line::{GoToLineEvent, GoToLineModal}; #[allow(unused_imports)] pub use maven::{MavenEvent, MavenView}; #[allow(unused_imports)] +pub use notifications::{NotificationsEvent, NotificationsView}; +#[allow(unused_imports)] pub use project_dialog::{ProjectDialog, ProjectDialogEvent, ProjectDialogMode}; #[allow(unused_imports)] pub use quick_open::{QuickOpenEvent, QuickOpenModal}; diff --git a/linux/src/workbench/notifications.rs b/linux/src/workbench/notifications.rs new file mode 100644 index 000000000..bae2fe58e --- /dev/null +++ b/linux/src/workbench/notifications.rs @@ -0,0 +1,399 @@ +//! 右侧通知工具窗口:对齐 Tauri `notifications-tool-window.tsx` 的展示语义。 +//! +//! 可见性由宿主持有(对齐 `right-tool-window-actions.ts` 的 toggle 语义: +//! 同视图再点关闭、面板 X 关闭、隐藏时保活不卸载),本视图不保存可见标志, +//! 因此隐藏后重显不会丢失列表与搜索态。通知源为诊断快照( +//! [`NotificationsView::set_diagnostics`] 全量替换)与手动系统事件 +//! ([`NotificationsView::push`] 追加),未读数口径与 Tauri 一致 +//! (`success` 类型不计入未读)。 + +use gpui_kit::assets::IconName; +use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::input::{Input, InputState}; +use gpui_kit::component::scroll::ScrollableElement as _; +use gpui_kit::component::{h_flex, v_flex, Icon, Sizable as _}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::{ + div, px, AppContext as _, Context, Entity, EventEmitter, FontWeight, InteractiveElement as _, + IntoElement, ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, Window, +}; + +use crate::theme::ThemeColors; + +/// 通知面板派发的事件:`Close` 由头部 X 发出,宿主据此隐藏右侧栏(保活); +/// `OpenFile` 由诊断类行点击发出,载荷为工作区相对路径与从 1 起的行号。 +#[derive(Debug, Clone)] +pub enum NotificationsEvent { + Close, + OpenFile(String, u32), +} + +/// 通知类型,对齐 Tauri `NotificationEntry["type"]`;`Diagnostic` 为 Linux 侧 +/// 由诊断快照投递的定位类通知,点击时额外发出 `OpenFile`。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotificationKind { + Info, + Success, + // `Warning` 暂无生产者(诊断后端接入后由 `set_diagnostics` 按级别构造)。 + #[allow(dead_code)] + Warning, + Error, + Diagnostic, +} + +/// 单条通知:诊断类通过 `path` 携带定位信息,非诊断类为 `None`。 +#[derive(Debug, Clone)] +pub struct NotificationItem { + id: u64, + title: String, + body: String, + kind: NotificationKind, + read: bool, + path: Option, + line: u32, +} + +/// 右侧通知工具窗口:内部持有通知列表本地态。 +pub struct NotificationsView { + items: Vec, + next_id: u64, + search: Option>, +} + +impl EventEmitter for NotificationsView {} + +impl NotificationsView { + /// 构造并预置 2 条未读提示,保证面板首次打开非空。 + pub fn new(_cx: &mut Context) -> Self { + Self { + items: vec![ + NotificationItem { + id: 0, + title: "Welcome to Lithe".to_string(), + body: "Notifications from the workspace and language services appear here." + .to_string(), + kind: NotificationKind::Info, + read: false, + path: None, + line: 1, + }, + NotificationItem { + id: 1, + title: "Tip: diagnostics are clickable".to_string(), + body: "Click a diagnostic notification to jump to its file location." + .to_string(), + kind: NotificationKind::Info, + read: false, + path: None, + line: 1, + }, + ], + next_id: 2, + search: None, + } + } + + /// 宿主投递手动系统事件:追加一条未读 `Info` 通知。 + pub fn push( + &mut self, + title: impl Into, + body: impl Into, + cx: &mut Context, + ) { + self.items.push(NotificationItem { + id: self.next_id, + title: title.into(), + body: body.into(), + kind: NotificationKind::Info, + read: false, + path: None, + line: 1, + }); + self.next_id += 1; + cx.notify(); + } + + /// 全量替换诊断类通知(诊断快照语义):保留非诊断项,诊断项按新快照 + /// 重建为未读;`line` 为从 1 起的行号,与跨平台契约一致。 + /// + /// 当前尚无诊断生产者(底部 Diagnostics 面板仍为占位),保留给诊断后端 + /// 接入后调用。 + #[allow(dead_code)] + pub fn set_diagnostics( + &mut self, + diagnostics: Vec<(String, u32, String)>, + cx: &mut Context, + ) { + self.items + .retain(|item| item.kind != NotificationKind::Diagnostic); + for (path, line, message) in diagnostics { + let body = format!("{}:{}", path, line); + self.items.push(NotificationItem { + id: self.next_id, + title: message, + body, + kind: NotificationKind::Diagnostic, + read: false, + path: Some(path), + line, + }); + self.next_id += 1; + } + cx.notify(); + } + + /// 未读数:`Success` 类型不计入,与 Tauri 角标口径一致。 + pub fn unread_count(&self) -> usize { + self.items + .iter() + .filter(|item| !item.read && item.kind != NotificationKind::Success) + .count() + } + + /// 全部标为已读。 + pub fn mark_all_read(&mut self, cx: &mut Context) { + for item in &mut self.items { + item.read = true; + } + cx.notify(); + } + + fn toggle_read(&mut self, id: u64, cx: &mut Context) { + if let Some(item) = self.items.iter_mut().find(|item| item.id == id) { + item.read = !item.read; + } + cx.notify(); + } + + /// 懒创建搜索输入框(首次渲染创建,后续渲染复用用户编辑态)。 + fn ensure_search(&mut self, window: &mut Window, cx: &mut Context) -> Entity { + if let Some(entity) = self.search.clone() { + return entity; + } + let entity = cx.new(|cx| InputState::new(window, cx)); + self.search = Some(entity.clone()); + entity + } + + fn query(&self, cx: &Context) -> String { + self.search + .as_ref() + .map(|entity| entity.read(cx).value().to_string()) + .unwrap_or_default() + .trim() + .to_lowercase() + } +} + +fn kind_icon(kind: NotificationKind) -> IconName { + match kind { + NotificationKind::Info => IconName::Info, + NotificationKind::Success => IconName::CircleCheck, + NotificationKind::Warning | NotificationKind::Error | NotificationKind::Diagnostic => { + IconName::TriangleAlert + } + } +} + +impl Render for NotificationsView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // 快照后构建行,避免在 `.children` 闭包里同时借用 self 与 cx。 + let search_entity = self.ensure_search(window, cx); + let query = self.query(cx); + let unread = self.unread_count(); + let items: Vec = self + .items + .iter() + .filter(|item| { + query.is_empty() + || item.title.to_lowercase().contains(&query) + || item.body.to_lowercase().contains(&query) + }) + .cloned() + .collect(); + let is_empty = self.items.is_empty(); + + let mut rows = Vec::new(); + for item in &items { + let id = item.id; + let path = item.path.clone(); + let line = item.line; + let is_diagnostic = item.kind == NotificationKind::Diagnostic; + rows.push( + v_flex() + .id(format!("notification-{id}")) + .w_full() + .cursor_pointer() + .rounded_sm() + .px_2() + .py_1() + .gap_1() + .hover(|row| row.bg(ThemeColors::bg_tab_hover())) + .when(!item.read, |row| row.bg(ThemeColors::subtle_selection())) + .child( + h_flex() + .w_full() + .items_center() + .gap_1p5() + .child(Icon::new(kind_icon(item.kind)).size(px(14.0)).text_color( + if item.kind == NotificationKind::Error || is_diagnostic { + ThemeColors::destructive() + } else { + ThemeColors::text_muted() + }, + )) + .child( + div() + .flex_1() + .truncate() + .text_xs() + .text_color(ThemeColors::text_primary()) + .when(!item.read, |title| title.font_weight(FontWeight::BOLD)) + .child(item.title.clone()), + ) + .when(!item.read, |row| { + row.child( + div() + .w(px(6.0)) + .h(px(6.0)) + .rounded_full() + .bg(ThemeColors::accent_blue()) + .flex_shrink_0(), + ) + }), + ) + .child( + div() + .w_full() + .pl(px(20.0)) + .truncate() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(item.body.clone()), + ) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.toggle_read(id, cx); + if is_diagnostic { + if let Some(path) = path.clone() { + cx.emit(NotificationsEvent::OpenFile(path, line)); + } + } + })) + .into_any_element(), + ); + } + + v_flex() + .size_full() + .bg(ThemeColors::bg_sidebar()) + .border_l_1() + .border_color(ThemeColors::border()) + .child( + h_flex() + .h(px(32.0)) + .w_full() + .bg(ThemeColors::bg_sidebar()) + .border_b_1() + .border_color(ThemeColors::border()) + .items_center() + .justify_between() + .px_3() + .child( + h_flex() + .items_center() + .gap_1p5() + .child( + div() + .text_xs() + .font_weight(FontWeight::BOLD) + .text_color(ThemeColors::text_muted()) + .child(crate::i18n::menu_text(cx, "notifications.title")), + ) + .when(unread > 0, |title| { + title.child( + div() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(format!("({unread})")), + ) + }), + ) + .child( + h_flex() + .items_center() + .gap_1() + .when(unread > 0, |actions| { + actions.child( + Button::new("notifications-mark-read") + .small() + .ghost() + .label("Mark all read") + .on_click(cx.listener(|this, _event, _window, cx| { + this.mark_all_read(cx); + })), + ) + }) + .child( + Button::new("notifications-close") + .small() + .ghost() + .icon(IconName::Close) + .tooltip(crate::i18n::menu_text(cx, "ui.close")) + .on_click(cx.listener(|_this, _event, _window, cx| { + cx.emit(NotificationsEvent::Close); + })), + ), + ), + ) + .child( + h_flex() + .w_full() + .items_center() + .gap_1p5() + .px_2() + .py_2() + .child( + Icon::new(IconName::Search) + .size(px(13.0)) + .text_color(ThemeColors::text_muted()), + ) + .child( + div() + .flex_1() + .child(Input::new(&search_entity).cleanable(true)), + ), + ) + .child(if is_empty { + div() + .flex_1() + .w_full() + .flex() + .items_center() + .justify_center() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child("No notifications") + .into_any_element() + } else if items.is_empty() { + div() + .flex_1() + .w_full() + .flex() + .items_center() + .justify_center() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child("No matching notifications") + .into_any_element() + } else { + div() + .flex_1() + .w_full() + .overflow_y_scrollbar() + .px_2() + .py_1() + .children(rows) + .into_any_element() + }) + } +} diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index 0eec5d903..d08d6ac97 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -21,8 +21,10 @@ use crate::workbench::bottom_panel::{BottomPanelView, BottomTab}; use crate::workbench::branch_manager::{BranchManagerEvent, BranchManagerView}; use crate::workbench::command_palette::{CommandPaletteEvent, CommandPaletteModal}; use crate::workbench::editor::EditorView; +use crate::workbench::extensions_panel::{ExtensionsEvent, ExtensionsView}; use crate::workbench::go_to_line::{GoToLineEvent, GoToLineModal}; use crate::workbench::maven::{MavenEvent, MavenView}; +use crate::workbench::notifications::{NotificationsEvent, NotificationsView}; use crate::workbench::project_dialog::{ProjectDialog, ProjectDialogEvent, ProjectDialogMode}; use crate::workbench::quick_open::{QuickOpenEvent, QuickOpenModal}; use crate::workbench::search_everywhere::{SearchEverywhereEvent, SearchEverywhereModal}; @@ -32,6 +34,15 @@ use crate::workbench::status_bar::{StatusBarEvent, StatusBarView}; use crate::workbench::toolbar::{ToolbarEvent, ToolbarView}; use crate::workbench::welcome_screen::{WelcomeEvent, WelcomeScreenView}; +/// 右侧工具窗口当前视图,对齐 Tauri `activeRightSidebarView` +///(`notifications` / `maven` / 扩展)。`None` 即隐藏,不持久化,重启丢失。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RightToolView { + Notifications, + Maven, + Extensions, +} + /// 主工作台视图。 pub struct WorkbenchView { /// 项目工作区根路径 @@ -66,8 +77,8 @@ pub struct WorkbenchView { pub show_project_dialog: bool, /// 分支管理器弹窗是否可见 pub show_branch_manager: bool, - /// 右侧 Maven 导航工具窗口是否可见 - pub show_maven: bool, + /// 右侧工具窗口当前视图(`None` 隐藏,对齐 Tauri 右侧不持久化语义) + pub right_tool: Option, /// 顶部标题栏/工具栏 pub toolbar: Entity, @@ -77,6 +88,12 @@ pub struct WorkbenchView { pub plugin_rail: Entity, /// 右侧 Maven 导航工具窗口实体 pub maven: Entity, + /// 右侧通知工具窗口实体(隐藏时保活) + pub notifications: Entity, + /// 右侧扩展面板实体(隐藏时保活) + pub extensions: Entity, + /// 通知诊断行点击后的待跳转行号(1 起,文件加载完成后在 render 应用) + pending_goto_line: Option, /// 侧边栏面板 (Files / Git / Search) pub sidebar: Entity, /// 主代码编辑器区 @@ -136,11 +153,12 @@ impl WorkbenchView { let branch_manager = cx.new(|cx| BranchManagerView::new(branch_manager_root, cx)); let welcome_screen = cx.new(|cx| WelcomeScreenView::new(cx)); let maven = cx.new(|cx| MavenView::new(root.clone(), cx)); + let notifications = cx.new(|cx| NotificationsView::new(cx)); + let extensions = cx.new(|cx| ExtensionsView::new(cx)); let focus_handle = cx.focus_handle(); // 1. 订阅侧边栏事件(打开文件、新建文件、提交 Git) let status_bar_clone = status_bar.clone(); - let bottom_panel_sidebar = bottom_panel.clone(); let sub_sidebar = cx.subscribe( &sidebar, @@ -172,13 +190,8 @@ impl WorkbenchView { }); } SidebarEvent::Commit(msg) => { - let log_msg = format!("[Git Commit] {}", msg); - let _ = bottom_panel_sidebar.update(cx, |bp, cx| { - bp.append_log(log_msg.clone(), cx); - let _ = bp.terminal.update(cx, |term, cx| { - term.send_command(&format!("git commit -m \"{}\"", msg), cx); - }); - }); + let cmd = format!("git commit -m \"{}\"", msg); + this.send_terminal_command(&cmd, cx); let _ = sidebar.update(cx, |sb, cx| { sb.refresh_git(cx); }); @@ -279,15 +292,38 @@ impl WorkbenchView { &plugin_rail, |this, _rail, event: &PluginRailEvent, cx| match event { PluginRailEvent::OpenExtensions => { - this.append_log("[Extensions] Opening extensions marketplace...", cx); - cx.notify(); + this.toggle_right_tool(RightToolView::Extensions, cx); } PluginRailEvent::ToggleNotifications => { - this.append_log("[Notifications] Toggling notifications tool window...", cx); - cx.notify(); + this.toggle_right_tool(RightToolView::Notifications, cx); } PluginRailEvent::ToggleMaven => { - this.show_maven = !this.show_maven; + this.toggle_right_tool(RightToolView::Maven, cx); + } + }, + ); + + // 4b-1. 订阅通知面板事件(关闭、诊断行跳转文件) + let sub_notifications = cx.subscribe( + ¬ifications, + |this, _view, event: &NotificationsEvent, cx| match event { + NotificationsEvent::Close => { + this.right_tool = None; + cx.notify(); + } + NotificationsEvent::OpenFile(path, line) => { + this.pending_goto_line = Some(*line); + this.open_file(path, cx); + } + }, + ); + + // 4b-2. 订阅扩展面板事件(关闭) + let sub_extensions = cx.subscribe( + &extensions, + |this, _view, event: &ExtensionsEvent, cx| match event { + ExtensionsEvent::Close => { + this.right_tool = None; cx.notify(); } }, @@ -296,19 +332,11 @@ impl WorkbenchView { // 4b. 订阅 Maven 视图事件(执行目标、关闭工具窗口) let sub_maven = cx.subscribe(&maven, |this, _maven, event: &MavenEvent, cx| match event { MavenEvent::RunGoal { pom_path, phase } => { - let _ = this.bottom_panel.update(cx, |bp, cx| { - bp.set_tab(BottomTab::Terminal, cx); - let _ = bp.terminal.update(cx, |term, cx| { - term.send_command(&format!("mvn -f \"{pom_path}\" {phase}"), cx); - }); - }); - let _ = this.activity_rail.update(cx, |r, cx| { - r.set_has_maven_run(true, cx); - }); - cx.notify(); + let cmd = format!("mvn -f \"{pom_path}\" {phase}"); + this.send_terminal_command(&cmd, cx); } MavenEvent::Close => { - this.show_maven = false; + this.right_tool = None; cx.notify(); } }); @@ -360,12 +388,7 @@ impl WorkbenchView { this.open_quick_open(cx); } ToolbarEvent::Run => { - this.append_log("[Run] Executing default run configuration...", cx); - let _ = this.bottom_panel.update(cx, |bp, cx| { - let _ = bp.terminal.update(cx, |term, cx| { - term.send_command("echo '[Lithe Run]' && cargo check", cx); - }); - }); + this.send_terminal_command("echo '[Lithe Run]' && cargo check", cx); } ToolbarEvent::Debug => { this.append_log("[Debug] Launching DAP debug session...", cx); @@ -557,12 +580,7 @@ impl WorkbenchView { this.show_project_dialog = false; this.open_project_path(path.clone(), cx); if let Some(cmd) = starter { - let _ = this.bottom_panel.update(cx, |bp, cx| { - bp.set_tab(BottomTab::Terminal, cx); - let _ = bp.terminal.update(cx, |term, cx| { - term.send_command(cmd, cx); - }); - }); + this.send_terminal_command(cmd, cx); } cx.notify(); } @@ -680,11 +698,14 @@ impl WorkbenchView { show_settings_dialog: false, show_project_dialog: false, show_branch_manager: false, - show_maven: false, + right_tool: None, + pending_goto_line: None, toolbar, activity_rail, plugin_rail, maven, + notifications, + extensions, sidebar, editor, bottom_panel, @@ -704,6 +725,8 @@ impl WorkbenchView { obs_sidebar, sub_rail, sub_plugin_rail, + sub_notifications, + sub_extensions, sub_maven, sub_toolbar, sub_search, @@ -814,6 +837,35 @@ impl WorkbenchView { } /// 切换到指定项目根路径:同步侧边栏、Maven 与欢迎页状态,并记录最近项目。 + /// 右侧工具窗口 toggle(对齐 `resolveRightToolWindowUpdate`): + /// 同视图再点关闭,否则切换视图并展开。 + fn toggle_right_tool(&mut self, view: RightToolView, cx: &mut Context) { + if self.right_tool == Some(view) { + self.right_tool = None; + } else { + self.right_tool = Some(view); + } + cx.notify(); + } + + /// 经底部终端发送命令:执行 + 计入运行历史 + 点亮左侧 maven 项 + ///(对齐 Tauri `toggleTerminalPane` 系 + `hasMavenRun` 挂载语义)。 + fn send_terminal_command(&mut self, cmd: &str, cx: &mut Context) { + let cmd = cmd.to_string(); + let _ = self.bottom_panel.update(cx, |bp, cx| { + bp.set_tab(BottomTab::Terminal, cx); + let _ = bp.terminal.update(cx, |term, cx| { + term.send_command(&cmd, cx); + }); + bp.record_run(&cmd, cx); + }); + let has_run = self.bottom_panel.read(cx).has_run_history(); + let _ = self.activity_rail.update(cx, |r, cx| { + r.set_has_maven_run(has_run, cx); + }); + cx.notify(); + } + fn open_project_path(&mut self, path: String, cx: &mut Context) { if self.show_project_dialog { self.show_project_dialog = false; @@ -834,6 +886,13 @@ impl WorkbenchView { let _ = self.maven.update(cx, |m, cx| { m.set_root(path.clone(), cx); }); + let _ = self.bottom_panel.update(cx, |bp, _cx| { + bp.working_dir = path.clone(); + }); + // 右侧通知中心投递项目打开事件(对齐 Tauri 系统事件通知)。 + let _ = self.notifications.update(cx, |n, cx| { + n.push("已打开项目", path.clone(), cx); + }); cx.notify(); } @@ -1084,13 +1143,8 @@ impl WorkbenchView { sb.refresh_git(cx); }); } - "workbench.run" | "run.run" => { - self.append_log("[Run] Executing default run configuration...", cx); - let _ = self.bottom_panel.update(cx, |bp, cx| { - let _ = bp.terminal.update(cx, |term, cx| { - term.send_command("cargo check", cx); - }); - }); + "workbench.run" | "run.run" | "run.start" => { + self.send_terminal_command("cargo check", cx); } "workbench.debug" | "run.debug" => { self.append_log("[Debug] Launching DAP debug session...", cx); @@ -1406,12 +1460,13 @@ fn sidebar_tab_for(view_id: &str) -> Option { } } -/// 活动栏底部项 id → 底部面板标签。Linux 目前只有 Terminal / Diagnostics -/// 两种真实面板:run/maven/gitLog 暂落到 Terminal,待各自后端面板接入。 +/// 活动栏底部项 id → 底部面板标签(对齐 Tauri `BottomPaneTab`)。 fn bottom_tab_for(pane_id: &str) -> Option { match pane_id { - "terminal" | "run" | "maven" | "gitLog" => Some(BottomTab::Terminal), + "terminal" => Some(BottomTab::Terminal), + "run" | "maven" => Some(BottomTab::Run), "diagnostics" => Some(BottomTab::Diagnostics), + "gitLog" => Some(BottomTab::GitLog), _ => None, } } @@ -1432,9 +1487,23 @@ fn this_sync_files(this: &WorkbenchView, cx: &mut Context) { } impl Render for WorkbenchView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let show_status_bar = settings::get(cx).show_status_bar; + // 通知诊断行点击的待跳转:文件加载完成后跳到指定行(1 起)。 + if let Some(line) = self.pending_goto_line.take() { + let _ = self.editor.update(cx, |ed, cx| { + ed.go_to_line(line, window, cx); + }); + } + // 右侧铃铛角标与通知未读数同步(变化时才 notify,避免渲染循环)。 + { + let unread = self.notifications.read(cx).unread_count(); + let _ = self.plugin_rail.update(cx, |rail, cx| { + rail.set_unread(unread, cx); + }); + } + div() .track_focus(&self.focus_handle) .relative() @@ -1589,6 +1658,7 @@ impl WorkbenchView { fn render_workbench(&self, show_status_bar: bool, cx: &mut Context) -> impl IntoElement { let bottom_visible = self.bottom_panel.read(cx).is_visible(); let bottom_splitter = self.render_bottom_splitter(cx); + let right_width = settings::get(cx).right_tool_window_width; v_flex() .size_full() .bg(ThemeColors::background()) @@ -1619,13 +1689,22 @@ impl WorkbenchView { .bg(ThemeColors::background()) .child(self.editor.clone()), ) - .when(self.show_maven, |layout| { + .when_some(self.right_tool, |layout, tool| { + let panel = match tool { + RightToolView::Notifications => { + self.notifications.clone().into_any_element() + } + RightToolView::Maven => self.maven.clone().into_any_element(), + RightToolView::Extensions => self.extensions.clone().into_any_element(), + }; layout.child( div() - .w(px(280.0)) + .w(px(right_width)) .h_full() .flex_shrink_0() - .child(self.maven.clone()), + .border_l_1() + .border_color(ThemeColors::border()) + .child(panel), ) }) .child(self.plugin_rail.clone()), From 4d63107a490e770fa05e6c5c7a154921193daa6a Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Thu, 24 Sep 2026 23:27:10 +0800 Subject: [PATCH 026/109] =?UTF-8?q?feat(linux):=20=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E6=A0=8F=E6=90=9C=E7=B4=A2=E5=AF=B9=E9=BD=90=20Tauri=20QuickOp?= =?UTF-8?q?en=20=E5=88=86=E7=BB=84=E4=B8=8E=E8=AE=A1=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 工具栏搜索按钮 tooltip 改用 workbench.search,对齐 title-bar.tsx quickOpenAction - QuickOpen 增加已打开/最近/其他三段分组与命中计数徽标,对齐 useFileSearch + FileCountBadge - 新增 quickOpen.filesCount/fileCountOne 文案 --- linux/src/i18n.rs | 2 + linux/src/workbench/quick_open.rs | 91 ++++++++++++++++++++++++++++--- linux/src/workbench/toolbar.rs | 2 +- linux/src/workbench/view.rs | 32 +++++++++++ 4 files changed, 117 insertions(+), 10 deletions(-) diff --git a/linux/src/i18n.rs b/linux/src/i18n.rs index 1f1e12a5f..15572e002 100644 --- a/linux/src/i18n.rs +++ b/linux/src/i18n.rs @@ -210,6 +210,8 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { "quickOpen.noMatch" => ("未找到匹配的文件", "No matching files found"), "quickOpen.noSymbols" => ("未找到符号", "No symbols found"), "quickOpen.searching" => ("正在搜索...", "Searching..."), + "quickOpen.filesCount" => ("{count} 个文件", "{count} files"), + "quickOpen.fileCountOne" => ("1 个文件", "1 file"), "commandPalette.placeholder" => ("输入命令...", "Type a command..."), "search.noResults" => ("未找到结果", "No results found"), // ---- Welcome 屏(与 Tauri locale.ts welcome.* 对齐) ---- diff --git a/linux/src/workbench/quick_open.rs b/linux/src/workbench/quick_open.rs index 08765d745..a9d7138fd 100644 --- a/linux/src/workbench/quick_open.rs +++ b/linux/src/workbench/quick_open.rs @@ -68,10 +68,13 @@ pub struct QuickOpenModal { pub query: String, pub mode: QuickOpenMode, pub files: Vec, - /// 命中筛选的文件路径,保持 `files` 的原始顺序。 + /// 命中筛选的文件路径,保持已打开、最近、其他三分组的扁平顺序 + /// (对齐 Tauri `openBufferFiles`、`recentFilesInResults`、`otherFiles`)。 pub filtered: Vec, pub selected_index: usize, pub focus_handle: FocusHandle, + /// 已打开的编辑器标签页路径,用于优先分组展示(对齐 Tauri `openBufferFiles`)。 + pub open_files: Vec, /// 最近打开的文件,用于优先分组展示。 pub recent: Vec, } @@ -87,6 +90,7 @@ impl QuickOpenModal { filtered: Vec::new(), selected_index: 0, focus_handle: cx.focus_handle(), + open_files: Vec::new(), recent: Vec::new(), } } @@ -98,6 +102,22 @@ impl QuickOpenModal { cx.notify(); } + /// 同步已打开的编辑器标签页路径(打开分组置顶,对齐 Tauri `openBufferFiles`)。 + pub fn set_open_files(&mut self, open: Vec, cx: &mut Context) { + self.open_files = open; + self.recompute_filtered(); + cx.notify(); + } + + /// 记录最近打开的文件(去重、前置、上限 20,对齐 Tauri `recentFilesInResults`)。 + pub fn push_recent(&mut self, path: String, cx: &mut Context) { + self.recent.retain(|r| *r != path); + self.recent.insert(0, path); + self.recent.truncate(20); + self.recompute_filtered(); + cx.notify(); + } + /// 复位到初始状态:清空查询与选中,回到 File 模式。 pub fn reset(&mut self, cx: &mut Context) { self.query.clear(); @@ -150,15 +170,34 @@ impl QuickOpenModal { } } - /// 按有效查询串重算命中列表。 + /// 按有效查询串重算命中列表:已打开优先、最近其次、其余最后 + /// (对齐 Tauri `useFileSearch` 的三段分组与全局选中下标)。 fn recompute_filtered(&mut self) { let q = self.effective_query(); - self.filtered = self - .files - .iter() - .filter(|path| q.is_empty() || path.to_lowercase().contains(&q)) - .cloned() - .collect(); + let matches = |path: &str| q.is_empty() || path.to_lowercase().contains(&q); + let mut seen = std::collections::HashSet::new(); + let mut ordered = Vec::new(); + // 已打开分组:保持传入的标签页顺序。 + for path in &self.open_files { + if matches(path) && seen.insert(path.clone()) { + ordered.push(path.clone()); + } + } + // 最近分组:保持最近优先顺序,排除已计入的已打开项。 + for path in &self.recent { + if self.files.iter().any(|f| f == path) || self.open_files.iter().any(|f| f == path) { + if matches(path) && seen.insert(path.clone()) { + ordered.push(path.clone()); + } + } + } + // 其余文件:保持 `files` 的原始顺序。 + for path in &self.files { + if matches(path) && seen.insert(path.clone()) { + ordered.push(path.clone()); + } + } + self.filtered = ordered; if self.selected_index >= self.filtered.len() { self.selected_index = self.filtered.len().saturating_sub(1); } @@ -326,10 +365,16 @@ impl Render for QuickOpenModal { div().w(px(2.0)).h(px(16.0)).bg(ThemeColors::primary()), ), ) + .child(count_badge( + self.filtered.len(), + self.files.len(), + !self.query.is_empty(), + cx, + )) .child(shortcut_badge("Esc")), ) .child( - // 2. 结果列表:按 recent / other 分组,路径分段着色 + // 2. 结果列表:已打开优先、最近其次、其余最后,路径分段着色 div() .flex_1() .w_full() @@ -431,3 +476,31 @@ fn shortcut_badge(label: &str) -> AnyElement { .child(label.to_string()) .into_any_element() } + +/// 文件计数徽标:有查询时 `命中 / 总数`,无查询时 `N 个文件` +/// (对齐 Tauri `FileCountBadge`)。 +fn count_badge(matched: usize, total: usize, has_query: bool, cx: &gpui_kit::App) -> AnyElement { + if total == 0 { + return div().into_any_element(); + } + let text = if has_query { + format!("{matched} / {total}") + } else if total == 1 { + crate::i18n::menu_text(cx, "quickOpen.fileCountOne").to_string() + } else { + crate::i18n::menu_text(cx, "quickOpen.filesCount") + .to_string() + .replace("{count}", &total.to_string()) + }; + div() + .px_1p5() + .py(px(1.0)) + .rounded_sm() + .bg(ThemeColors::accent()) + .border_1() + .border_color(ThemeColors::border()) + .text_xs() + .text_color(ThemeColors::muted_foreground()) + .child(text) + .into_any_element() +} diff --git a/linux/src/workbench/toolbar.rs b/linux/src/workbench/toolbar.rs index ef87bafd2..5a8e0d756 100644 --- a/linux/src/workbench/toolbar.rs +++ b/linux/src/workbench/toolbar.rs @@ -823,7 +823,7 @@ impl Render for ToolbarView { .small() .ghost() .icon(IconName::Search) - .tooltip(crate::i18n::menu_text(cx, "menu.quickOpen")) + .tooltip(crate::i18n::menu_text(cx, "workbench.search")) .on_click(cx.listener(|_this, _event, _window, cx| { cx.emit(ToolbarEvent::QuickOpen); })), diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index d08d6ac97..9fc7731fe 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -231,6 +231,19 @@ impl WorkbenchView { qo.set_files(file_list, cx); }); } + // 已打开标签页同步给快速打开置顶分组(对齐 Tauri `openBufferFiles`)。 + let open_files: Vec = this + .editor + .read(cx) + .tabs + .iter() + .map(|tab| tab.path.clone()) + .collect(); + if !open_files.is_empty() { + let _ = quick_open_sync.update(cx, |qo, cx| { + qo.set_open_files(open_files, cx); + }); + } // 同步 Maven 可用性:有 pom 项目时右侧插件栏才显示 Maven 入口。 let has_maven = this.maven.read(cx).has_projects(); @@ -499,6 +512,7 @@ impl WorkbenchView { // 7. 订阅快速打开事件 let status_bar_quick = status_bar.clone(); + let quick_open_recent = quick_open.clone(); let sub_quick_open = cx.subscribe( &quick_open, move |this, _qo, event: &QuickOpenEvent, cx| match event { @@ -513,6 +527,11 @@ impl WorkbenchView { cx, ); }); + // 记录最近打开(对齐 Tauri `addOrUpdateRecentFile`)。 + let recent_path = path.clone(); + let _ = quick_open_recent.update(cx, |qo, cx| { + qo.push_recent(recent_path, cx); + }); this.show_quick_open = false; cx.notify(); } @@ -1476,6 +1495,14 @@ fn this_sync_files(this: &WorkbenchView, cx: &mut Context) { if let Some(root_node) = &this.sidebar.read(cx).root_node { WorkbenchView::collect_all_file_paths(root_node, &mut file_list); } + // 已打开标签页路径同步给快速打开的置顶分组(对齐 Tauri `openBufferFiles`)。 + let open_files: Vec = this + .editor + .read(cx) + .tabs + .iter() + .map(|tab| tab.path.clone()) + .collect(); if !file_list.is_empty() { let _ = this.search_everywhere.update(cx, |search, cx| { search.set_files(file_list.clone(), cx); @@ -1484,6 +1511,11 @@ fn this_sync_files(this: &WorkbenchView, cx: &mut Context) { qo.set_files(file_list, cx); }); } + if !open_files.is_empty() { + let _ = this.quick_open.update(cx, |qo, cx| { + qo.set_open_files(open_files, cx); + }); + } } impl Render for WorkbenchView { From ff24bf3e0cca07041d09aa1b0bf140ceb450f1c9 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Thu, 24 Sep 2026 23:47:57 +0800 Subject: [PATCH 027/109] =?UTF-8?q?feat(linux):=20=E5=A4=8D=E7=94=A8=20Win?= =?UTF-8?q?dows=20lithe=20=E5=9B=BE=E6=A0=87=E9=9B=86=E7=9A=84=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=A4=B9=E4=B8=8E=E6=96=87=E4=BB=B6=E5=BD=A9=E8=89=B2?= =?UTF-8?q?=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 linux/assets/file-icons(lithe-icons SVG + map.json) - 新增 linux/build.rs:编译期生成查找表与 SVG 内嵌字节 - 新增 workbench/file_icon:对齐 Tauri 文件名/多后缀扩展名/目录名查找与深浅主题 - 侧边栏、快速打开、编辑器标签页接入,缺失回退 Lucide 单色图标 --- linux/assets/file-icons/icons/files/adobe.svg | 7 + .../assets/file-icons/icons/files/agents.svg | 7 + .../assets/file-icons/icons/files/android.svg | 7 + .../assets/file-icons/icons/files/angular.svg | 7 + .../assets/file-icons/icons/files/ansible.svg | 7 + linux/assets/file-icons/icons/files/apple.svg | 7 + .../assets/file-icons/icons/files/archive.svg | 7 + .../assets/file-icons/icons/files/arduino.svg | 7 + linux/assets/file-icons/icons/files/astro.svg | 7 + linux/assets/file-icons/icons/files/audio.svg | 7 + .../assets/file-icons/icons/files/authors.svg | 7 + linux/assets/file-icons/icons/files/babel.svg | 7 + linux/assets/file-icons/icons/files/biome.svg | 7 + .../file-icons/icons/files/bitbucket.svg | 7 + .../assets/file-icons/icons/files/blender.svg | 7 + linux/assets/file-icons/icons/files/bun.svg | 7 + linux/assets/file-icons/icons/files/c.svg | 7 + .../file-icons/icons/files/certificate.svg | 7 + .../file-icons/icons/files/changelog.svg | 7 + .../assets/file-icons/icons/files/claude.svg | 7 + linux/assets/file-icons/icons/files/cline.svg | 7 + .../assets/file-icons/icons/files/clojure.svg | 7 + .../file-icons/icons/files/cloudflare.svg | 7 + linux/assets/file-icons/icons/files/cmake.svg | 7 + linux/assets/file-icons/icons/files/codex.svg | 7 + .../file-icons/icons/files/commitlint.svg | 7 + .../assets/file-icons/icons/files/config.svg | 7 + .../assets/file-icons/icons/files/copilot.svg | 7 + linux/assets/file-icons/icons/files/cpp.svg | 7 + .../assets/file-icons/icons/files/csharp.svg | 7 + .../assets/file-icons/icons/files/cspell.svg | 7 + linux/assets/file-icons/icons/files/css.svg | 7 + linux/assets/file-icons/icons/files/csv.svg | 7 + .../assets/file-icons/icons/files/cursor.svg | 7 + .../assets/file-icons/icons/files/cypress.svg | 7 + linux/assets/file-icons/icons/files/dart.svg | 7 + .../file-icons/icons/files/database.svg | 7 + linux/assets/file-icons/icons/files/deno.svg | 7 + .../file-icons/icons/files/dependabot.svg | 7 + .../file-icons/icons/files/devcontainer.svg | 7 + linux/assets/file-icons/icons/files/diff.svg | 7 + .../assets/file-icons/icons/files/django.svg | 7 + .../file-icons/icons/files/docker-compose.svg | 7 + .../assets/file-icons/icons/files/docker.svg | 7 + .../file-icons/icons/files/document.svg | 7 + .../file-icons/icons/files/docusaurus.svg | 7 + .../assets/file-icons/icons/files/drawio.svg | 7 + .../assets/file-icons/icons/files/drizzle.svg | 7 + .../file-icons/icons/files/editorconfig.svg | 7 + .../file-icons/icons/files/electron.svg | 7 + .../assets/file-icons/icons/files/elixir.svg | 7 + linux/assets/file-icons/icons/files/env.svg | 7 + .../assets/file-icons/icons/files/erlang.svg | 7 + linux/assets/file-icons/icons/files/error.svg | 7 + .../assets/file-icons/icons/files/eslint.svg | 7 + .../file-icons/icons/files/excalidraw.svg | 7 + .../assets/file-icons/icons/files/fastapi.svg | 7 + linux/assets/file-icons/icons/files/figma.svg | 7 + linux/assets/file-icons/icons/files/file.svg | 7 + .../file-icons/icons/files/firebase.svg | 7 + linux/assets/file-icons/icons/files/flask.svg | 7 + linux/assets/file-icons/icons/files/font.svg | 7 + .../assets/file-icons/icons/files/gatsby.svg | 7 + .../assets/file-icons/icons/files/gemini.svg | 7 + linux/assets/file-icons/icons/files/git.svg | 7 + .../file-icons/icons/files/github-actions.svg | 7 + .../assets/file-icons/icons/files/github.svg | 7 + .../assets/file-icons/icons/files/gitlab.svg | 7 + linux/assets/file-icons/icons/files/go.svg | 7 + .../assets/file-icons/icons/files/gradle.svg | 7 + .../file-icons/icons/files/graphql-schema.svg | 7 + .../assets/file-icons/icons/files/graphql.svg | 7 + .../assets/file-icons/icons/files/haskell.svg | 7 + linux/assets/file-icons/icons/files/helm.svg | 7 + linux/assets/file-icons/icons/files/html.svg | 7 + linux/assets/file-icons/icons/files/http.svg | 7 + linux/assets/file-icons/icons/files/hurl.svg | 7 + linux/assets/file-icons/icons/files/image.svg | 7 + .../file-icons/icons/files/index-js.svg | 7 + .../file-icons/icons/files/index-ts.svg | 7 + linux/assets/file-icons/icons/files/java.svg | 7 + .../file-icons/icons/files/javascript.svg | 7 + .../assets/file-icons/icons/files/jenkins.svg | 7 + linux/assets/file-icons/icons/files/jest.svg | 7 + .../file-icons/icons/files/jsconfig.svg | 7 + linux/assets/file-icons/icons/files/json.svg | 7 + linux/assets/file-icons/icons/files/julia.svg | 7 + linux/assets/file-icons/icons/files/key.svg | 7 + .../assets/file-icons/icons/files/kotlin.svg | 7 + .../file-icons/icons/files/kubernetes.svg | 7 + .../assets/file-icons/icons/files/laravel.svg | 7 + .../assets/file-icons/icons/files/layout.svg | 7 + .../assets/file-icons/icons/files/license.svg | 7 + .../file-icons/icons/files/lintstaged.svg | 7 + linux/assets/file-icons/icons/files/linux.svg | 7 + linux/assets/file-icons/icons/files/lit.svg | 7 + linux/assets/file-icons/icons/files/lithe.svg | 7 + .../assets/file-icons/icons/files/loading.svg | 7 + linux/assets/file-icons/icons/files/lock.svg | 7 + linux/assets/file-icons/icons/files/log.svg | 7 + linux/assets/file-icons/icons/files/lua.svg | 7 + .../file-icons/icons/files/makefile.svg | 7 + .../file-icons/icons/files/markdown.svg | 7 + .../file-icons/icons/files/markdownlint.svg | 7 + linux/assets/file-icons/icons/files/maven.svg | 7 + linux/assets/file-icons/icons/files/mcp.svg | 7 + .../assets/file-icons/icons/files/mermaid.svg | 7 + linux/assets/file-icons/icons/files/mongo.svg | 7 + .../assets/file-icons/icons/files/netlify.svg | 7 + linux/assets/file-icons/icons/files/next.svg | 7 + linux/assets/file-icons/icons/files/nginx.svg | 7 + linux/assets/file-icons/icons/files/nim.svg | 7 + linux/assets/file-icons/icons/files/nix.svg | 7 + linux/assets/file-icons/icons/files/node.svg | 7 + .../file-icons/icons/files/not-found.svg | 7 + .../file-icons/icons/files/notebook.svg | 7 + linux/assets/file-icons/icons/files/npm.svg | 7 + linux/assets/file-icons/icons/files/nuxt.svg | 7 + linux/assets/file-icons/icons/files/nx.svg | 7 + linux/assets/file-icons/icons/files/ocaml.svg | 7 + .../assets/file-icons/icons/files/package.svg | 7 + linux/assets/file-icons/icons/files/page.svg | 7 + linux/assets/file-icons/icons/files/patch.svg | 7 + linux/assets/file-icons/icons/files/pdf.svg | 7 + linux/assets/file-icons/icons/files/perl.svg | 7 + linux/assets/file-icons/icons/files/php.svg | 7 + .../file-icons/icons/files/playwright.svg | 7 + linux/assets/file-icons/icons/files/pnpm.svg | 7 + .../file-icons/icons/files/postgres.svg | 7 + .../file-icons/icons/files/powerpoint.svg | 7 + .../file-icons/icons/files/prettier.svg | 7 + .../assets/file-icons/icons/files/prisma.svg | 7 + linux/assets/file-icons/icons/files/proto.svg | 7 + .../assets/file-icons/icons/files/python.svg | 7 + linux/assets/file-icons/icons/files/qwik.svg | 7 + linux/assets/file-icons/icons/files/r.svg | 7 + linux/assets/file-icons/icons/files/react.svg | 7 + linux/assets/file-icons/icons/files/redis.svg | 7 + linux/assets/file-icons/icons/files/remix.svg | 7 + .../file-icons/icons/files/renovate.svg | 7 + .../file-icons/icons/files/rescript.svg | 7 + .../assets/file-icons/icons/files/rollup.svg | 7 + linux/assets/file-icons/icons/files/route.svg | 7 + .../assets/file-icons/icons/files/rspack.svg | 7 + linux/assets/file-icons/icons/files/ruby.svg | 7 + linux/assets/file-icons/icons/files/rust.svg | 7 + linux/assets/file-icons/icons/files/sass.svg | 7 + linux/assets/file-icons/icons/files/scala.svg | 7 + .../file-icons/icons/files/security.svg | 7 + linux/assets/file-icons/icons/files/shell.svg | 7 + .../assets/file-icons/icons/files/sketch.svg | 7 + linux/assets/file-icons/icons/files/solid.svg | 7 + .../file-icons/icons/files/solidity.svg | 7 + .../file-icons/icons/files/spreadsheet.svg | 7 + linux/assets/file-icons/icons/files/sql.svg | 7 + .../file-icons/icons/files/storybook.svg | 7 + .../file-icons/icons/files/stylelint.svg | 7 + .../file-icons/icons/files/supabase.svg | 7 + .../assets/file-icons/icons/files/svelte.svg | 7 + linux/assets/file-icons/icons/files/svg.svg | 7 + linux/assets/file-icons/icons/files/swc.svg | 7 + linux/assets/file-icons/icons/files/swift.svg | 7 + .../file-icons/icons/files/tailwind.svg | 7 + linux/assets/file-icons/icons/files/tauri.svg | 7 + .../file-icons/icons/files/terraform.svg | 7 + linux/assets/file-icons/icons/files/test.svg | 7 + linux/assets/file-icons/icons/files/text.svg | 7 + linux/assets/file-icons/icons/files/toml.svg | 7 + .../file-icons/icons/files/turborepo.svg | 7 + .../file-icons/icons/files/typescript.svg | 7 + .../file-icons/icons/files/vercel-config.svg | 7 + .../assets/file-icons/icons/files/vercel.svg | 7 + linux/assets/file-icons/icons/files/video.svg | 7 + linux/assets/file-icons/icons/files/vite.svg | 7 + .../assets/file-icons/icons/files/vitest.svg | 7 + linux/assets/file-icons/icons/files/vue.svg | 7 + .../assets/file-icons/icons/files/warning.svg | 7 + linux/assets/file-icons/icons/files/wasm.svg | 7 + .../assets/file-icons/icons/files/webpack.svg | 7 + .../assets/file-icons/icons/files/windows.svg | 7 + linux/assets/file-icons/icons/files/word.svg | 7 + linux/assets/file-icons/icons/files/xcode.svg | 7 + linux/assets/file-icons/icons/files/xml.svg | 7 + linux/assets/file-icons/icons/files/yaml.svg | 7 + linux/assets/file-icons/icons/files/yarn.svg | 7 + linux/assets/file-icons/icons/files/zig.svg | 7 + .../icons/folders/folder-ai-open.svg | 5 + .../file-icons/icons/folders/folder-ai.svg | 5 + .../icons/folders/folder-assets-open.svg | 5 + .../icons/folders/folder-assets.svg | 5 + .../icons/folders/folder-build-open.svg | 5 + .../file-icons/icons/folders/folder-build.svg | 5 + .../icons/folders/folder-cloud-open.svg | 5 + .../file-icons/icons/folders/folder-cloud.svg | 5 + .../icons/folders/folder-components-open.svg | 5 + .../icons/folders/folder-components.svg | 5 + .../icons/folders/folder-config-open.svg | 5 + .../icons/folders/folder-config.svg | 5 + .../icons/folders/folder-database-open.svg | 5 + .../icons/folders/folder-database.svg | 5 + .../icons/folders/folder-docs-open.svg | 5 + .../file-icons/icons/folders/folder-docs.svg | 5 + .../icons/folders/folder-extensions-open.svg | 5 + .../icons/folders/folder-extensions.svg | 5 + .../icons/folders/folder-git-open.svg | 5 + .../file-icons/icons/folders/folder-git.svg | 5 + .../icons/folders/folder-locales-open.svg | 5 + .../icons/folders/folder-locales.svg | 5 + .../icons/folders/folder-mobile-open.svg | 5 + .../icons/folders/folder-mobile.svg | 5 + .../file-icons/icons/folders/folder-open.svg | 4 + .../icons/folders/folder-packages-open.svg | 5 + .../icons/folders/folder-packages.svg | 5 + .../icons/folders/folder-routes-open.svg | 5 + .../icons/folders/folder-routes.svg | 5 + .../icons/folders/folder-rust-open.svg | 5 + .../file-icons/icons/folders/folder-rust.svg | 5 + .../icons/folders/folder-scripts-open.svg | 5 + .../icons/folders/folder-scripts.svg | 5 + .../icons/folders/folder-security-open.svg | 5 + .../icons/folders/folder-security.svg | 5 + .../icons/folders/folder-source-open.svg | 5 + .../icons/folders/folder-source.svg | 5 + .../icons/folders/folder-styles-open.svg | 5 + .../icons/folders/folder-styles.svg | 5 + .../icons/folders/folder-test-open.svg | 5 + .../file-icons/icons/folders/folder-test.svg | 5 + .../file-icons/icons/folders/folder.svg | 4 + .../file-icons/icons/light/files/adobe.svg | 7 + .../file-icons/icons/light/files/agents.svg | 7 + .../file-icons/icons/light/files/android.svg | 7 + .../file-icons/icons/light/files/angular.svg | 7 + .../file-icons/icons/light/files/ansible.svg | 7 + .../file-icons/icons/light/files/apple.svg | 7 + .../file-icons/icons/light/files/archive.svg | 7 + .../file-icons/icons/light/files/arduino.svg | 7 + .../file-icons/icons/light/files/astro.svg | 7 + .../file-icons/icons/light/files/audio.svg | 7 + .../file-icons/icons/light/files/authors.svg | 7 + .../file-icons/icons/light/files/babel.svg | 7 + .../file-icons/icons/light/files/biome.svg | 7 + .../icons/light/files/bitbucket.svg | 7 + .../file-icons/icons/light/files/blender.svg | 7 + .../file-icons/icons/light/files/bun.svg | 7 + .../assets/file-icons/icons/light/files/c.svg | 7 + .../icons/light/files/certificate.svg | 7 + .../icons/light/files/changelog.svg | 7 + .../file-icons/icons/light/files/claude.svg | 7 + .../file-icons/icons/light/files/cline.svg | 7 + .../file-icons/icons/light/files/clojure.svg | 7 + .../icons/light/files/cloudflare.svg | 7 + .../file-icons/icons/light/files/cmake.svg | 7 + .../file-icons/icons/light/files/codex.svg | 7 + .../icons/light/files/commitlint.svg | 7 + .../file-icons/icons/light/files/config.svg | 7 + .../file-icons/icons/light/files/copilot.svg | 7 + .../file-icons/icons/light/files/cpp.svg | 7 + .../file-icons/icons/light/files/csharp.svg | 7 + .../file-icons/icons/light/files/cspell.svg | 7 + .../file-icons/icons/light/files/css.svg | 7 + .../file-icons/icons/light/files/csv.svg | 7 + .../file-icons/icons/light/files/cursor.svg | 7 + .../file-icons/icons/light/files/cypress.svg | 7 + .../file-icons/icons/light/files/dart.svg | 7 + .../file-icons/icons/light/files/database.svg | 7 + .../file-icons/icons/light/files/deno.svg | 7 + .../icons/light/files/dependabot.svg | 7 + .../icons/light/files/devcontainer.svg | 7 + .../file-icons/icons/light/files/diff.svg | 7 + .../file-icons/icons/light/files/django.svg | 7 + .../icons/light/files/docker-compose.svg | 7 + .../file-icons/icons/light/files/docker.svg | 7 + .../file-icons/icons/light/files/document.svg | 7 + .../icons/light/files/docusaurus.svg | 7 + .../file-icons/icons/light/files/drawio.svg | 7 + .../file-icons/icons/light/files/drizzle.svg | 7 + .../icons/light/files/editorconfig.svg | 7 + .../file-icons/icons/light/files/electron.svg | 7 + .../file-icons/icons/light/files/elixir.svg | 7 + .../file-icons/icons/light/files/env.svg | 7 + .../file-icons/icons/light/files/erlang.svg | 7 + .../file-icons/icons/light/files/error.svg | 7 + .../file-icons/icons/light/files/eslint.svg | 7 + .../icons/light/files/excalidraw.svg | 7 + .../file-icons/icons/light/files/fastapi.svg | 7 + .../file-icons/icons/light/files/figma.svg | 7 + .../file-icons/icons/light/files/file.svg | 7 + .../file-icons/icons/light/files/firebase.svg | 7 + .../file-icons/icons/light/files/flask.svg | 7 + .../file-icons/icons/light/files/font.svg | 7 + .../file-icons/icons/light/files/gatsby.svg | 7 + .../file-icons/icons/light/files/gemini.svg | 7 + .../file-icons/icons/light/files/git.svg | 7 + .../icons/light/files/github-actions.svg | 7 + .../file-icons/icons/light/files/github.svg | 7 + .../file-icons/icons/light/files/gitlab.svg | 7 + .../file-icons/icons/light/files/go.svg | 7 + .../file-icons/icons/light/files/gradle.svg | 7 + .../icons/light/files/graphql-schema.svg | 7 + .../file-icons/icons/light/files/graphql.svg | 7 + .../file-icons/icons/light/files/haskell.svg | 7 + .../file-icons/icons/light/files/helm.svg | 7 + .../file-icons/icons/light/files/html.svg | 7 + .../file-icons/icons/light/files/http.svg | 7 + .../file-icons/icons/light/files/hurl.svg | 7 + .../file-icons/icons/light/files/image.svg | 7 + .../file-icons/icons/light/files/index-js.svg | 7 + .../file-icons/icons/light/files/index-ts.svg | 7 + .../file-icons/icons/light/files/java.svg | 7 + .../icons/light/files/javascript.svg | 7 + .../file-icons/icons/light/files/jenkins.svg | 7 + .../file-icons/icons/light/files/jest.svg | 7 + .../file-icons/icons/light/files/jsconfig.svg | 7 + .../file-icons/icons/light/files/json.svg | 7 + .../file-icons/icons/light/files/julia.svg | 7 + .../file-icons/icons/light/files/key.svg | 7 + .../file-icons/icons/light/files/kotlin.svg | 7 + .../icons/light/files/kubernetes.svg | 7 + .../file-icons/icons/light/files/laravel.svg | 7 + .../file-icons/icons/light/files/layout.svg | 7 + .../file-icons/icons/light/files/license.svg | 7 + .../icons/light/files/lintstaged.svg | 7 + .../file-icons/icons/light/files/linux.svg | 7 + .../file-icons/icons/light/files/lit.svg | 7 + .../file-icons/icons/light/files/lithe.svg | 7 + .../file-icons/icons/light/files/loading.svg | 7 + .../file-icons/icons/light/files/lock.svg | 7 + .../file-icons/icons/light/files/log.svg | 7 + .../file-icons/icons/light/files/lua.svg | 7 + .../file-icons/icons/light/files/makefile.svg | 7 + .../file-icons/icons/light/files/markdown.svg | 7 + .../icons/light/files/markdownlint.svg | 7 + .../file-icons/icons/light/files/maven.svg | 7 + .../file-icons/icons/light/files/mcp.svg | 7 + .../file-icons/icons/light/files/mermaid.svg | 7 + .../file-icons/icons/light/files/mongo.svg | 7 + .../file-icons/icons/light/files/netlify.svg | 7 + .../file-icons/icons/light/files/next.svg | 7 + .../file-icons/icons/light/files/nginx.svg | 7 + .../file-icons/icons/light/files/nim.svg | 7 + .../file-icons/icons/light/files/nix.svg | 7 + .../file-icons/icons/light/files/node.svg | 7 + .../icons/light/files/not-found.svg | 7 + .../file-icons/icons/light/files/notebook.svg | 7 + .../file-icons/icons/light/files/npm.svg | 7 + .../file-icons/icons/light/files/nuxt.svg | 7 + .../file-icons/icons/light/files/nx.svg | 7 + .../file-icons/icons/light/files/ocaml.svg | 7 + .../file-icons/icons/light/files/package.svg | 7 + .../file-icons/icons/light/files/page.svg | 7 + .../file-icons/icons/light/files/patch.svg | 7 + .../file-icons/icons/light/files/pdf.svg | 7 + .../file-icons/icons/light/files/perl.svg | 7 + .../file-icons/icons/light/files/php.svg | 7 + .../icons/light/files/playwright.svg | 7 + .../file-icons/icons/light/files/pnpm.svg | 7 + .../file-icons/icons/light/files/postgres.svg | 7 + .../icons/light/files/powerpoint.svg | 7 + .../file-icons/icons/light/files/prettier.svg | 7 + .../file-icons/icons/light/files/prisma.svg | 7 + .../file-icons/icons/light/files/proto.svg | 7 + .../file-icons/icons/light/files/python.svg | 7 + .../file-icons/icons/light/files/qwik.svg | 7 + .../assets/file-icons/icons/light/files/r.svg | 7 + .../file-icons/icons/light/files/react.svg | 7 + .../file-icons/icons/light/files/redis.svg | 7 + .../file-icons/icons/light/files/remix.svg | 7 + .../file-icons/icons/light/files/renovate.svg | 7 + .../file-icons/icons/light/files/rescript.svg | 7 + .../file-icons/icons/light/files/rollup.svg | 7 + .../file-icons/icons/light/files/route.svg | 7 + .../file-icons/icons/light/files/rspack.svg | 7 + .../file-icons/icons/light/files/ruby.svg | 7 + .../file-icons/icons/light/files/rust.svg | 7 + .../file-icons/icons/light/files/sass.svg | 7 + .../file-icons/icons/light/files/scala.svg | 7 + .../file-icons/icons/light/files/security.svg | 7 + .../file-icons/icons/light/files/shell.svg | 7 + .../file-icons/icons/light/files/sketch.svg | 7 + .../file-icons/icons/light/files/solid.svg | 7 + .../file-icons/icons/light/files/solidity.svg | 7 + .../icons/light/files/spreadsheet.svg | 7 + .../file-icons/icons/light/files/sql.svg | 7 + .../icons/light/files/storybook.svg | 7 + .../icons/light/files/stylelint.svg | 7 + .../file-icons/icons/light/files/supabase.svg | 7 + .../file-icons/icons/light/files/svelte.svg | 7 + .../file-icons/icons/light/files/svg.svg | 7 + .../file-icons/icons/light/files/swc.svg | 7 + .../file-icons/icons/light/files/swift.svg | 7 + .../file-icons/icons/light/files/tailwind.svg | 7 + .../file-icons/icons/light/files/tauri.svg | 7 + .../icons/light/files/terraform.svg | 7 + .../file-icons/icons/light/files/test.svg | 7 + .../file-icons/icons/light/files/text.svg | 7 + .../file-icons/icons/light/files/toml.svg | 7 + .../icons/light/files/turborepo.svg | 7 + .../icons/light/files/typescript.svg | 7 + .../icons/light/files/vercel-config.svg | 7 + .../file-icons/icons/light/files/vercel.svg | 7 + .../file-icons/icons/light/files/video.svg | 7 + .../file-icons/icons/light/files/vite.svg | 7 + .../file-icons/icons/light/files/vitest.svg | 7 + .../file-icons/icons/light/files/vue.svg | 7 + .../file-icons/icons/light/files/warning.svg | 7 + .../file-icons/icons/light/files/wasm.svg | 7 + .../file-icons/icons/light/files/webpack.svg | 7 + .../file-icons/icons/light/files/windows.svg | 7 + .../file-icons/icons/light/files/word.svg | 7 + .../file-icons/icons/light/files/xcode.svg | 7 + .../file-icons/icons/light/files/xml.svg | 7 + .../file-icons/icons/light/files/yaml.svg | 7 + .../file-icons/icons/light/files/yarn.svg | 7 + .../file-icons/icons/light/files/zig.svg | 7 + .../icons/light/folders/folder-ai-open.svg | 5 + .../icons/light/folders/folder-ai.svg | 5 + .../light/folders/folder-assets-open.svg | 5 + .../icons/light/folders/folder-assets.svg | 5 + .../icons/light/folders/folder-build-open.svg | 5 + .../icons/light/folders/folder-build.svg | 5 + .../icons/light/folders/folder-cloud-open.svg | 5 + .../icons/light/folders/folder-cloud.svg | 5 + .../light/folders/folder-components-open.svg | 5 + .../icons/light/folders/folder-components.svg | 5 + .../light/folders/folder-config-open.svg | 5 + .../icons/light/folders/folder-config.svg | 5 + .../light/folders/folder-database-open.svg | 5 + .../icons/light/folders/folder-database.svg | 5 + .../icons/light/folders/folder-docs-open.svg | 5 + .../icons/light/folders/folder-docs.svg | 5 + .../light/folders/folder-extensions-open.svg | 5 + .../icons/light/folders/folder-extensions.svg | 5 + .../icons/light/folders/folder-git-open.svg | 5 + .../icons/light/folders/folder-git.svg | 5 + .../light/folders/folder-locales-open.svg | 5 + .../icons/light/folders/folder-locales.svg | 5 + .../light/folders/folder-mobile-open.svg | 5 + .../icons/light/folders/folder-mobile.svg | 5 + .../icons/light/folders/folder-open.svg | 4 + .../light/folders/folder-packages-open.svg | 5 + .../icons/light/folders/folder-packages.svg | 5 + .../light/folders/folder-routes-open.svg | 5 + .../icons/light/folders/folder-routes.svg | 5 + .../icons/light/folders/folder-rust-open.svg | 5 + .../icons/light/folders/folder-rust.svg | 5 + .../light/folders/folder-scripts-open.svg | 5 + .../icons/light/folders/folder-scripts.svg | 5 + .../light/folders/folder-security-open.svg | 5 + .../icons/light/folders/folder-security.svg | 5 + .../light/folders/folder-source-open.svg | 5 + .../icons/light/folders/folder-source.svg | 5 + .../light/folders/folder-styles-open.svg | 5 + .../icons/light/folders/folder-styles.svg | 5 + .../icons/light/folders/folder-test-open.svg | 5 + .../icons/light/folders/folder-test.svg | 5 + .../file-icons/icons/light/folders/folder.svg | 4 + linux/assets/file-icons/map.json | 1 + linux/build.rs | 325 ++++++++++++++++++ linux/src/workbench/editor.rs | 39 ++- linux/src/workbench/file_icon.rs | 107 ++++++ linux/src/workbench/mod.rs | 3 + linux/src/workbench/quick_open.rs | 35 +- linux/src/workbench/sidebar.rs | 72 +++- 463 files changed, 3570 insertions(+), 32 deletions(-) create mode 100644 linux/assets/file-icons/icons/files/adobe.svg create mode 100644 linux/assets/file-icons/icons/files/agents.svg create mode 100644 linux/assets/file-icons/icons/files/android.svg create mode 100644 linux/assets/file-icons/icons/files/angular.svg create mode 100644 linux/assets/file-icons/icons/files/ansible.svg create mode 100644 linux/assets/file-icons/icons/files/apple.svg create mode 100644 linux/assets/file-icons/icons/files/archive.svg create mode 100644 linux/assets/file-icons/icons/files/arduino.svg create mode 100644 linux/assets/file-icons/icons/files/astro.svg create mode 100644 linux/assets/file-icons/icons/files/audio.svg create mode 100644 linux/assets/file-icons/icons/files/authors.svg create mode 100644 linux/assets/file-icons/icons/files/babel.svg create mode 100644 linux/assets/file-icons/icons/files/biome.svg create mode 100644 linux/assets/file-icons/icons/files/bitbucket.svg create mode 100644 linux/assets/file-icons/icons/files/blender.svg create mode 100644 linux/assets/file-icons/icons/files/bun.svg create mode 100644 linux/assets/file-icons/icons/files/c.svg create mode 100644 linux/assets/file-icons/icons/files/certificate.svg create mode 100644 linux/assets/file-icons/icons/files/changelog.svg create mode 100644 linux/assets/file-icons/icons/files/claude.svg create mode 100644 linux/assets/file-icons/icons/files/cline.svg create mode 100644 linux/assets/file-icons/icons/files/clojure.svg create mode 100644 linux/assets/file-icons/icons/files/cloudflare.svg create mode 100644 linux/assets/file-icons/icons/files/cmake.svg create mode 100644 linux/assets/file-icons/icons/files/codex.svg create mode 100644 linux/assets/file-icons/icons/files/commitlint.svg create mode 100644 linux/assets/file-icons/icons/files/config.svg create mode 100644 linux/assets/file-icons/icons/files/copilot.svg create mode 100644 linux/assets/file-icons/icons/files/cpp.svg create mode 100644 linux/assets/file-icons/icons/files/csharp.svg create mode 100644 linux/assets/file-icons/icons/files/cspell.svg create mode 100644 linux/assets/file-icons/icons/files/css.svg create mode 100644 linux/assets/file-icons/icons/files/csv.svg create mode 100644 linux/assets/file-icons/icons/files/cursor.svg create mode 100644 linux/assets/file-icons/icons/files/cypress.svg create mode 100644 linux/assets/file-icons/icons/files/dart.svg create mode 100644 linux/assets/file-icons/icons/files/database.svg create mode 100644 linux/assets/file-icons/icons/files/deno.svg create mode 100644 linux/assets/file-icons/icons/files/dependabot.svg create mode 100644 linux/assets/file-icons/icons/files/devcontainer.svg create mode 100644 linux/assets/file-icons/icons/files/diff.svg create mode 100644 linux/assets/file-icons/icons/files/django.svg create mode 100644 linux/assets/file-icons/icons/files/docker-compose.svg create mode 100644 linux/assets/file-icons/icons/files/docker.svg create mode 100644 linux/assets/file-icons/icons/files/document.svg create mode 100644 linux/assets/file-icons/icons/files/docusaurus.svg create mode 100644 linux/assets/file-icons/icons/files/drawio.svg create mode 100644 linux/assets/file-icons/icons/files/drizzle.svg create mode 100644 linux/assets/file-icons/icons/files/editorconfig.svg create mode 100644 linux/assets/file-icons/icons/files/electron.svg create mode 100644 linux/assets/file-icons/icons/files/elixir.svg create mode 100644 linux/assets/file-icons/icons/files/env.svg create mode 100644 linux/assets/file-icons/icons/files/erlang.svg create mode 100644 linux/assets/file-icons/icons/files/error.svg create mode 100644 linux/assets/file-icons/icons/files/eslint.svg create mode 100644 linux/assets/file-icons/icons/files/excalidraw.svg create mode 100644 linux/assets/file-icons/icons/files/fastapi.svg create mode 100644 linux/assets/file-icons/icons/files/figma.svg create mode 100644 linux/assets/file-icons/icons/files/file.svg create mode 100644 linux/assets/file-icons/icons/files/firebase.svg create mode 100644 linux/assets/file-icons/icons/files/flask.svg create mode 100644 linux/assets/file-icons/icons/files/font.svg create mode 100644 linux/assets/file-icons/icons/files/gatsby.svg create mode 100644 linux/assets/file-icons/icons/files/gemini.svg create mode 100644 linux/assets/file-icons/icons/files/git.svg create mode 100644 linux/assets/file-icons/icons/files/github-actions.svg create mode 100644 linux/assets/file-icons/icons/files/github.svg create mode 100644 linux/assets/file-icons/icons/files/gitlab.svg create mode 100644 linux/assets/file-icons/icons/files/go.svg create mode 100644 linux/assets/file-icons/icons/files/gradle.svg create mode 100644 linux/assets/file-icons/icons/files/graphql-schema.svg create mode 100644 linux/assets/file-icons/icons/files/graphql.svg create mode 100644 linux/assets/file-icons/icons/files/haskell.svg create mode 100644 linux/assets/file-icons/icons/files/helm.svg create mode 100644 linux/assets/file-icons/icons/files/html.svg create mode 100644 linux/assets/file-icons/icons/files/http.svg create mode 100644 linux/assets/file-icons/icons/files/hurl.svg create mode 100644 linux/assets/file-icons/icons/files/image.svg create mode 100644 linux/assets/file-icons/icons/files/index-js.svg create mode 100644 linux/assets/file-icons/icons/files/index-ts.svg create mode 100644 linux/assets/file-icons/icons/files/java.svg create mode 100644 linux/assets/file-icons/icons/files/javascript.svg create mode 100644 linux/assets/file-icons/icons/files/jenkins.svg create mode 100644 linux/assets/file-icons/icons/files/jest.svg create mode 100644 linux/assets/file-icons/icons/files/jsconfig.svg create mode 100644 linux/assets/file-icons/icons/files/json.svg create mode 100644 linux/assets/file-icons/icons/files/julia.svg create mode 100644 linux/assets/file-icons/icons/files/key.svg create mode 100644 linux/assets/file-icons/icons/files/kotlin.svg create mode 100644 linux/assets/file-icons/icons/files/kubernetes.svg create mode 100644 linux/assets/file-icons/icons/files/laravel.svg create mode 100644 linux/assets/file-icons/icons/files/layout.svg create mode 100644 linux/assets/file-icons/icons/files/license.svg create mode 100644 linux/assets/file-icons/icons/files/lintstaged.svg create mode 100644 linux/assets/file-icons/icons/files/linux.svg create mode 100644 linux/assets/file-icons/icons/files/lit.svg create mode 100644 linux/assets/file-icons/icons/files/lithe.svg create mode 100644 linux/assets/file-icons/icons/files/loading.svg create mode 100644 linux/assets/file-icons/icons/files/lock.svg create mode 100644 linux/assets/file-icons/icons/files/log.svg create mode 100644 linux/assets/file-icons/icons/files/lua.svg create mode 100644 linux/assets/file-icons/icons/files/makefile.svg create mode 100644 linux/assets/file-icons/icons/files/markdown.svg create mode 100644 linux/assets/file-icons/icons/files/markdownlint.svg create mode 100644 linux/assets/file-icons/icons/files/maven.svg create mode 100644 linux/assets/file-icons/icons/files/mcp.svg create mode 100644 linux/assets/file-icons/icons/files/mermaid.svg create mode 100644 linux/assets/file-icons/icons/files/mongo.svg create mode 100644 linux/assets/file-icons/icons/files/netlify.svg create mode 100644 linux/assets/file-icons/icons/files/next.svg create mode 100644 linux/assets/file-icons/icons/files/nginx.svg create mode 100644 linux/assets/file-icons/icons/files/nim.svg create mode 100644 linux/assets/file-icons/icons/files/nix.svg create mode 100644 linux/assets/file-icons/icons/files/node.svg create mode 100644 linux/assets/file-icons/icons/files/not-found.svg create mode 100644 linux/assets/file-icons/icons/files/notebook.svg create mode 100644 linux/assets/file-icons/icons/files/npm.svg create mode 100644 linux/assets/file-icons/icons/files/nuxt.svg create mode 100644 linux/assets/file-icons/icons/files/nx.svg create mode 100644 linux/assets/file-icons/icons/files/ocaml.svg create mode 100644 linux/assets/file-icons/icons/files/package.svg create mode 100644 linux/assets/file-icons/icons/files/page.svg create mode 100644 linux/assets/file-icons/icons/files/patch.svg create mode 100644 linux/assets/file-icons/icons/files/pdf.svg create mode 100644 linux/assets/file-icons/icons/files/perl.svg create mode 100644 linux/assets/file-icons/icons/files/php.svg create mode 100644 linux/assets/file-icons/icons/files/playwright.svg create mode 100644 linux/assets/file-icons/icons/files/pnpm.svg create mode 100644 linux/assets/file-icons/icons/files/postgres.svg create mode 100644 linux/assets/file-icons/icons/files/powerpoint.svg create mode 100644 linux/assets/file-icons/icons/files/prettier.svg create mode 100644 linux/assets/file-icons/icons/files/prisma.svg create mode 100644 linux/assets/file-icons/icons/files/proto.svg create mode 100644 linux/assets/file-icons/icons/files/python.svg create mode 100644 linux/assets/file-icons/icons/files/qwik.svg create mode 100644 linux/assets/file-icons/icons/files/r.svg create mode 100644 linux/assets/file-icons/icons/files/react.svg create mode 100644 linux/assets/file-icons/icons/files/redis.svg create mode 100644 linux/assets/file-icons/icons/files/remix.svg create mode 100644 linux/assets/file-icons/icons/files/renovate.svg create mode 100644 linux/assets/file-icons/icons/files/rescript.svg create mode 100644 linux/assets/file-icons/icons/files/rollup.svg create mode 100644 linux/assets/file-icons/icons/files/route.svg create mode 100644 linux/assets/file-icons/icons/files/rspack.svg create mode 100644 linux/assets/file-icons/icons/files/ruby.svg create mode 100644 linux/assets/file-icons/icons/files/rust.svg create mode 100644 linux/assets/file-icons/icons/files/sass.svg create mode 100644 linux/assets/file-icons/icons/files/scala.svg create mode 100644 linux/assets/file-icons/icons/files/security.svg create mode 100644 linux/assets/file-icons/icons/files/shell.svg create mode 100644 linux/assets/file-icons/icons/files/sketch.svg create mode 100644 linux/assets/file-icons/icons/files/solid.svg create mode 100644 linux/assets/file-icons/icons/files/solidity.svg create mode 100644 linux/assets/file-icons/icons/files/spreadsheet.svg create mode 100644 linux/assets/file-icons/icons/files/sql.svg create mode 100644 linux/assets/file-icons/icons/files/storybook.svg create mode 100644 linux/assets/file-icons/icons/files/stylelint.svg create mode 100644 linux/assets/file-icons/icons/files/supabase.svg create mode 100644 linux/assets/file-icons/icons/files/svelte.svg create mode 100644 linux/assets/file-icons/icons/files/svg.svg create mode 100644 linux/assets/file-icons/icons/files/swc.svg create mode 100644 linux/assets/file-icons/icons/files/swift.svg create mode 100644 linux/assets/file-icons/icons/files/tailwind.svg create mode 100644 linux/assets/file-icons/icons/files/tauri.svg create mode 100644 linux/assets/file-icons/icons/files/terraform.svg create mode 100644 linux/assets/file-icons/icons/files/test.svg create mode 100644 linux/assets/file-icons/icons/files/text.svg create mode 100644 linux/assets/file-icons/icons/files/toml.svg create mode 100644 linux/assets/file-icons/icons/files/turborepo.svg create mode 100644 linux/assets/file-icons/icons/files/typescript.svg create mode 100644 linux/assets/file-icons/icons/files/vercel-config.svg create mode 100644 linux/assets/file-icons/icons/files/vercel.svg create mode 100644 linux/assets/file-icons/icons/files/video.svg create mode 100644 linux/assets/file-icons/icons/files/vite.svg create mode 100644 linux/assets/file-icons/icons/files/vitest.svg create mode 100644 linux/assets/file-icons/icons/files/vue.svg create mode 100644 linux/assets/file-icons/icons/files/warning.svg create mode 100644 linux/assets/file-icons/icons/files/wasm.svg create mode 100644 linux/assets/file-icons/icons/files/webpack.svg create mode 100644 linux/assets/file-icons/icons/files/windows.svg create mode 100644 linux/assets/file-icons/icons/files/word.svg create mode 100644 linux/assets/file-icons/icons/files/xcode.svg create mode 100644 linux/assets/file-icons/icons/files/xml.svg create mode 100644 linux/assets/file-icons/icons/files/yaml.svg create mode 100644 linux/assets/file-icons/icons/files/yarn.svg create mode 100644 linux/assets/file-icons/icons/files/zig.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-ai-open.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-ai.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-assets-open.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-assets.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-build-open.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-build.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-cloud-open.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-cloud.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-components-open.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-components.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-config-open.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-config.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-database-open.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-database.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-docs-open.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-docs.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-extensions-open.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-extensions.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-git-open.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-git.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-locales-open.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-locales.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-mobile-open.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-mobile.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-open.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-packages-open.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-packages.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-routes-open.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-routes.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-rust-open.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-rust.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-scripts-open.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-scripts.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-security-open.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-security.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-source-open.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-source.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-styles-open.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-styles.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-test-open.svg create mode 100644 linux/assets/file-icons/icons/folders/folder-test.svg create mode 100644 linux/assets/file-icons/icons/folders/folder.svg create mode 100644 linux/assets/file-icons/icons/light/files/adobe.svg create mode 100644 linux/assets/file-icons/icons/light/files/agents.svg create mode 100644 linux/assets/file-icons/icons/light/files/android.svg create mode 100644 linux/assets/file-icons/icons/light/files/angular.svg create mode 100644 linux/assets/file-icons/icons/light/files/ansible.svg create mode 100644 linux/assets/file-icons/icons/light/files/apple.svg create mode 100644 linux/assets/file-icons/icons/light/files/archive.svg create mode 100644 linux/assets/file-icons/icons/light/files/arduino.svg create mode 100644 linux/assets/file-icons/icons/light/files/astro.svg create mode 100644 linux/assets/file-icons/icons/light/files/audio.svg create mode 100644 linux/assets/file-icons/icons/light/files/authors.svg create mode 100644 linux/assets/file-icons/icons/light/files/babel.svg create mode 100644 linux/assets/file-icons/icons/light/files/biome.svg create mode 100644 linux/assets/file-icons/icons/light/files/bitbucket.svg create mode 100644 linux/assets/file-icons/icons/light/files/blender.svg create mode 100644 linux/assets/file-icons/icons/light/files/bun.svg create mode 100644 linux/assets/file-icons/icons/light/files/c.svg create mode 100644 linux/assets/file-icons/icons/light/files/certificate.svg create mode 100644 linux/assets/file-icons/icons/light/files/changelog.svg create mode 100644 linux/assets/file-icons/icons/light/files/claude.svg create mode 100644 linux/assets/file-icons/icons/light/files/cline.svg create mode 100644 linux/assets/file-icons/icons/light/files/clojure.svg create mode 100644 linux/assets/file-icons/icons/light/files/cloudflare.svg create mode 100644 linux/assets/file-icons/icons/light/files/cmake.svg create mode 100644 linux/assets/file-icons/icons/light/files/codex.svg create mode 100644 linux/assets/file-icons/icons/light/files/commitlint.svg create mode 100644 linux/assets/file-icons/icons/light/files/config.svg create mode 100644 linux/assets/file-icons/icons/light/files/copilot.svg create mode 100644 linux/assets/file-icons/icons/light/files/cpp.svg create mode 100644 linux/assets/file-icons/icons/light/files/csharp.svg create mode 100644 linux/assets/file-icons/icons/light/files/cspell.svg create mode 100644 linux/assets/file-icons/icons/light/files/css.svg create mode 100644 linux/assets/file-icons/icons/light/files/csv.svg create mode 100644 linux/assets/file-icons/icons/light/files/cursor.svg create mode 100644 linux/assets/file-icons/icons/light/files/cypress.svg create mode 100644 linux/assets/file-icons/icons/light/files/dart.svg create mode 100644 linux/assets/file-icons/icons/light/files/database.svg create mode 100644 linux/assets/file-icons/icons/light/files/deno.svg create mode 100644 linux/assets/file-icons/icons/light/files/dependabot.svg create mode 100644 linux/assets/file-icons/icons/light/files/devcontainer.svg create mode 100644 linux/assets/file-icons/icons/light/files/diff.svg create mode 100644 linux/assets/file-icons/icons/light/files/django.svg create mode 100644 linux/assets/file-icons/icons/light/files/docker-compose.svg create mode 100644 linux/assets/file-icons/icons/light/files/docker.svg create mode 100644 linux/assets/file-icons/icons/light/files/document.svg create mode 100644 linux/assets/file-icons/icons/light/files/docusaurus.svg create mode 100644 linux/assets/file-icons/icons/light/files/drawio.svg create mode 100644 linux/assets/file-icons/icons/light/files/drizzle.svg create mode 100644 linux/assets/file-icons/icons/light/files/editorconfig.svg create mode 100644 linux/assets/file-icons/icons/light/files/electron.svg create mode 100644 linux/assets/file-icons/icons/light/files/elixir.svg create mode 100644 linux/assets/file-icons/icons/light/files/env.svg create mode 100644 linux/assets/file-icons/icons/light/files/erlang.svg create mode 100644 linux/assets/file-icons/icons/light/files/error.svg create mode 100644 linux/assets/file-icons/icons/light/files/eslint.svg create mode 100644 linux/assets/file-icons/icons/light/files/excalidraw.svg create mode 100644 linux/assets/file-icons/icons/light/files/fastapi.svg create mode 100644 linux/assets/file-icons/icons/light/files/figma.svg create mode 100644 linux/assets/file-icons/icons/light/files/file.svg create mode 100644 linux/assets/file-icons/icons/light/files/firebase.svg create mode 100644 linux/assets/file-icons/icons/light/files/flask.svg create mode 100644 linux/assets/file-icons/icons/light/files/font.svg create mode 100644 linux/assets/file-icons/icons/light/files/gatsby.svg create mode 100644 linux/assets/file-icons/icons/light/files/gemini.svg create mode 100644 linux/assets/file-icons/icons/light/files/git.svg create mode 100644 linux/assets/file-icons/icons/light/files/github-actions.svg create mode 100644 linux/assets/file-icons/icons/light/files/github.svg create mode 100644 linux/assets/file-icons/icons/light/files/gitlab.svg create mode 100644 linux/assets/file-icons/icons/light/files/go.svg create mode 100644 linux/assets/file-icons/icons/light/files/gradle.svg create mode 100644 linux/assets/file-icons/icons/light/files/graphql-schema.svg create mode 100644 linux/assets/file-icons/icons/light/files/graphql.svg create mode 100644 linux/assets/file-icons/icons/light/files/haskell.svg create mode 100644 linux/assets/file-icons/icons/light/files/helm.svg create mode 100644 linux/assets/file-icons/icons/light/files/html.svg create mode 100644 linux/assets/file-icons/icons/light/files/http.svg create mode 100644 linux/assets/file-icons/icons/light/files/hurl.svg create mode 100644 linux/assets/file-icons/icons/light/files/image.svg create mode 100644 linux/assets/file-icons/icons/light/files/index-js.svg create mode 100644 linux/assets/file-icons/icons/light/files/index-ts.svg create mode 100644 linux/assets/file-icons/icons/light/files/java.svg create mode 100644 linux/assets/file-icons/icons/light/files/javascript.svg create mode 100644 linux/assets/file-icons/icons/light/files/jenkins.svg create mode 100644 linux/assets/file-icons/icons/light/files/jest.svg create mode 100644 linux/assets/file-icons/icons/light/files/jsconfig.svg create mode 100644 linux/assets/file-icons/icons/light/files/json.svg create mode 100644 linux/assets/file-icons/icons/light/files/julia.svg create mode 100644 linux/assets/file-icons/icons/light/files/key.svg create mode 100644 linux/assets/file-icons/icons/light/files/kotlin.svg create mode 100644 linux/assets/file-icons/icons/light/files/kubernetes.svg create mode 100644 linux/assets/file-icons/icons/light/files/laravel.svg create mode 100644 linux/assets/file-icons/icons/light/files/layout.svg create mode 100644 linux/assets/file-icons/icons/light/files/license.svg create mode 100644 linux/assets/file-icons/icons/light/files/lintstaged.svg create mode 100644 linux/assets/file-icons/icons/light/files/linux.svg create mode 100644 linux/assets/file-icons/icons/light/files/lit.svg create mode 100644 linux/assets/file-icons/icons/light/files/lithe.svg create mode 100644 linux/assets/file-icons/icons/light/files/loading.svg create mode 100644 linux/assets/file-icons/icons/light/files/lock.svg create mode 100644 linux/assets/file-icons/icons/light/files/log.svg create mode 100644 linux/assets/file-icons/icons/light/files/lua.svg create mode 100644 linux/assets/file-icons/icons/light/files/makefile.svg create mode 100644 linux/assets/file-icons/icons/light/files/markdown.svg create mode 100644 linux/assets/file-icons/icons/light/files/markdownlint.svg create mode 100644 linux/assets/file-icons/icons/light/files/maven.svg create mode 100644 linux/assets/file-icons/icons/light/files/mcp.svg create mode 100644 linux/assets/file-icons/icons/light/files/mermaid.svg create mode 100644 linux/assets/file-icons/icons/light/files/mongo.svg create mode 100644 linux/assets/file-icons/icons/light/files/netlify.svg create mode 100644 linux/assets/file-icons/icons/light/files/next.svg create mode 100644 linux/assets/file-icons/icons/light/files/nginx.svg create mode 100644 linux/assets/file-icons/icons/light/files/nim.svg create mode 100644 linux/assets/file-icons/icons/light/files/nix.svg create mode 100644 linux/assets/file-icons/icons/light/files/node.svg create mode 100644 linux/assets/file-icons/icons/light/files/not-found.svg create mode 100644 linux/assets/file-icons/icons/light/files/notebook.svg create mode 100644 linux/assets/file-icons/icons/light/files/npm.svg create mode 100644 linux/assets/file-icons/icons/light/files/nuxt.svg create mode 100644 linux/assets/file-icons/icons/light/files/nx.svg create mode 100644 linux/assets/file-icons/icons/light/files/ocaml.svg create mode 100644 linux/assets/file-icons/icons/light/files/package.svg create mode 100644 linux/assets/file-icons/icons/light/files/page.svg create mode 100644 linux/assets/file-icons/icons/light/files/patch.svg create mode 100644 linux/assets/file-icons/icons/light/files/pdf.svg create mode 100644 linux/assets/file-icons/icons/light/files/perl.svg create mode 100644 linux/assets/file-icons/icons/light/files/php.svg create mode 100644 linux/assets/file-icons/icons/light/files/playwright.svg create mode 100644 linux/assets/file-icons/icons/light/files/pnpm.svg create mode 100644 linux/assets/file-icons/icons/light/files/postgres.svg create mode 100644 linux/assets/file-icons/icons/light/files/powerpoint.svg create mode 100644 linux/assets/file-icons/icons/light/files/prettier.svg create mode 100644 linux/assets/file-icons/icons/light/files/prisma.svg create mode 100644 linux/assets/file-icons/icons/light/files/proto.svg create mode 100644 linux/assets/file-icons/icons/light/files/python.svg create mode 100644 linux/assets/file-icons/icons/light/files/qwik.svg create mode 100644 linux/assets/file-icons/icons/light/files/r.svg create mode 100644 linux/assets/file-icons/icons/light/files/react.svg create mode 100644 linux/assets/file-icons/icons/light/files/redis.svg create mode 100644 linux/assets/file-icons/icons/light/files/remix.svg create mode 100644 linux/assets/file-icons/icons/light/files/renovate.svg create mode 100644 linux/assets/file-icons/icons/light/files/rescript.svg create mode 100644 linux/assets/file-icons/icons/light/files/rollup.svg create mode 100644 linux/assets/file-icons/icons/light/files/route.svg create mode 100644 linux/assets/file-icons/icons/light/files/rspack.svg create mode 100644 linux/assets/file-icons/icons/light/files/ruby.svg create mode 100644 linux/assets/file-icons/icons/light/files/rust.svg create mode 100644 linux/assets/file-icons/icons/light/files/sass.svg create mode 100644 linux/assets/file-icons/icons/light/files/scala.svg create mode 100644 linux/assets/file-icons/icons/light/files/security.svg create mode 100644 linux/assets/file-icons/icons/light/files/shell.svg create mode 100644 linux/assets/file-icons/icons/light/files/sketch.svg create mode 100644 linux/assets/file-icons/icons/light/files/solid.svg create mode 100644 linux/assets/file-icons/icons/light/files/solidity.svg create mode 100644 linux/assets/file-icons/icons/light/files/spreadsheet.svg create mode 100644 linux/assets/file-icons/icons/light/files/sql.svg create mode 100644 linux/assets/file-icons/icons/light/files/storybook.svg create mode 100644 linux/assets/file-icons/icons/light/files/stylelint.svg create mode 100644 linux/assets/file-icons/icons/light/files/supabase.svg create mode 100644 linux/assets/file-icons/icons/light/files/svelte.svg create mode 100644 linux/assets/file-icons/icons/light/files/svg.svg create mode 100644 linux/assets/file-icons/icons/light/files/swc.svg create mode 100644 linux/assets/file-icons/icons/light/files/swift.svg create mode 100644 linux/assets/file-icons/icons/light/files/tailwind.svg create mode 100644 linux/assets/file-icons/icons/light/files/tauri.svg create mode 100644 linux/assets/file-icons/icons/light/files/terraform.svg create mode 100644 linux/assets/file-icons/icons/light/files/test.svg create mode 100644 linux/assets/file-icons/icons/light/files/text.svg create mode 100644 linux/assets/file-icons/icons/light/files/toml.svg create mode 100644 linux/assets/file-icons/icons/light/files/turborepo.svg create mode 100644 linux/assets/file-icons/icons/light/files/typescript.svg create mode 100644 linux/assets/file-icons/icons/light/files/vercel-config.svg create mode 100644 linux/assets/file-icons/icons/light/files/vercel.svg create mode 100644 linux/assets/file-icons/icons/light/files/video.svg create mode 100644 linux/assets/file-icons/icons/light/files/vite.svg create mode 100644 linux/assets/file-icons/icons/light/files/vitest.svg create mode 100644 linux/assets/file-icons/icons/light/files/vue.svg create mode 100644 linux/assets/file-icons/icons/light/files/warning.svg create mode 100644 linux/assets/file-icons/icons/light/files/wasm.svg create mode 100644 linux/assets/file-icons/icons/light/files/webpack.svg create mode 100644 linux/assets/file-icons/icons/light/files/windows.svg create mode 100644 linux/assets/file-icons/icons/light/files/word.svg create mode 100644 linux/assets/file-icons/icons/light/files/xcode.svg create mode 100644 linux/assets/file-icons/icons/light/files/xml.svg create mode 100644 linux/assets/file-icons/icons/light/files/yaml.svg create mode 100644 linux/assets/file-icons/icons/light/files/yarn.svg create mode 100644 linux/assets/file-icons/icons/light/files/zig.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-ai-open.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-ai.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-assets-open.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-assets.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-build-open.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-build.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-cloud-open.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-cloud.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-components-open.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-components.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-config-open.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-config.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-database-open.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-database.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-docs-open.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-docs.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-extensions-open.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-extensions.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-git-open.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-git.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-locales-open.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-locales.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-mobile-open.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-mobile.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-open.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-packages-open.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-packages.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-routes-open.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-routes.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-rust-open.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-rust.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-scripts-open.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-scripts.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-security-open.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-security.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-source-open.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-source.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-styles-open.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-styles.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-test-open.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder-test.svg create mode 100644 linux/assets/file-icons/icons/light/folders/folder.svg create mode 100644 linux/assets/file-icons/map.json create mode 100644 linux/build.rs create mode 100644 linux/src/workbench/file_icon.rs diff --git a/linux/assets/file-icons/icons/files/adobe.svg b/linux/assets/file-icons/icons/files/adobe.svg new file mode 100644 index 000000000..b201a3451 --- /dev/null +++ b/linux/assets/file-icons/icons/files/adobe.svg @@ -0,0 +1,7 @@ + + + + + + A + diff --git a/linux/assets/file-icons/icons/files/agents.svg b/linux/assets/file-icons/icons/files/agents.svg new file mode 100644 index 000000000..6d6d2df2f --- /dev/null +++ b/linux/assets/file-icons/icons/files/agents.svg @@ -0,0 +1,7 @@ + + + + + + AI + diff --git a/linux/assets/file-icons/icons/files/android.svg b/linux/assets/file-icons/icons/files/android.svg new file mode 100644 index 000000000..231f6d43d --- /dev/null +++ b/linux/assets/file-icons/icons/files/android.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/angular.svg b/linux/assets/file-icons/icons/files/angular.svg new file mode 100644 index 000000000..30fec93cc --- /dev/null +++ b/linux/assets/file-icons/icons/files/angular.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/ansible.svg b/linux/assets/file-icons/icons/files/ansible.svg new file mode 100644 index 000000000..fff9e0b79 --- /dev/null +++ b/linux/assets/file-icons/icons/files/ansible.svg @@ -0,0 +1,7 @@ + + + + + + A + diff --git a/linux/assets/file-icons/icons/files/apple.svg b/linux/assets/file-icons/icons/files/apple.svg new file mode 100644 index 000000000..a34cd9b5c --- /dev/null +++ b/linux/assets/file-icons/icons/files/apple.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/archive.svg b/linux/assets/file-icons/icons/files/archive.svg new file mode 100644 index 000000000..d3822e3c8 --- /dev/null +++ b/linux/assets/file-icons/icons/files/archive.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/arduino.svg b/linux/assets/file-icons/icons/files/arduino.svg new file mode 100644 index 000000000..63b1d77eb --- /dev/null +++ b/linux/assets/file-icons/icons/files/arduino.svg @@ -0,0 +1,7 @@ + + + + + + A + diff --git a/linux/assets/file-icons/icons/files/astro.svg b/linux/assets/file-icons/icons/files/astro.svg new file mode 100644 index 000000000..ecb1c91ec --- /dev/null +++ b/linux/assets/file-icons/icons/files/astro.svg @@ -0,0 +1,7 @@ + + + + + + A + diff --git a/linux/assets/file-icons/icons/files/audio.svg b/linux/assets/file-icons/icons/files/audio.svg new file mode 100644 index 000000000..20e064dfe --- /dev/null +++ b/linux/assets/file-icons/icons/files/audio.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/authors.svg b/linux/assets/file-icons/icons/files/authors.svg new file mode 100644 index 000000000..c2e044276 --- /dev/null +++ b/linux/assets/file-icons/icons/files/authors.svg @@ -0,0 +1,7 @@ + + + + + + BY + diff --git a/linux/assets/file-icons/icons/files/babel.svg b/linux/assets/file-icons/icons/files/babel.svg new file mode 100644 index 000000000..3d01b0c9c --- /dev/null +++ b/linux/assets/file-icons/icons/files/babel.svg @@ -0,0 +1,7 @@ + + + + + + B + diff --git a/linux/assets/file-icons/icons/files/biome.svg b/linux/assets/file-icons/icons/files/biome.svg new file mode 100644 index 000000000..8a9c038f0 --- /dev/null +++ b/linux/assets/file-icons/icons/files/biome.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/bitbucket.svg b/linux/assets/file-icons/icons/files/bitbucket.svg new file mode 100644 index 000000000..b8cda2c49 --- /dev/null +++ b/linux/assets/file-icons/icons/files/bitbucket.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/blender.svg b/linux/assets/file-icons/icons/files/blender.svg new file mode 100644 index 000000000..55a19331a --- /dev/null +++ b/linux/assets/file-icons/icons/files/blender.svg @@ -0,0 +1,7 @@ + + + + + + B + diff --git a/linux/assets/file-icons/icons/files/bun.svg b/linux/assets/file-icons/icons/files/bun.svg new file mode 100644 index 000000000..633a6c56a --- /dev/null +++ b/linux/assets/file-icons/icons/files/bun.svg @@ -0,0 +1,7 @@ + + + + + + B + diff --git a/linux/assets/file-icons/icons/files/c.svg b/linux/assets/file-icons/icons/files/c.svg new file mode 100644 index 000000000..94161033b --- /dev/null +++ b/linux/assets/file-icons/icons/files/c.svg @@ -0,0 +1,7 @@ + + + + + + C + diff --git a/linux/assets/file-icons/icons/files/certificate.svg b/linux/assets/file-icons/icons/files/certificate.svg new file mode 100644 index 000000000..7d7da3ae5 --- /dev/null +++ b/linux/assets/file-icons/icons/files/certificate.svg @@ -0,0 +1,7 @@ + + + + + + CRT + diff --git a/linux/assets/file-icons/icons/files/changelog.svg b/linux/assets/file-icons/icons/files/changelog.svg new file mode 100644 index 000000000..c9f55d8d9 --- /dev/null +++ b/linux/assets/file-icons/icons/files/changelog.svg @@ -0,0 +1,7 @@ + + + + + + LOG + diff --git a/linux/assets/file-icons/icons/files/claude.svg b/linux/assets/file-icons/icons/files/claude.svg new file mode 100644 index 000000000..59fc7a417 --- /dev/null +++ b/linux/assets/file-icons/icons/files/claude.svg @@ -0,0 +1,7 @@ + + + + + + AI + diff --git a/linux/assets/file-icons/icons/files/cline.svg b/linux/assets/file-icons/icons/files/cline.svg new file mode 100644 index 000000000..ac58f0392 --- /dev/null +++ b/linux/assets/file-icons/icons/files/cline.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/clojure.svg b/linux/assets/file-icons/icons/files/clojure.svg new file mode 100644 index 000000000..506bfd14e --- /dev/null +++ b/linux/assets/file-icons/icons/files/clojure.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/cloudflare.svg b/linux/assets/file-icons/icons/files/cloudflare.svg new file mode 100644 index 000000000..82a406e40 --- /dev/null +++ b/linux/assets/file-icons/icons/files/cloudflare.svg @@ -0,0 +1,7 @@ + + + + + + CF + diff --git a/linux/assets/file-icons/icons/files/cmake.svg b/linux/assets/file-icons/icons/files/cmake.svg new file mode 100644 index 000000000..bf7a4b776 --- /dev/null +++ b/linux/assets/file-icons/icons/files/cmake.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/codex.svg b/linux/assets/file-icons/icons/files/codex.svg new file mode 100644 index 000000000..33de1d910 --- /dev/null +++ b/linux/assets/file-icons/icons/files/codex.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/commitlint.svg b/linux/assets/file-icons/icons/files/commitlint.svg new file mode 100644 index 000000000..00b291e99 --- /dev/null +++ b/linux/assets/file-icons/icons/files/commitlint.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/config.svg b/linux/assets/file-icons/icons/files/config.svg new file mode 100644 index 000000000..8a2de5e52 --- /dev/null +++ b/linux/assets/file-icons/icons/files/config.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/copilot.svg b/linux/assets/file-icons/icons/files/copilot.svg new file mode 100644 index 000000000..e1aef20fb --- /dev/null +++ b/linux/assets/file-icons/icons/files/copilot.svg @@ -0,0 +1,7 @@ + + + + + + AI + diff --git a/linux/assets/file-icons/icons/files/cpp.svg b/linux/assets/file-icons/icons/files/cpp.svg new file mode 100644 index 000000000..ade60b287 --- /dev/null +++ b/linux/assets/file-icons/icons/files/cpp.svg @@ -0,0 +1,7 @@ + + + + + + C+ + diff --git a/linux/assets/file-icons/icons/files/csharp.svg b/linux/assets/file-icons/icons/files/csharp.svg new file mode 100644 index 000000000..15d7d14cd --- /dev/null +++ b/linux/assets/file-icons/icons/files/csharp.svg @@ -0,0 +1,7 @@ + + + + + + C# + diff --git a/linux/assets/file-icons/icons/files/cspell.svg b/linux/assets/file-icons/icons/files/cspell.svg new file mode 100644 index 000000000..0c710dac0 --- /dev/null +++ b/linux/assets/file-icons/icons/files/cspell.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/css.svg b/linux/assets/file-icons/icons/files/css.svg new file mode 100644 index 000000000..1bafc3494 --- /dev/null +++ b/linux/assets/file-icons/icons/files/css.svg @@ -0,0 +1,7 @@ + + + + + + # + diff --git a/linux/assets/file-icons/icons/files/csv.svg b/linux/assets/file-icons/icons/files/csv.svg new file mode 100644 index 000000000..00ff4d9c8 --- /dev/null +++ b/linux/assets/file-icons/icons/files/csv.svg @@ -0,0 +1,7 @@ + + + + + + CSV + diff --git a/linux/assets/file-icons/icons/files/cursor.svg b/linux/assets/file-icons/icons/files/cursor.svg new file mode 100644 index 000000000..0a0383480 --- /dev/null +++ b/linux/assets/file-icons/icons/files/cursor.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/cypress.svg b/linux/assets/file-icons/icons/files/cypress.svg new file mode 100644 index 000000000..3dd11b61d --- /dev/null +++ b/linux/assets/file-icons/icons/files/cypress.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/dart.svg b/linux/assets/file-icons/icons/files/dart.svg new file mode 100644 index 000000000..c326fc377 --- /dev/null +++ b/linux/assets/file-icons/icons/files/dart.svg @@ -0,0 +1,7 @@ + + + + + + D + diff --git a/linux/assets/file-icons/icons/files/database.svg b/linux/assets/file-icons/icons/files/database.svg new file mode 100644 index 000000000..e313be47d --- /dev/null +++ b/linux/assets/file-icons/icons/files/database.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/deno.svg b/linux/assets/file-icons/icons/files/deno.svg new file mode 100644 index 000000000..d9f5b39d7 --- /dev/null +++ b/linux/assets/file-icons/icons/files/deno.svg @@ -0,0 +1,7 @@ + + + + + + D + diff --git a/linux/assets/file-icons/icons/files/dependabot.svg b/linux/assets/file-icons/icons/files/dependabot.svg new file mode 100644 index 000000000..2fac961c6 --- /dev/null +++ b/linux/assets/file-icons/icons/files/dependabot.svg @@ -0,0 +1,7 @@ + + + + + + D + diff --git a/linux/assets/file-icons/icons/files/devcontainer.svg b/linux/assets/file-icons/icons/files/devcontainer.svg new file mode 100644 index 000000000..d927b1b63 --- /dev/null +++ b/linux/assets/file-icons/icons/files/devcontainer.svg @@ -0,0 +1,7 @@ + + + + + + DC + diff --git a/linux/assets/file-icons/icons/files/diff.svg b/linux/assets/file-icons/icons/files/diff.svg new file mode 100644 index 000000000..fbbadc24d --- /dev/null +++ b/linux/assets/file-icons/icons/files/diff.svg @@ -0,0 +1,7 @@ + + + + + + +- + diff --git a/linux/assets/file-icons/icons/files/django.svg b/linux/assets/file-icons/icons/files/django.svg new file mode 100644 index 000000000..29e0d32fc --- /dev/null +++ b/linux/assets/file-icons/icons/files/django.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/docker-compose.svg b/linux/assets/file-icons/icons/files/docker-compose.svg new file mode 100644 index 000000000..68c63db31 --- /dev/null +++ b/linux/assets/file-icons/icons/files/docker-compose.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/docker.svg b/linux/assets/file-icons/icons/files/docker.svg new file mode 100644 index 000000000..9444a3892 --- /dev/null +++ b/linux/assets/file-icons/icons/files/docker.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/document.svg b/linux/assets/file-icons/icons/files/document.svg new file mode 100644 index 000000000..09c9f51bf --- /dev/null +++ b/linux/assets/file-icons/icons/files/document.svg @@ -0,0 +1,7 @@ + + + + + + DOC + diff --git a/linux/assets/file-icons/icons/files/docusaurus.svg b/linux/assets/file-icons/icons/files/docusaurus.svg new file mode 100644 index 000000000..629f18e33 --- /dev/null +++ b/linux/assets/file-icons/icons/files/docusaurus.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/drawio.svg b/linux/assets/file-icons/icons/files/drawio.svg new file mode 100644 index 000000000..c337cfccd --- /dev/null +++ b/linux/assets/file-icons/icons/files/drawio.svg @@ -0,0 +1,7 @@ + + + + + + D + diff --git a/linux/assets/file-icons/icons/files/drizzle.svg b/linux/assets/file-icons/icons/files/drizzle.svg new file mode 100644 index 000000000..7041d34a5 --- /dev/null +++ b/linux/assets/file-icons/icons/files/drizzle.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/editorconfig.svg b/linux/assets/file-icons/icons/files/editorconfig.svg new file mode 100644 index 000000000..2250697b1 --- /dev/null +++ b/linux/assets/file-icons/icons/files/editorconfig.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/electron.svg b/linux/assets/file-icons/icons/files/electron.svg new file mode 100644 index 000000000..2bfb7ff1d --- /dev/null +++ b/linux/assets/file-icons/icons/files/electron.svg @@ -0,0 +1,7 @@ + + + + + + E + diff --git a/linux/assets/file-icons/icons/files/elixir.svg b/linux/assets/file-icons/icons/files/elixir.svg new file mode 100644 index 000000000..d331e75f3 --- /dev/null +++ b/linux/assets/file-icons/icons/files/elixir.svg @@ -0,0 +1,7 @@ + + + + + + EX + diff --git a/linux/assets/file-icons/icons/files/env.svg b/linux/assets/file-icons/icons/files/env.svg new file mode 100644 index 000000000..305241630 --- /dev/null +++ b/linux/assets/file-icons/icons/files/env.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/erlang.svg b/linux/assets/file-icons/icons/files/erlang.svg new file mode 100644 index 000000000..374a8b1e5 --- /dev/null +++ b/linux/assets/file-icons/icons/files/erlang.svg @@ -0,0 +1,7 @@ + + + + + + ER + diff --git a/linux/assets/file-icons/icons/files/error.svg b/linux/assets/file-icons/icons/files/error.svg new file mode 100644 index 000000000..6d1526ae9 --- /dev/null +++ b/linux/assets/file-icons/icons/files/error.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/eslint.svg b/linux/assets/file-icons/icons/files/eslint.svg new file mode 100644 index 000000000..2031a67f4 --- /dev/null +++ b/linux/assets/file-icons/icons/files/eslint.svg @@ -0,0 +1,7 @@ + + + + + + E + diff --git a/linux/assets/file-icons/icons/files/excalidraw.svg b/linux/assets/file-icons/icons/files/excalidraw.svg new file mode 100644 index 000000000..f6c0eeed0 --- /dev/null +++ b/linux/assets/file-icons/icons/files/excalidraw.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/fastapi.svg b/linux/assets/file-icons/icons/files/fastapi.svg new file mode 100644 index 000000000..695f0b01e --- /dev/null +++ b/linux/assets/file-icons/icons/files/fastapi.svg @@ -0,0 +1,7 @@ + + + + + + FA + diff --git a/linux/assets/file-icons/icons/files/figma.svg b/linux/assets/file-icons/icons/files/figma.svg new file mode 100644 index 000000000..dbef799af --- /dev/null +++ b/linux/assets/file-icons/icons/files/figma.svg @@ -0,0 +1,7 @@ + + + + + + F + diff --git a/linux/assets/file-icons/icons/files/file.svg b/linux/assets/file-icons/icons/files/file.svg new file mode 100644 index 000000000..59017652f --- /dev/null +++ b/linux/assets/file-icons/icons/files/file.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/firebase.svg b/linux/assets/file-icons/icons/files/firebase.svg new file mode 100644 index 000000000..c118fb9bf --- /dev/null +++ b/linux/assets/file-icons/icons/files/firebase.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/flask.svg b/linux/assets/file-icons/icons/files/flask.svg new file mode 100644 index 000000000..28bdc7784 --- /dev/null +++ b/linux/assets/file-icons/icons/files/flask.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/font.svg b/linux/assets/file-icons/icons/files/font.svg new file mode 100644 index 000000000..1cc2912bf --- /dev/null +++ b/linux/assets/file-icons/icons/files/font.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/gatsby.svg b/linux/assets/file-icons/icons/files/gatsby.svg new file mode 100644 index 000000000..7a1302efe --- /dev/null +++ b/linux/assets/file-icons/icons/files/gatsby.svg @@ -0,0 +1,7 @@ + + + + + + G + diff --git a/linux/assets/file-icons/icons/files/gemini.svg b/linux/assets/file-icons/icons/files/gemini.svg new file mode 100644 index 000000000..59aea00b6 --- /dev/null +++ b/linux/assets/file-icons/icons/files/gemini.svg @@ -0,0 +1,7 @@ + + + + + + G + diff --git a/linux/assets/file-icons/icons/files/git.svg b/linux/assets/file-icons/icons/files/git.svg new file mode 100644 index 000000000..e5cd60b77 --- /dev/null +++ b/linux/assets/file-icons/icons/files/git.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/github-actions.svg b/linux/assets/file-icons/icons/files/github-actions.svg new file mode 100644 index 000000000..aab3f321d --- /dev/null +++ b/linux/assets/file-icons/icons/files/github-actions.svg @@ -0,0 +1,7 @@ + + + + + + GH + diff --git a/linux/assets/file-icons/icons/files/github.svg b/linux/assets/file-icons/icons/files/github.svg new file mode 100644 index 000000000..8a1f67d7b --- /dev/null +++ b/linux/assets/file-icons/icons/files/github.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/gitlab.svg b/linux/assets/file-icons/icons/files/gitlab.svg new file mode 100644 index 000000000..2500a36ae --- /dev/null +++ b/linux/assets/file-icons/icons/files/gitlab.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/go.svg b/linux/assets/file-icons/icons/files/go.svg new file mode 100644 index 000000000..0174b5232 --- /dev/null +++ b/linux/assets/file-icons/icons/files/go.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/gradle.svg b/linux/assets/file-icons/icons/files/gradle.svg new file mode 100644 index 000000000..267a55ee3 --- /dev/null +++ b/linux/assets/file-icons/icons/files/gradle.svg @@ -0,0 +1,7 @@ + + + + + + G + diff --git a/linux/assets/file-icons/icons/files/graphql-schema.svg b/linux/assets/file-icons/icons/files/graphql-schema.svg new file mode 100644 index 000000000..64f8c0f84 --- /dev/null +++ b/linux/assets/file-icons/icons/files/graphql-schema.svg @@ -0,0 +1,7 @@ + + + + + + GS + diff --git a/linux/assets/file-icons/icons/files/graphql.svg b/linux/assets/file-icons/icons/files/graphql.svg new file mode 100644 index 000000000..bab772115 --- /dev/null +++ b/linux/assets/file-icons/icons/files/graphql.svg @@ -0,0 +1,7 @@ + + + + + + G + diff --git a/linux/assets/file-icons/icons/files/haskell.svg b/linux/assets/file-icons/icons/files/haskell.svg new file mode 100644 index 000000000..52ddcaeee --- /dev/null +++ b/linux/assets/file-icons/icons/files/haskell.svg @@ -0,0 +1,7 @@ + + + + + + HS + diff --git a/linux/assets/file-icons/icons/files/helm.svg b/linux/assets/file-icons/icons/files/helm.svg new file mode 100644 index 000000000..5be27ed28 --- /dev/null +++ b/linux/assets/file-icons/icons/files/helm.svg @@ -0,0 +1,7 @@ + + + + + + H + diff --git a/linux/assets/file-icons/icons/files/html.svg b/linux/assets/file-icons/icons/files/html.svg new file mode 100644 index 000000000..c80ca7a79 --- /dev/null +++ b/linux/assets/file-icons/icons/files/html.svg @@ -0,0 +1,7 @@ + + + + + + H + diff --git a/linux/assets/file-icons/icons/files/http.svg b/linux/assets/file-icons/icons/files/http.svg new file mode 100644 index 000000000..5280b368d --- /dev/null +++ b/linux/assets/file-icons/icons/files/http.svg @@ -0,0 +1,7 @@ + + + + + + HT + diff --git a/linux/assets/file-icons/icons/files/hurl.svg b/linux/assets/file-icons/icons/files/hurl.svg new file mode 100644 index 000000000..bf70415fa --- /dev/null +++ b/linux/assets/file-icons/icons/files/hurl.svg @@ -0,0 +1,7 @@ + + + + + + HU + diff --git a/linux/assets/file-icons/icons/files/image.svg b/linux/assets/file-icons/icons/files/image.svg new file mode 100644 index 000000000..1d2bb2639 --- /dev/null +++ b/linux/assets/file-icons/icons/files/image.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/index-js.svg b/linux/assets/file-icons/icons/files/index-js.svg new file mode 100644 index 000000000..ec34e3867 --- /dev/null +++ b/linux/assets/file-icons/icons/files/index-js.svg @@ -0,0 +1,7 @@ + + + + + + IDX + diff --git a/linux/assets/file-icons/icons/files/index-ts.svg b/linux/assets/file-icons/icons/files/index-ts.svg new file mode 100644 index 000000000..5e532a7ed --- /dev/null +++ b/linux/assets/file-icons/icons/files/index-ts.svg @@ -0,0 +1,7 @@ + + + + + + IDX + diff --git a/linux/assets/file-icons/icons/files/java.svg b/linux/assets/file-icons/icons/files/java.svg new file mode 100644 index 000000000..2bc93d42d --- /dev/null +++ b/linux/assets/file-icons/icons/files/java.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/javascript.svg b/linux/assets/file-icons/icons/files/javascript.svg new file mode 100644 index 000000000..59e3fe6ec --- /dev/null +++ b/linux/assets/file-icons/icons/files/javascript.svg @@ -0,0 +1,7 @@ + + + + + + JS + diff --git a/linux/assets/file-icons/icons/files/jenkins.svg b/linux/assets/file-icons/icons/files/jenkins.svg new file mode 100644 index 000000000..57d712ab0 --- /dev/null +++ b/linux/assets/file-icons/icons/files/jenkins.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/jest.svg b/linux/assets/file-icons/icons/files/jest.svg new file mode 100644 index 000000000..473457b11 --- /dev/null +++ b/linux/assets/file-icons/icons/files/jest.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/jsconfig.svg b/linux/assets/file-icons/icons/files/jsconfig.svg new file mode 100644 index 000000000..ce9fe1edd --- /dev/null +++ b/linux/assets/file-icons/icons/files/jsconfig.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/json.svg b/linux/assets/file-icons/icons/files/json.svg new file mode 100644 index 000000000..943bb57f6 --- /dev/null +++ b/linux/assets/file-icons/icons/files/json.svg @@ -0,0 +1,7 @@ + + + + + + {} + diff --git a/linux/assets/file-icons/icons/files/julia.svg b/linux/assets/file-icons/icons/files/julia.svg new file mode 100644 index 000000000..5a2b777b7 --- /dev/null +++ b/linux/assets/file-icons/icons/files/julia.svg @@ -0,0 +1,7 @@ + + + + + + JL + diff --git a/linux/assets/file-icons/icons/files/key.svg b/linux/assets/file-icons/icons/files/key.svg new file mode 100644 index 000000000..9348cc4ce --- /dev/null +++ b/linux/assets/file-icons/icons/files/key.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/kotlin.svg b/linux/assets/file-icons/icons/files/kotlin.svg new file mode 100644 index 000000000..353a11ce8 --- /dev/null +++ b/linux/assets/file-icons/icons/files/kotlin.svg @@ -0,0 +1,7 @@ + + + + + + K + diff --git a/linux/assets/file-icons/icons/files/kubernetes.svg b/linux/assets/file-icons/icons/files/kubernetes.svg new file mode 100644 index 000000000..9b38b313b --- /dev/null +++ b/linux/assets/file-icons/icons/files/kubernetes.svg @@ -0,0 +1,7 @@ + + + + + + K8 + diff --git a/linux/assets/file-icons/icons/files/laravel.svg b/linux/assets/file-icons/icons/files/laravel.svg new file mode 100644 index 000000000..7be1f3df4 --- /dev/null +++ b/linux/assets/file-icons/icons/files/laravel.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/layout.svg b/linux/assets/file-icons/icons/files/layout.svg new file mode 100644 index 000000000..ccdd1d73f --- /dev/null +++ b/linux/assets/file-icons/icons/files/layout.svg @@ -0,0 +1,7 @@ + + + + + + L + diff --git a/linux/assets/file-icons/icons/files/license.svg b/linux/assets/file-icons/icons/files/license.svg new file mode 100644 index 000000000..053cf8990 --- /dev/null +++ b/linux/assets/file-icons/icons/files/license.svg @@ -0,0 +1,7 @@ + + + + + + LIC + diff --git a/linux/assets/file-icons/icons/files/lintstaged.svg b/linux/assets/file-icons/icons/files/lintstaged.svg new file mode 100644 index 000000000..dcbb7fbce --- /dev/null +++ b/linux/assets/file-icons/icons/files/lintstaged.svg @@ -0,0 +1,7 @@ + + + + + + LS + diff --git a/linux/assets/file-icons/icons/files/linux.svg b/linux/assets/file-icons/icons/files/linux.svg new file mode 100644 index 000000000..b7baaefb1 --- /dev/null +++ b/linux/assets/file-icons/icons/files/linux.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/lit.svg b/linux/assets/file-icons/icons/files/lit.svg new file mode 100644 index 000000000..41956bae5 --- /dev/null +++ b/linux/assets/file-icons/icons/files/lit.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/lithe.svg b/linux/assets/file-icons/icons/files/lithe.svg new file mode 100644 index 000000000..9dd935c7e --- /dev/null +++ b/linux/assets/file-icons/icons/files/lithe.svg @@ -0,0 +1,7 @@ + + + + + + L + diff --git a/linux/assets/file-icons/icons/files/loading.svg b/linux/assets/file-icons/icons/files/loading.svg new file mode 100644 index 000000000..bc3f15ad1 --- /dev/null +++ b/linux/assets/file-icons/icons/files/loading.svg @@ -0,0 +1,7 @@ + + + + + + ... + diff --git a/linux/assets/file-icons/icons/files/lock.svg b/linux/assets/file-icons/icons/files/lock.svg new file mode 100644 index 000000000..278b20f1c --- /dev/null +++ b/linux/assets/file-icons/icons/files/lock.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/log.svg b/linux/assets/file-icons/icons/files/log.svg new file mode 100644 index 000000000..936d5e2a3 --- /dev/null +++ b/linux/assets/file-icons/icons/files/log.svg @@ -0,0 +1,7 @@ + + + + + + LOG + diff --git a/linux/assets/file-icons/icons/files/lua.svg b/linux/assets/file-icons/icons/files/lua.svg new file mode 100644 index 000000000..25ea107b8 --- /dev/null +++ b/linux/assets/file-icons/icons/files/lua.svg @@ -0,0 +1,7 @@ + + + + + + L + diff --git a/linux/assets/file-icons/icons/files/makefile.svg b/linux/assets/file-icons/icons/files/makefile.svg new file mode 100644 index 000000000..a927f9c41 --- /dev/null +++ b/linux/assets/file-icons/icons/files/makefile.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/markdown.svg b/linux/assets/file-icons/icons/files/markdown.svg new file mode 100644 index 000000000..027d5207f --- /dev/null +++ b/linux/assets/file-icons/icons/files/markdown.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/markdownlint.svg b/linux/assets/file-icons/icons/files/markdownlint.svg new file mode 100644 index 000000000..22e601114 --- /dev/null +++ b/linux/assets/file-icons/icons/files/markdownlint.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/maven.svg b/linux/assets/file-icons/icons/files/maven.svg new file mode 100644 index 000000000..dd935da72 --- /dev/null +++ b/linux/assets/file-icons/icons/files/maven.svg @@ -0,0 +1,7 @@ + + + + + + M + diff --git a/linux/assets/file-icons/icons/files/mcp.svg b/linux/assets/file-icons/icons/files/mcp.svg new file mode 100644 index 000000000..963f893fe --- /dev/null +++ b/linux/assets/file-icons/icons/files/mcp.svg @@ -0,0 +1,7 @@ + + + + + + M + diff --git a/linux/assets/file-icons/icons/files/mermaid.svg b/linux/assets/file-icons/icons/files/mermaid.svg new file mode 100644 index 000000000..eb7cab28a --- /dev/null +++ b/linux/assets/file-icons/icons/files/mermaid.svg @@ -0,0 +1,7 @@ + + + + + + MM + diff --git a/linux/assets/file-icons/icons/files/mongo.svg b/linux/assets/file-icons/icons/files/mongo.svg new file mode 100644 index 000000000..96e2a647b --- /dev/null +++ b/linux/assets/file-icons/icons/files/mongo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/netlify.svg b/linux/assets/file-icons/icons/files/netlify.svg new file mode 100644 index 000000000..133725c92 --- /dev/null +++ b/linux/assets/file-icons/icons/files/netlify.svg @@ -0,0 +1,7 @@ + + + + + + N + diff --git a/linux/assets/file-icons/icons/files/next.svg b/linux/assets/file-icons/icons/files/next.svg new file mode 100644 index 000000000..e2d90d83d --- /dev/null +++ b/linux/assets/file-icons/icons/files/next.svg @@ -0,0 +1,7 @@ + + + + + + N + diff --git a/linux/assets/file-icons/icons/files/nginx.svg b/linux/assets/file-icons/icons/files/nginx.svg new file mode 100644 index 000000000..7f1d606bf --- /dev/null +++ b/linux/assets/file-icons/icons/files/nginx.svg @@ -0,0 +1,7 @@ + + + + + + N + diff --git a/linux/assets/file-icons/icons/files/nim.svg b/linux/assets/file-icons/icons/files/nim.svg new file mode 100644 index 000000000..ac80cf1a1 --- /dev/null +++ b/linux/assets/file-icons/icons/files/nim.svg @@ -0,0 +1,7 @@ + + + + + + N + diff --git a/linux/assets/file-icons/icons/files/nix.svg b/linux/assets/file-icons/icons/files/nix.svg new file mode 100644 index 000000000..f8027026f --- /dev/null +++ b/linux/assets/file-icons/icons/files/nix.svg @@ -0,0 +1,7 @@ + + + + + + N + diff --git a/linux/assets/file-icons/icons/files/node.svg b/linux/assets/file-icons/icons/files/node.svg new file mode 100644 index 000000000..aaf878edc --- /dev/null +++ b/linux/assets/file-icons/icons/files/node.svg @@ -0,0 +1,7 @@ + + + + + + N + diff --git a/linux/assets/file-icons/icons/files/not-found.svg b/linux/assets/file-icons/icons/files/not-found.svg new file mode 100644 index 000000000..b0ef19fe1 --- /dev/null +++ b/linux/assets/file-icons/icons/files/not-found.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/notebook.svg b/linux/assets/file-icons/icons/files/notebook.svg new file mode 100644 index 000000000..3c841c9e8 --- /dev/null +++ b/linux/assets/file-icons/icons/files/notebook.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/npm.svg b/linux/assets/file-icons/icons/files/npm.svg new file mode 100644 index 000000000..26c322283 --- /dev/null +++ b/linux/assets/file-icons/icons/files/npm.svg @@ -0,0 +1,7 @@ + + + + + + N + diff --git a/linux/assets/file-icons/icons/files/nuxt.svg b/linux/assets/file-icons/icons/files/nuxt.svg new file mode 100644 index 000000000..fb699c2a2 --- /dev/null +++ b/linux/assets/file-icons/icons/files/nuxt.svg @@ -0,0 +1,7 @@ + + + + + + N + diff --git a/linux/assets/file-icons/icons/files/nx.svg b/linux/assets/file-icons/icons/files/nx.svg new file mode 100644 index 000000000..ef0c748d8 --- /dev/null +++ b/linux/assets/file-icons/icons/files/nx.svg @@ -0,0 +1,7 @@ + + + + + + NX + diff --git a/linux/assets/file-icons/icons/files/ocaml.svg b/linux/assets/file-icons/icons/files/ocaml.svg new file mode 100644 index 000000000..02d552768 --- /dev/null +++ b/linux/assets/file-icons/icons/files/ocaml.svg @@ -0,0 +1,7 @@ + + + + + + ML + diff --git a/linux/assets/file-icons/icons/files/package.svg b/linux/assets/file-icons/icons/files/package.svg new file mode 100644 index 000000000..51f25f450 --- /dev/null +++ b/linux/assets/file-icons/icons/files/package.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/page.svg b/linux/assets/file-icons/icons/files/page.svg new file mode 100644 index 000000000..860d4e6b4 --- /dev/null +++ b/linux/assets/file-icons/icons/files/page.svg @@ -0,0 +1,7 @@ + + + + + + P + diff --git a/linux/assets/file-icons/icons/files/patch.svg b/linux/assets/file-icons/icons/files/patch.svg new file mode 100644 index 000000000..ddaa0c327 --- /dev/null +++ b/linux/assets/file-icons/icons/files/patch.svg @@ -0,0 +1,7 @@ + + + + + + +- + diff --git a/linux/assets/file-icons/icons/files/pdf.svg b/linux/assets/file-icons/icons/files/pdf.svg new file mode 100644 index 000000000..15236fe62 --- /dev/null +++ b/linux/assets/file-icons/icons/files/pdf.svg @@ -0,0 +1,7 @@ + + + + + + PDF + diff --git a/linux/assets/file-icons/icons/files/perl.svg b/linux/assets/file-icons/icons/files/perl.svg new file mode 100644 index 000000000..6e5a983ae --- /dev/null +++ b/linux/assets/file-icons/icons/files/perl.svg @@ -0,0 +1,7 @@ + + + + + + PL + diff --git a/linux/assets/file-icons/icons/files/php.svg b/linux/assets/file-icons/icons/files/php.svg new file mode 100644 index 000000000..a40c8ff3c --- /dev/null +++ b/linux/assets/file-icons/icons/files/php.svg @@ -0,0 +1,7 @@ + + + + + + P + diff --git a/linux/assets/file-icons/icons/files/playwright.svg b/linux/assets/file-icons/icons/files/playwright.svg new file mode 100644 index 000000000..68522e996 --- /dev/null +++ b/linux/assets/file-icons/icons/files/playwright.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/pnpm.svg b/linux/assets/file-icons/icons/files/pnpm.svg new file mode 100644 index 000000000..fcc30d19e --- /dev/null +++ b/linux/assets/file-icons/icons/files/pnpm.svg @@ -0,0 +1,7 @@ + + + + + + P + diff --git a/linux/assets/file-icons/icons/files/postgres.svg b/linux/assets/file-icons/icons/files/postgres.svg new file mode 100644 index 000000000..0f5a3a35f --- /dev/null +++ b/linux/assets/file-icons/icons/files/postgres.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/powerpoint.svg b/linux/assets/file-icons/icons/files/powerpoint.svg new file mode 100644 index 000000000..28ff619e1 --- /dev/null +++ b/linux/assets/file-icons/icons/files/powerpoint.svg @@ -0,0 +1,7 @@ + + + + + + PPT + diff --git a/linux/assets/file-icons/icons/files/prettier.svg b/linux/assets/file-icons/icons/files/prettier.svg new file mode 100644 index 000000000..fc6d6e16a --- /dev/null +++ b/linux/assets/file-icons/icons/files/prettier.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/prisma.svg b/linux/assets/file-icons/icons/files/prisma.svg new file mode 100644 index 000000000..2d6f445e2 --- /dev/null +++ b/linux/assets/file-icons/icons/files/prisma.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/proto.svg b/linux/assets/file-icons/icons/files/proto.svg new file mode 100644 index 000000000..e116a7fdd --- /dev/null +++ b/linux/assets/file-icons/icons/files/proto.svg @@ -0,0 +1,7 @@ + + + + + + P + diff --git a/linux/assets/file-icons/icons/files/python.svg b/linux/assets/file-icons/icons/files/python.svg new file mode 100644 index 000000000..2a3ffb94c --- /dev/null +++ b/linux/assets/file-icons/icons/files/python.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/qwik.svg b/linux/assets/file-icons/icons/files/qwik.svg new file mode 100644 index 000000000..fa7b9a5c9 --- /dev/null +++ b/linux/assets/file-icons/icons/files/qwik.svg @@ -0,0 +1,7 @@ + + + + + + Q + diff --git a/linux/assets/file-icons/icons/files/r.svg b/linux/assets/file-icons/icons/files/r.svg new file mode 100644 index 000000000..abf4b93c1 --- /dev/null +++ b/linux/assets/file-icons/icons/files/r.svg @@ -0,0 +1,7 @@ + + + + + + R + diff --git a/linux/assets/file-icons/icons/files/react.svg b/linux/assets/file-icons/icons/files/react.svg new file mode 100644 index 000000000..88525c8e3 --- /dev/null +++ b/linux/assets/file-icons/icons/files/react.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/redis.svg b/linux/assets/file-icons/icons/files/redis.svg new file mode 100644 index 000000000..ef6c2033a --- /dev/null +++ b/linux/assets/file-icons/icons/files/redis.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/remix.svg b/linux/assets/file-icons/icons/files/remix.svg new file mode 100644 index 000000000..e8dbe13ce --- /dev/null +++ b/linux/assets/file-icons/icons/files/remix.svg @@ -0,0 +1,7 @@ + + + + + + R + diff --git a/linux/assets/file-icons/icons/files/renovate.svg b/linux/assets/file-icons/icons/files/renovate.svg new file mode 100644 index 000000000..4690deb44 --- /dev/null +++ b/linux/assets/file-icons/icons/files/renovate.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/rescript.svg b/linux/assets/file-icons/icons/files/rescript.svg new file mode 100644 index 000000000..f2d3b7c61 --- /dev/null +++ b/linux/assets/file-icons/icons/files/rescript.svg @@ -0,0 +1,7 @@ + + + + + + RE + diff --git a/linux/assets/file-icons/icons/files/rollup.svg b/linux/assets/file-icons/icons/files/rollup.svg new file mode 100644 index 000000000..869e62dde --- /dev/null +++ b/linux/assets/file-icons/icons/files/rollup.svg @@ -0,0 +1,7 @@ + + + + + + R + diff --git a/linux/assets/file-icons/icons/files/route.svg b/linux/assets/file-icons/icons/files/route.svg new file mode 100644 index 000000000..5e2a4c710 --- /dev/null +++ b/linux/assets/file-icons/icons/files/route.svg @@ -0,0 +1,7 @@ + + + + + + RT + diff --git a/linux/assets/file-icons/icons/files/rspack.svg b/linux/assets/file-icons/icons/files/rspack.svg new file mode 100644 index 000000000..302fab094 --- /dev/null +++ b/linux/assets/file-icons/icons/files/rspack.svg @@ -0,0 +1,7 @@ + + + + + + R + diff --git a/linux/assets/file-icons/icons/files/ruby.svg b/linux/assets/file-icons/icons/files/ruby.svg new file mode 100644 index 000000000..818e46b2f --- /dev/null +++ b/linux/assets/file-icons/icons/files/ruby.svg @@ -0,0 +1,7 @@ + + + + + + RB + diff --git a/linux/assets/file-icons/icons/files/rust.svg b/linux/assets/file-icons/icons/files/rust.svg new file mode 100644 index 000000000..3b109303d --- /dev/null +++ b/linux/assets/file-icons/icons/files/rust.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/sass.svg b/linux/assets/file-icons/icons/files/sass.svg new file mode 100644 index 000000000..93d0d5417 --- /dev/null +++ b/linux/assets/file-icons/icons/files/sass.svg @@ -0,0 +1,7 @@ + + + + + + S + diff --git a/linux/assets/file-icons/icons/files/scala.svg b/linux/assets/file-icons/icons/files/scala.svg new file mode 100644 index 000000000..2899b8ef7 --- /dev/null +++ b/linux/assets/file-icons/icons/files/scala.svg @@ -0,0 +1,7 @@ + + + + + + S + diff --git a/linux/assets/file-icons/icons/files/security.svg b/linux/assets/file-icons/icons/files/security.svg new file mode 100644 index 000000000..e8cfb3b5d --- /dev/null +++ b/linux/assets/file-icons/icons/files/security.svg @@ -0,0 +1,7 @@ + + + + + + SEC + diff --git a/linux/assets/file-icons/icons/files/shell.svg b/linux/assets/file-icons/icons/files/shell.svg new file mode 100644 index 000000000..95aecf385 --- /dev/null +++ b/linux/assets/file-icons/icons/files/shell.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/sketch.svg b/linux/assets/file-icons/icons/files/sketch.svg new file mode 100644 index 000000000..1fcb9ba0b --- /dev/null +++ b/linux/assets/file-icons/icons/files/sketch.svg @@ -0,0 +1,7 @@ + + + + + + S + diff --git a/linux/assets/file-icons/icons/files/solid.svg b/linux/assets/file-icons/icons/files/solid.svg new file mode 100644 index 000000000..1eb34aeef --- /dev/null +++ b/linux/assets/file-icons/icons/files/solid.svg @@ -0,0 +1,7 @@ + + + + + + S + diff --git a/linux/assets/file-icons/icons/files/solidity.svg b/linux/assets/file-icons/icons/files/solidity.svg new file mode 100644 index 000000000..1a9a884d8 --- /dev/null +++ b/linux/assets/file-icons/icons/files/solidity.svg @@ -0,0 +1,7 @@ + + + + + + S + diff --git a/linux/assets/file-icons/icons/files/spreadsheet.svg b/linux/assets/file-icons/icons/files/spreadsheet.svg new file mode 100644 index 000000000..591053b33 --- /dev/null +++ b/linux/assets/file-icons/icons/files/spreadsheet.svg @@ -0,0 +1,7 @@ + + + + + + XLS + diff --git a/linux/assets/file-icons/icons/files/sql.svg b/linux/assets/file-icons/icons/files/sql.svg new file mode 100644 index 000000000..886f5f0e8 --- /dev/null +++ b/linux/assets/file-icons/icons/files/sql.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/storybook.svg b/linux/assets/file-icons/icons/files/storybook.svg new file mode 100644 index 000000000..508b0d4b5 --- /dev/null +++ b/linux/assets/file-icons/icons/files/storybook.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/stylelint.svg b/linux/assets/file-icons/icons/files/stylelint.svg new file mode 100644 index 000000000..ddffe630a --- /dev/null +++ b/linux/assets/file-icons/icons/files/stylelint.svg @@ -0,0 +1,7 @@ + + + + + + SL + diff --git a/linux/assets/file-icons/icons/files/supabase.svg b/linux/assets/file-icons/icons/files/supabase.svg new file mode 100644 index 000000000..7fa1d07fb --- /dev/null +++ b/linux/assets/file-icons/icons/files/supabase.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/svelte.svg b/linux/assets/file-icons/icons/files/svelte.svg new file mode 100644 index 000000000..fd08f3199 --- /dev/null +++ b/linux/assets/file-icons/icons/files/svelte.svg @@ -0,0 +1,7 @@ + + + + + + S + diff --git a/linux/assets/file-icons/icons/files/svg.svg b/linux/assets/file-icons/icons/files/svg.svg new file mode 100644 index 000000000..82fe92ab9 --- /dev/null +++ b/linux/assets/file-icons/icons/files/svg.svg @@ -0,0 +1,7 @@ + + + + + + S + diff --git a/linux/assets/file-icons/icons/files/swc.svg b/linux/assets/file-icons/icons/files/swc.svg new file mode 100644 index 000000000..1a45cc869 --- /dev/null +++ b/linux/assets/file-icons/icons/files/swc.svg @@ -0,0 +1,7 @@ + + + + + + S + diff --git a/linux/assets/file-icons/icons/files/swift.svg b/linux/assets/file-icons/icons/files/swift.svg new file mode 100644 index 000000000..0821c2986 --- /dev/null +++ b/linux/assets/file-icons/icons/files/swift.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/tailwind.svg b/linux/assets/file-icons/icons/files/tailwind.svg new file mode 100644 index 000000000..6142d3e56 --- /dev/null +++ b/linux/assets/file-icons/icons/files/tailwind.svg @@ -0,0 +1,7 @@ + + + + + + T + diff --git a/linux/assets/file-icons/icons/files/tauri.svg b/linux/assets/file-icons/icons/files/tauri.svg new file mode 100644 index 000000000..d98590ba6 --- /dev/null +++ b/linux/assets/file-icons/icons/files/tauri.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/terraform.svg b/linux/assets/file-icons/icons/files/terraform.svg new file mode 100644 index 000000000..f02174afb --- /dev/null +++ b/linux/assets/file-icons/icons/files/terraform.svg @@ -0,0 +1,7 @@ + + + + + + TF + diff --git a/linux/assets/file-icons/icons/files/test.svg b/linux/assets/file-icons/icons/files/test.svg new file mode 100644 index 000000000..640d48ccb --- /dev/null +++ b/linux/assets/file-icons/icons/files/test.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/text.svg b/linux/assets/file-icons/icons/files/text.svg new file mode 100644 index 000000000..ce1557bcb --- /dev/null +++ b/linux/assets/file-icons/icons/files/text.svg @@ -0,0 +1,7 @@ + + + + + + TXT + diff --git a/linux/assets/file-icons/icons/files/toml.svg b/linux/assets/file-icons/icons/files/toml.svg new file mode 100644 index 000000000..690cf1e3b --- /dev/null +++ b/linux/assets/file-icons/icons/files/toml.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/turborepo.svg b/linux/assets/file-icons/icons/files/turborepo.svg new file mode 100644 index 000000000..01c8c146d --- /dev/null +++ b/linux/assets/file-icons/icons/files/turborepo.svg @@ -0,0 +1,7 @@ + + + + + + T + diff --git a/linux/assets/file-icons/icons/files/typescript.svg b/linux/assets/file-icons/icons/files/typescript.svg new file mode 100644 index 000000000..6bb44cce8 --- /dev/null +++ b/linux/assets/file-icons/icons/files/typescript.svg @@ -0,0 +1,7 @@ + + + + + + TS + diff --git a/linux/assets/file-icons/icons/files/vercel-config.svg b/linux/assets/file-icons/icons/files/vercel-config.svg new file mode 100644 index 000000000..520eb3b64 --- /dev/null +++ b/linux/assets/file-icons/icons/files/vercel-config.svg @@ -0,0 +1,7 @@ + + + + + + VC + diff --git a/linux/assets/file-icons/icons/files/vercel.svg b/linux/assets/file-icons/icons/files/vercel.svg new file mode 100644 index 000000000..045058ceb --- /dev/null +++ b/linux/assets/file-icons/icons/files/vercel.svg @@ -0,0 +1,7 @@ + + + + + + V + diff --git a/linux/assets/file-icons/icons/files/video.svg b/linux/assets/file-icons/icons/files/video.svg new file mode 100644 index 000000000..dad213208 --- /dev/null +++ b/linux/assets/file-icons/icons/files/video.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/vite.svg b/linux/assets/file-icons/icons/files/vite.svg new file mode 100644 index 000000000..7101b8e53 --- /dev/null +++ b/linux/assets/file-icons/icons/files/vite.svg @@ -0,0 +1,7 @@ + + + + + + V + diff --git a/linux/assets/file-icons/icons/files/vitest.svg b/linux/assets/file-icons/icons/files/vitest.svg new file mode 100644 index 000000000..3a4824a93 --- /dev/null +++ b/linux/assets/file-icons/icons/files/vitest.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/vue.svg b/linux/assets/file-icons/icons/files/vue.svg new file mode 100644 index 000000000..afddcd1dd --- /dev/null +++ b/linux/assets/file-icons/icons/files/vue.svg @@ -0,0 +1,7 @@ + + + + + + V + diff --git a/linux/assets/file-icons/icons/files/warning.svg b/linux/assets/file-icons/icons/files/warning.svg new file mode 100644 index 000000000..576f04b1d --- /dev/null +++ b/linux/assets/file-icons/icons/files/warning.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/wasm.svg b/linux/assets/file-icons/icons/files/wasm.svg new file mode 100644 index 000000000..ed16980f5 --- /dev/null +++ b/linux/assets/file-icons/icons/files/wasm.svg @@ -0,0 +1,7 @@ + + + + + + W + diff --git a/linux/assets/file-icons/icons/files/webpack.svg b/linux/assets/file-icons/icons/files/webpack.svg new file mode 100644 index 000000000..512e1c057 --- /dev/null +++ b/linux/assets/file-icons/icons/files/webpack.svg @@ -0,0 +1,7 @@ + + + + + + W + diff --git a/linux/assets/file-icons/icons/files/windows.svg b/linux/assets/file-icons/icons/files/windows.svg new file mode 100644 index 000000000..dccc1f9bf --- /dev/null +++ b/linux/assets/file-icons/icons/files/windows.svg @@ -0,0 +1,7 @@ + + + + + + W + diff --git a/linux/assets/file-icons/icons/files/word.svg b/linux/assets/file-icons/icons/files/word.svg new file mode 100644 index 000000000..850083eff --- /dev/null +++ b/linux/assets/file-icons/icons/files/word.svg @@ -0,0 +1,7 @@ + + + + + + DOC + diff --git a/linux/assets/file-icons/icons/files/xcode.svg b/linux/assets/file-icons/icons/files/xcode.svg new file mode 100644 index 000000000..9b63fe1cd --- /dev/null +++ b/linux/assets/file-icons/icons/files/xcode.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/files/xml.svg b/linux/assets/file-icons/icons/files/xml.svg new file mode 100644 index 000000000..7905a6bdf --- /dev/null +++ b/linux/assets/file-icons/icons/files/xml.svg @@ -0,0 +1,7 @@ + + + + + + <> + diff --git a/linux/assets/file-icons/icons/files/yaml.svg b/linux/assets/file-icons/icons/files/yaml.svg new file mode 100644 index 000000000..262b00903 --- /dev/null +++ b/linux/assets/file-icons/icons/files/yaml.svg @@ -0,0 +1,7 @@ + + + + + + Y + diff --git a/linux/assets/file-icons/icons/files/yarn.svg b/linux/assets/file-icons/icons/files/yarn.svg new file mode 100644 index 000000000..9ee784fc6 --- /dev/null +++ b/linux/assets/file-icons/icons/files/yarn.svg @@ -0,0 +1,7 @@ + + + + + + Y + diff --git a/linux/assets/file-icons/icons/files/zig.svg b/linux/assets/file-icons/icons/files/zig.svg new file mode 100644 index 000000000..613c135a4 --- /dev/null +++ b/linux/assets/file-icons/icons/files/zig.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-ai-open.svg b/linux/assets/file-icons/icons/folders/folder-ai-open.svg new file mode 100644 index 000000000..c002af7f1 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-ai-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-ai.svg b/linux/assets/file-icons/icons/folders/folder-ai.svg new file mode 100644 index 000000000..c2a7d27f1 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-ai.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-assets-open.svg b/linux/assets/file-icons/icons/folders/folder-assets-open.svg new file mode 100644 index 000000000..07c3cc1c8 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-assets-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-assets.svg b/linux/assets/file-icons/icons/folders/folder-assets.svg new file mode 100644 index 000000000..a4505fc3f --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-assets.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-build-open.svg b/linux/assets/file-icons/icons/folders/folder-build-open.svg new file mode 100644 index 000000000..677645f72 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-build-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-build.svg b/linux/assets/file-icons/icons/folders/folder-build.svg new file mode 100644 index 000000000..f8ae479c8 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-build.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-cloud-open.svg b/linux/assets/file-icons/icons/folders/folder-cloud-open.svg new file mode 100644 index 000000000..93e712555 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-cloud-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-cloud.svg b/linux/assets/file-icons/icons/folders/folder-cloud.svg new file mode 100644 index 000000000..fd76ff9e7 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-cloud.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-components-open.svg b/linux/assets/file-icons/icons/folders/folder-components-open.svg new file mode 100644 index 000000000..eaf07554e --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-components-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-components.svg b/linux/assets/file-icons/icons/folders/folder-components.svg new file mode 100644 index 000000000..9375049e5 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-components.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-config-open.svg b/linux/assets/file-icons/icons/folders/folder-config-open.svg new file mode 100644 index 000000000..433c9e0e9 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-config-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-config.svg b/linux/assets/file-icons/icons/folders/folder-config.svg new file mode 100644 index 000000000..68e7304c2 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-config.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-database-open.svg b/linux/assets/file-icons/icons/folders/folder-database-open.svg new file mode 100644 index 000000000..dcd2fd81a --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-database-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-database.svg b/linux/assets/file-icons/icons/folders/folder-database.svg new file mode 100644 index 000000000..0ed82fe06 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-database.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-docs-open.svg b/linux/assets/file-icons/icons/folders/folder-docs-open.svg new file mode 100644 index 000000000..15024b33e --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-docs-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-docs.svg b/linux/assets/file-icons/icons/folders/folder-docs.svg new file mode 100644 index 000000000..65acb0762 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-docs.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-extensions-open.svg b/linux/assets/file-icons/icons/folders/folder-extensions-open.svg new file mode 100644 index 000000000..c07f6810c --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-extensions-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-extensions.svg b/linux/assets/file-icons/icons/folders/folder-extensions.svg new file mode 100644 index 000000000..ebcfc4aa0 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-extensions.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-git-open.svg b/linux/assets/file-icons/icons/folders/folder-git-open.svg new file mode 100644 index 000000000..df8eca0c3 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-git-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-git.svg b/linux/assets/file-icons/icons/folders/folder-git.svg new file mode 100644 index 000000000..2e6f82e35 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-git.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-locales-open.svg b/linux/assets/file-icons/icons/folders/folder-locales-open.svg new file mode 100644 index 000000000..ea2f034f8 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-locales-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-locales.svg b/linux/assets/file-icons/icons/folders/folder-locales.svg new file mode 100644 index 000000000..785df402a --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-locales.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-mobile-open.svg b/linux/assets/file-icons/icons/folders/folder-mobile-open.svg new file mode 100644 index 000000000..f1e6fc141 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-mobile-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-mobile.svg b/linux/assets/file-icons/icons/folders/folder-mobile.svg new file mode 100644 index 000000000..6f5dc8122 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-mobile.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-open.svg b/linux/assets/file-icons/icons/folders/folder-open.svg new file mode 100644 index 000000000..44cc2562f --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-open.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-packages-open.svg b/linux/assets/file-icons/icons/folders/folder-packages-open.svg new file mode 100644 index 000000000..372bb7bea --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-packages-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-packages.svg b/linux/assets/file-icons/icons/folders/folder-packages.svg new file mode 100644 index 000000000..c60c0c158 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-packages.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-routes-open.svg b/linux/assets/file-icons/icons/folders/folder-routes-open.svg new file mode 100644 index 000000000..e11a12e42 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-routes-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-routes.svg b/linux/assets/file-icons/icons/folders/folder-routes.svg new file mode 100644 index 000000000..ca413527a --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-routes.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-rust-open.svg b/linux/assets/file-icons/icons/folders/folder-rust-open.svg new file mode 100644 index 000000000..a58f44340 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-rust-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-rust.svg b/linux/assets/file-icons/icons/folders/folder-rust.svg new file mode 100644 index 000000000..b4d7ad475 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-rust.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-scripts-open.svg b/linux/assets/file-icons/icons/folders/folder-scripts-open.svg new file mode 100644 index 000000000..48be14c05 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-scripts-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-scripts.svg b/linux/assets/file-icons/icons/folders/folder-scripts.svg new file mode 100644 index 000000000..af0eae3b8 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-scripts.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-security-open.svg b/linux/assets/file-icons/icons/folders/folder-security-open.svg new file mode 100644 index 000000000..f440c6c94 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-security-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-security.svg b/linux/assets/file-icons/icons/folders/folder-security.svg new file mode 100644 index 000000000..7f48358f2 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-security.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-source-open.svg b/linux/assets/file-icons/icons/folders/folder-source-open.svg new file mode 100644 index 000000000..4a3ed1046 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-source-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-source.svg b/linux/assets/file-icons/icons/folders/folder-source.svg new file mode 100644 index 000000000..88bae64e8 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-source.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-styles-open.svg b/linux/assets/file-icons/icons/folders/folder-styles-open.svg new file mode 100644 index 000000000..54fb0735a --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-styles-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-styles.svg b/linux/assets/file-icons/icons/folders/folder-styles.svg new file mode 100644 index 000000000..3bc647e83 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-styles.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-test-open.svg b/linux/assets/file-icons/icons/folders/folder-test-open.svg new file mode 100644 index 000000000..590550958 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-test-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder-test.svg b/linux/assets/file-icons/icons/folders/folder-test.svg new file mode 100644 index 000000000..1de3ba775 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder-test.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/folders/folder.svg b/linux/assets/file-icons/icons/folders/folder.svg new file mode 100644 index 000000000..6e0864bb3 --- /dev/null +++ b/linux/assets/file-icons/icons/folders/folder.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/assets/file-icons/icons/light/files/adobe.svg b/linux/assets/file-icons/icons/light/files/adobe.svg new file mode 100644 index 000000000..ba716eb30 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/adobe.svg @@ -0,0 +1,7 @@ + + + + + + A + diff --git a/linux/assets/file-icons/icons/light/files/agents.svg b/linux/assets/file-icons/icons/light/files/agents.svg new file mode 100644 index 000000000..e787c0597 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/agents.svg @@ -0,0 +1,7 @@ + + + + + + AI + diff --git a/linux/assets/file-icons/icons/light/files/android.svg b/linux/assets/file-icons/icons/light/files/android.svg new file mode 100644 index 000000000..f30f85a54 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/android.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/angular.svg b/linux/assets/file-icons/icons/light/files/angular.svg new file mode 100644 index 000000000..8d0550c61 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/angular.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/ansible.svg b/linux/assets/file-icons/icons/light/files/ansible.svg new file mode 100644 index 000000000..5b3892010 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/ansible.svg @@ -0,0 +1,7 @@ + + + + + + A + diff --git a/linux/assets/file-icons/icons/light/files/apple.svg b/linux/assets/file-icons/icons/light/files/apple.svg new file mode 100644 index 000000000..acba52121 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/apple.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/archive.svg b/linux/assets/file-icons/icons/light/files/archive.svg new file mode 100644 index 000000000..e952d5813 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/archive.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/arduino.svg b/linux/assets/file-icons/icons/light/files/arduino.svg new file mode 100644 index 000000000..08875c147 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/arduino.svg @@ -0,0 +1,7 @@ + + + + + + A + diff --git a/linux/assets/file-icons/icons/light/files/astro.svg b/linux/assets/file-icons/icons/light/files/astro.svg new file mode 100644 index 000000000..19fd05318 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/astro.svg @@ -0,0 +1,7 @@ + + + + + + A + diff --git a/linux/assets/file-icons/icons/light/files/audio.svg b/linux/assets/file-icons/icons/light/files/audio.svg new file mode 100644 index 000000000..96d9360fd --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/audio.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/authors.svg b/linux/assets/file-icons/icons/light/files/authors.svg new file mode 100644 index 000000000..b408e4921 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/authors.svg @@ -0,0 +1,7 @@ + + + + + + BY + diff --git a/linux/assets/file-icons/icons/light/files/babel.svg b/linux/assets/file-icons/icons/light/files/babel.svg new file mode 100644 index 000000000..c3ce53910 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/babel.svg @@ -0,0 +1,7 @@ + + + + + + B + diff --git a/linux/assets/file-icons/icons/light/files/biome.svg b/linux/assets/file-icons/icons/light/files/biome.svg new file mode 100644 index 000000000..7c80ba33b --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/biome.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/bitbucket.svg b/linux/assets/file-icons/icons/light/files/bitbucket.svg new file mode 100644 index 000000000..7c32ee162 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/bitbucket.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/blender.svg b/linux/assets/file-icons/icons/light/files/blender.svg new file mode 100644 index 000000000..4153cd689 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/blender.svg @@ -0,0 +1,7 @@ + + + + + + B + diff --git a/linux/assets/file-icons/icons/light/files/bun.svg b/linux/assets/file-icons/icons/light/files/bun.svg new file mode 100644 index 000000000..82af13464 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/bun.svg @@ -0,0 +1,7 @@ + + + + + + B + diff --git a/linux/assets/file-icons/icons/light/files/c.svg b/linux/assets/file-icons/icons/light/files/c.svg new file mode 100644 index 000000000..996ae83d7 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/c.svg @@ -0,0 +1,7 @@ + + + + + + C + diff --git a/linux/assets/file-icons/icons/light/files/certificate.svg b/linux/assets/file-icons/icons/light/files/certificate.svg new file mode 100644 index 000000000..1e5abe60f --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/certificate.svg @@ -0,0 +1,7 @@ + + + + + + CRT + diff --git a/linux/assets/file-icons/icons/light/files/changelog.svg b/linux/assets/file-icons/icons/light/files/changelog.svg new file mode 100644 index 000000000..ef01e6c7d --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/changelog.svg @@ -0,0 +1,7 @@ + + + + + + LOG + diff --git a/linux/assets/file-icons/icons/light/files/claude.svg b/linux/assets/file-icons/icons/light/files/claude.svg new file mode 100644 index 000000000..378c9e243 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/claude.svg @@ -0,0 +1,7 @@ + + + + + + AI + diff --git a/linux/assets/file-icons/icons/light/files/cline.svg b/linux/assets/file-icons/icons/light/files/cline.svg new file mode 100644 index 000000000..552832ef4 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/cline.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/clojure.svg b/linux/assets/file-icons/icons/light/files/clojure.svg new file mode 100644 index 000000000..d9521265b --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/clojure.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/cloudflare.svg b/linux/assets/file-icons/icons/light/files/cloudflare.svg new file mode 100644 index 000000000..97d63b54c --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/cloudflare.svg @@ -0,0 +1,7 @@ + + + + + + CF + diff --git a/linux/assets/file-icons/icons/light/files/cmake.svg b/linux/assets/file-icons/icons/light/files/cmake.svg new file mode 100644 index 000000000..cc06991ae --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/cmake.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/codex.svg b/linux/assets/file-icons/icons/light/files/codex.svg new file mode 100644 index 000000000..fd1a89358 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/codex.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/commitlint.svg b/linux/assets/file-icons/icons/light/files/commitlint.svg new file mode 100644 index 000000000..9566b78f6 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/commitlint.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/config.svg b/linux/assets/file-icons/icons/light/files/config.svg new file mode 100644 index 000000000..ab0ecc327 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/config.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/copilot.svg b/linux/assets/file-icons/icons/light/files/copilot.svg new file mode 100644 index 000000000..a7ef00343 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/copilot.svg @@ -0,0 +1,7 @@ + + + + + + AI + diff --git a/linux/assets/file-icons/icons/light/files/cpp.svg b/linux/assets/file-icons/icons/light/files/cpp.svg new file mode 100644 index 000000000..b71ea0cad --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/cpp.svg @@ -0,0 +1,7 @@ + + + + + + C+ + diff --git a/linux/assets/file-icons/icons/light/files/csharp.svg b/linux/assets/file-icons/icons/light/files/csharp.svg new file mode 100644 index 000000000..2f49b3ea6 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/csharp.svg @@ -0,0 +1,7 @@ + + + + + + C# + diff --git a/linux/assets/file-icons/icons/light/files/cspell.svg b/linux/assets/file-icons/icons/light/files/cspell.svg new file mode 100644 index 000000000..1d7e55469 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/cspell.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/css.svg b/linux/assets/file-icons/icons/light/files/css.svg new file mode 100644 index 000000000..375de6653 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/css.svg @@ -0,0 +1,7 @@ + + + + + + # + diff --git a/linux/assets/file-icons/icons/light/files/csv.svg b/linux/assets/file-icons/icons/light/files/csv.svg new file mode 100644 index 000000000..7ed227c51 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/csv.svg @@ -0,0 +1,7 @@ + + + + + + CSV + diff --git a/linux/assets/file-icons/icons/light/files/cursor.svg b/linux/assets/file-icons/icons/light/files/cursor.svg new file mode 100644 index 000000000..df8003f2e --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/cursor.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/cypress.svg b/linux/assets/file-icons/icons/light/files/cypress.svg new file mode 100644 index 000000000..34e832521 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/cypress.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/dart.svg b/linux/assets/file-icons/icons/light/files/dart.svg new file mode 100644 index 000000000..941437104 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/dart.svg @@ -0,0 +1,7 @@ + + + + + + D + diff --git a/linux/assets/file-icons/icons/light/files/database.svg b/linux/assets/file-icons/icons/light/files/database.svg new file mode 100644 index 000000000..2040e77f0 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/database.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/deno.svg b/linux/assets/file-icons/icons/light/files/deno.svg new file mode 100644 index 000000000..301830fce --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/deno.svg @@ -0,0 +1,7 @@ + + + + + + D + diff --git a/linux/assets/file-icons/icons/light/files/dependabot.svg b/linux/assets/file-icons/icons/light/files/dependabot.svg new file mode 100644 index 000000000..38a401d0e --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/dependabot.svg @@ -0,0 +1,7 @@ + + + + + + D + diff --git a/linux/assets/file-icons/icons/light/files/devcontainer.svg b/linux/assets/file-icons/icons/light/files/devcontainer.svg new file mode 100644 index 000000000..9f5dd2277 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/devcontainer.svg @@ -0,0 +1,7 @@ + + + + + + DC + diff --git a/linux/assets/file-icons/icons/light/files/diff.svg b/linux/assets/file-icons/icons/light/files/diff.svg new file mode 100644 index 000000000..6f52d90e4 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/diff.svg @@ -0,0 +1,7 @@ + + + + + + +- + diff --git a/linux/assets/file-icons/icons/light/files/django.svg b/linux/assets/file-icons/icons/light/files/django.svg new file mode 100644 index 000000000..4ec56bcfb --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/django.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/docker-compose.svg b/linux/assets/file-icons/icons/light/files/docker-compose.svg new file mode 100644 index 000000000..2d4c58925 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/docker-compose.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/docker.svg b/linux/assets/file-icons/icons/light/files/docker.svg new file mode 100644 index 000000000..867766922 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/docker.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/document.svg b/linux/assets/file-icons/icons/light/files/document.svg new file mode 100644 index 000000000..f5ad58865 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/document.svg @@ -0,0 +1,7 @@ + + + + + + DOC + diff --git a/linux/assets/file-icons/icons/light/files/docusaurus.svg b/linux/assets/file-icons/icons/light/files/docusaurus.svg new file mode 100644 index 000000000..ed0fe2214 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/docusaurus.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/drawio.svg b/linux/assets/file-icons/icons/light/files/drawio.svg new file mode 100644 index 000000000..807b2aa58 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/drawio.svg @@ -0,0 +1,7 @@ + + + + + + D + diff --git a/linux/assets/file-icons/icons/light/files/drizzle.svg b/linux/assets/file-icons/icons/light/files/drizzle.svg new file mode 100644 index 000000000..29a554e29 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/drizzle.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/editorconfig.svg b/linux/assets/file-icons/icons/light/files/editorconfig.svg new file mode 100644 index 000000000..2532cefd4 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/editorconfig.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/electron.svg b/linux/assets/file-icons/icons/light/files/electron.svg new file mode 100644 index 000000000..066bddd15 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/electron.svg @@ -0,0 +1,7 @@ + + + + + + E + diff --git a/linux/assets/file-icons/icons/light/files/elixir.svg b/linux/assets/file-icons/icons/light/files/elixir.svg new file mode 100644 index 000000000..70e88edc3 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/elixir.svg @@ -0,0 +1,7 @@ + + + + + + EX + diff --git a/linux/assets/file-icons/icons/light/files/env.svg b/linux/assets/file-icons/icons/light/files/env.svg new file mode 100644 index 000000000..739868f76 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/env.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/erlang.svg b/linux/assets/file-icons/icons/light/files/erlang.svg new file mode 100644 index 000000000..6dd8f7210 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/erlang.svg @@ -0,0 +1,7 @@ + + + + + + ER + diff --git a/linux/assets/file-icons/icons/light/files/error.svg b/linux/assets/file-icons/icons/light/files/error.svg new file mode 100644 index 000000000..6777185ab --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/error.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/eslint.svg b/linux/assets/file-icons/icons/light/files/eslint.svg new file mode 100644 index 000000000..31cdc71f1 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/eslint.svg @@ -0,0 +1,7 @@ + + + + + + E + diff --git a/linux/assets/file-icons/icons/light/files/excalidraw.svg b/linux/assets/file-icons/icons/light/files/excalidraw.svg new file mode 100644 index 000000000..b3ab80975 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/excalidraw.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/fastapi.svg b/linux/assets/file-icons/icons/light/files/fastapi.svg new file mode 100644 index 000000000..cf4cb4448 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/fastapi.svg @@ -0,0 +1,7 @@ + + + + + + FA + diff --git a/linux/assets/file-icons/icons/light/files/figma.svg b/linux/assets/file-icons/icons/light/files/figma.svg new file mode 100644 index 000000000..9d3f20dfc --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/figma.svg @@ -0,0 +1,7 @@ + + + + + + F + diff --git a/linux/assets/file-icons/icons/light/files/file.svg b/linux/assets/file-icons/icons/light/files/file.svg new file mode 100644 index 000000000..ecd9c5967 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/file.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/firebase.svg b/linux/assets/file-icons/icons/light/files/firebase.svg new file mode 100644 index 000000000..160cd7a47 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/firebase.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/flask.svg b/linux/assets/file-icons/icons/light/files/flask.svg new file mode 100644 index 000000000..f91b6630f --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/flask.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/font.svg b/linux/assets/file-icons/icons/light/files/font.svg new file mode 100644 index 000000000..232442e9e --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/font.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/gatsby.svg b/linux/assets/file-icons/icons/light/files/gatsby.svg new file mode 100644 index 000000000..f0ccaaf81 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/gatsby.svg @@ -0,0 +1,7 @@ + + + + + + G + diff --git a/linux/assets/file-icons/icons/light/files/gemini.svg b/linux/assets/file-icons/icons/light/files/gemini.svg new file mode 100644 index 000000000..aa1b35013 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/gemini.svg @@ -0,0 +1,7 @@ + + + + + + G + diff --git a/linux/assets/file-icons/icons/light/files/git.svg b/linux/assets/file-icons/icons/light/files/git.svg new file mode 100644 index 000000000..60e5c97fe --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/git.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/github-actions.svg b/linux/assets/file-icons/icons/light/files/github-actions.svg new file mode 100644 index 000000000..1a8595f25 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/github-actions.svg @@ -0,0 +1,7 @@ + + + + + + GH + diff --git a/linux/assets/file-icons/icons/light/files/github.svg b/linux/assets/file-icons/icons/light/files/github.svg new file mode 100644 index 000000000..ac94fb622 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/github.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/gitlab.svg b/linux/assets/file-icons/icons/light/files/gitlab.svg new file mode 100644 index 000000000..eb6eb35ed --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/gitlab.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/go.svg b/linux/assets/file-icons/icons/light/files/go.svg new file mode 100644 index 000000000..73f3b11a9 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/go.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/gradle.svg b/linux/assets/file-icons/icons/light/files/gradle.svg new file mode 100644 index 000000000..456555037 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/gradle.svg @@ -0,0 +1,7 @@ + + + + + + G + diff --git a/linux/assets/file-icons/icons/light/files/graphql-schema.svg b/linux/assets/file-icons/icons/light/files/graphql-schema.svg new file mode 100644 index 000000000..d50cccb17 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/graphql-schema.svg @@ -0,0 +1,7 @@ + + + + + + GS + diff --git a/linux/assets/file-icons/icons/light/files/graphql.svg b/linux/assets/file-icons/icons/light/files/graphql.svg new file mode 100644 index 000000000..c63624564 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/graphql.svg @@ -0,0 +1,7 @@ + + + + + + G + diff --git a/linux/assets/file-icons/icons/light/files/haskell.svg b/linux/assets/file-icons/icons/light/files/haskell.svg new file mode 100644 index 000000000..24db0ded5 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/haskell.svg @@ -0,0 +1,7 @@ + + + + + + HS + diff --git a/linux/assets/file-icons/icons/light/files/helm.svg b/linux/assets/file-icons/icons/light/files/helm.svg new file mode 100644 index 000000000..f1c3bb82a --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/helm.svg @@ -0,0 +1,7 @@ + + + + + + H + diff --git a/linux/assets/file-icons/icons/light/files/html.svg b/linux/assets/file-icons/icons/light/files/html.svg new file mode 100644 index 000000000..d1e8d808b --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/html.svg @@ -0,0 +1,7 @@ + + + + + + H + diff --git a/linux/assets/file-icons/icons/light/files/http.svg b/linux/assets/file-icons/icons/light/files/http.svg new file mode 100644 index 000000000..977a063c4 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/http.svg @@ -0,0 +1,7 @@ + + + + + + HT + diff --git a/linux/assets/file-icons/icons/light/files/hurl.svg b/linux/assets/file-icons/icons/light/files/hurl.svg new file mode 100644 index 000000000..1ce145c73 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/hurl.svg @@ -0,0 +1,7 @@ + + + + + + HU + diff --git a/linux/assets/file-icons/icons/light/files/image.svg b/linux/assets/file-icons/icons/light/files/image.svg new file mode 100644 index 000000000..2bfa5eb89 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/image.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/index-js.svg b/linux/assets/file-icons/icons/light/files/index-js.svg new file mode 100644 index 000000000..e676bf67e --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/index-js.svg @@ -0,0 +1,7 @@ + + + + + + IDX + diff --git a/linux/assets/file-icons/icons/light/files/index-ts.svg b/linux/assets/file-icons/icons/light/files/index-ts.svg new file mode 100644 index 000000000..9c62ff182 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/index-ts.svg @@ -0,0 +1,7 @@ + + + + + + IDX + diff --git a/linux/assets/file-icons/icons/light/files/java.svg b/linux/assets/file-icons/icons/light/files/java.svg new file mode 100644 index 000000000..948e01ddd --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/java.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/javascript.svg b/linux/assets/file-icons/icons/light/files/javascript.svg new file mode 100644 index 000000000..0e36f8cfe --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/javascript.svg @@ -0,0 +1,7 @@ + + + + + + JS + diff --git a/linux/assets/file-icons/icons/light/files/jenkins.svg b/linux/assets/file-icons/icons/light/files/jenkins.svg new file mode 100644 index 000000000..cc549fc11 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/jenkins.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/jest.svg b/linux/assets/file-icons/icons/light/files/jest.svg new file mode 100644 index 000000000..97aef2580 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/jest.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/jsconfig.svg b/linux/assets/file-icons/icons/light/files/jsconfig.svg new file mode 100644 index 000000000..2af749c4d --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/jsconfig.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/json.svg b/linux/assets/file-icons/icons/light/files/json.svg new file mode 100644 index 000000000..9bd3910d1 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/json.svg @@ -0,0 +1,7 @@ + + + + + + {} + diff --git a/linux/assets/file-icons/icons/light/files/julia.svg b/linux/assets/file-icons/icons/light/files/julia.svg new file mode 100644 index 000000000..1400b22df --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/julia.svg @@ -0,0 +1,7 @@ + + + + + + JL + diff --git a/linux/assets/file-icons/icons/light/files/key.svg b/linux/assets/file-icons/icons/light/files/key.svg new file mode 100644 index 000000000..48a792e87 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/key.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/kotlin.svg b/linux/assets/file-icons/icons/light/files/kotlin.svg new file mode 100644 index 000000000..8d0fd4dd0 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/kotlin.svg @@ -0,0 +1,7 @@ + + + + + + K + diff --git a/linux/assets/file-icons/icons/light/files/kubernetes.svg b/linux/assets/file-icons/icons/light/files/kubernetes.svg new file mode 100644 index 000000000..6837d0a06 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/kubernetes.svg @@ -0,0 +1,7 @@ + + + + + + K8 + diff --git a/linux/assets/file-icons/icons/light/files/laravel.svg b/linux/assets/file-icons/icons/light/files/laravel.svg new file mode 100644 index 000000000..8c2f3393e --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/laravel.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/layout.svg b/linux/assets/file-icons/icons/light/files/layout.svg new file mode 100644 index 000000000..70c99e58b --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/layout.svg @@ -0,0 +1,7 @@ + + + + + + L + diff --git a/linux/assets/file-icons/icons/light/files/license.svg b/linux/assets/file-icons/icons/light/files/license.svg new file mode 100644 index 000000000..0aa5fbce7 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/license.svg @@ -0,0 +1,7 @@ + + + + + + LIC + diff --git a/linux/assets/file-icons/icons/light/files/lintstaged.svg b/linux/assets/file-icons/icons/light/files/lintstaged.svg new file mode 100644 index 000000000..0caeb0f9f --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/lintstaged.svg @@ -0,0 +1,7 @@ + + + + + + LS + diff --git a/linux/assets/file-icons/icons/light/files/linux.svg b/linux/assets/file-icons/icons/light/files/linux.svg new file mode 100644 index 000000000..b43691eb4 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/linux.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/lit.svg b/linux/assets/file-icons/icons/light/files/lit.svg new file mode 100644 index 000000000..76e58c410 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/lit.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/lithe.svg b/linux/assets/file-icons/icons/light/files/lithe.svg new file mode 100644 index 000000000..dc5523ab4 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/lithe.svg @@ -0,0 +1,7 @@ + + + + + + L + diff --git a/linux/assets/file-icons/icons/light/files/loading.svg b/linux/assets/file-icons/icons/light/files/loading.svg new file mode 100644 index 000000000..cb632d555 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/loading.svg @@ -0,0 +1,7 @@ + + + + + + ... + diff --git a/linux/assets/file-icons/icons/light/files/lock.svg b/linux/assets/file-icons/icons/light/files/lock.svg new file mode 100644 index 000000000..8abab2a1d --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/lock.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/log.svg b/linux/assets/file-icons/icons/light/files/log.svg new file mode 100644 index 000000000..f06880824 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/log.svg @@ -0,0 +1,7 @@ + + + + + + LOG + diff --git a/linux/assets/file-icons/icons/light/files/lua.svg b/linux/assets/file-icons/icons/light/files/lua.svg new file mode 100644 index 000000000..3fc5f6d9b --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/lua.svg @@ -0,0 +1,7 @@ + + + + + + L + diff --git a/linux/assets/file-icons/icons/light/files/makefile.svg b/linux/assets/file-icons/icons/light/files/makefile.svg new file mode 100644 index 000000000..e6ca2fb8b --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/makefile.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/markdown.svg b/linux/assets/file-icons/icons/light/files/markdown.svg new file mode 100644 index 000000000..0a57e1538 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/markdown.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/markdownlint.svg b/linux/assets/file-icons/icons/light/files/markdownlint.svg new file mode 100644 index 000000000..3aabe5da5 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/markdownlint.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/maven.svg b/linux/assets/file-icons/icons/light/files/maven.svg new file mode 100644 index 000000000..b5d240b51 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/maven.svg @@ -0,0 +1,7 @@ + + + + + + M + diff --git a/linux/assets/file-icons/icons/light/files/mcp.svg b/linux/assets/file-icons/icons/light/files/mcp.svg new file mode 100644 index 000000000..9480e0499 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/mcp.svg @@ -0,0 +1,7 @@ + + + + + + M + diff --git a/linux/assets/file-icons/icons/light/files/mermaid.svg b/linux/assets/file-icons/icons/light/files/mermaid.svg new file mode 100644 index 000000000..4d45d6f3c --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/mermaid.svg @@ -0,0 +1,7 @@ + + + + + + MM + diff --git a/linux/assets/file-icons/icons/light/files/mongo.svg b/linux/assets/file-icons/icons/light/files/mongo.svg new file mode 100644 index 000000000..18986c84c --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/mongo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/netlify.svg b/linux/assets/file-icons/icons/light/files/netlify.svg new file mode 100644 index 000000000..941d09eb5 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/netlify.svg @@ -0,0 +1,7 @@ + + + + + + N + diff --git a/linux/assets/file-icons/icons/light/files/next.svg b/linux/assets/file-icons/icons/light/files/next.svg new file mode 100644 index 000000000..6b62626f4 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/next.svg @@ -0,0 +1,7 @@ + + + + + + N + diff --git a/linux/assets/file-icons/icons/light/files/nginx.svg b/linux/assets/file-icons/icons/light/files/nginx.svg new file mode 100644 index 000000000..e56efaeea --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/nginx.svg @@ -0,0 +1,7 @@ + + + + + + N + diff --git a/linux/assets/file-icons/icons/light/files/nim.svg b/linux/assets/file-icons/icons/light/files/nim.svg new file mode 100644 index 000000000..8fb2d6ee4 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/nim.svg @@ -0,0 +1,7 @@ + + + + + + N + diff --git a/linux/assets/file-icons/icons/light/files/nix.svg b/linux/assets/file-icons/icons/light/files/nix.svg new file mode 100644 index 000000000..5e4119b95 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/nix.svg @@ -0,0 +1,7 @@ + + + + + + N + diff --git a/linux/assets/file-icons/icons/light/files/node.svg b/linux/assets/file-icons/icons/light/files/node.svg new file mode 100644 index 000000000..6bc1f64b2 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/node.svg @@ -0,0 +1,7 @@ + + + + + + N + diff --git a/linux/assets/file-icons/icons/light/files/not-found.svg b/linux/assets/file-icons/icons/light/files/not-found.svg new file mode 100644 index 000000000..0062bda71 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/not-found.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/notebook.svg b/linux/assets/file-icons/icons/light/files/notebook.svg new file mode 100644 index 000000000..a9c69987e --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/notebook.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/npm.svg b/linux/assets/file-icons/icons/light/files/npm.svg new file mode 100644 index 000000000..bfb1828ca --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/npm.svg @@ -0,0 +1,7 @@ + + + + + + N + diff --git a/linux/assets/file-icons/icons/light/files/nuxt.svg b/linux/assets/file-icons/icons/light/files/nuxt.svg new file mode 100644 index 000000000..d169e54d3 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/nuxt.svg @@ -0,0 +1,7 @@ + + + + + + N + diff --git a/linux/assets/file-icons/icons/light/files/nx.svg b/linux/assets/file-icons/icons/light/files/nx.svg new file mode 100644 index 000000000..69e48c5c8 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/nx.svg @@ -0,0 +1,7 @@ + + + + + + NX + diff --git a/linux/assets/file-icons/icons/light/files/ocaml.svg b/linux/assets/file-icons/icons/light/files/ocaml.svg new file mode 100644 index 000000000..57d304597 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/ocaml.svg @@ -0,0 +1,7 @@ + + + + + + ML + diff --git a/linux/assets/file-icons/icons/light/files/package.svg b/linux/assets/file-icons/icons/light/files/package.svg new file mode 100644 index 000000000..21909634f --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/package.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/page.svg b/linux/assets/file-icons/icons/light/files/page.svg new file mode 100644 index 000000000..6832c7701 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/page.svg @@ -0,0 +1,7 @@ + + + + + + P + diff --git a/linux/assets/file-icons/icons/light/files/patch.svg b/linux/assets/file-icons/icons/light/files/patch.svg new file mode 100644 index 000000000..5c1c448a2 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/patch.svg @@ -0,0 +1,7 @@ + + + + + + +- + diff --git a/linux/assets/file-icons/icons/light/files/pdf.svg b/linux/assets/file-icons/icons/light/files/pdf.svg new file mode 100644 index 000000000..e79c22e9b --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/pdf.svg @@ -0,0 +1,7 @@ + + + + + + PDF + diff --git a/linux/assets/file-icons/icons/light/files/perl.svg b/linux/assets/file-icons/icons/light/files/perl.svg new file mode 100644 index 000000000..a41c0cacd --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/perl.svg @@ -0,0 +1,7 @@ + + + + + + PL + diff --git a/linux/assets/file-icons/icons/light/files/php.svg b/linux/assets/file-icons/icons/light/files/php.svg new file mode 100644 index 000000000..d4c2edfe9 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/php.svg @@ -0,0 +1,7 @@ + + + + + + P + diff --git a/linux/assets/file-icons/icons/light/files/playwright.svg b/linux/assets/file-icons/icons/light/files/playwright.svg new file mode 100644 index 000000000..0317c2886 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/playwright.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/pnpm.svg b/linux/assets/file-icons/icons/light/files/pnpm.svg new file mode 100644 index 000000000..03d12c964 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/pnpm.svg @@ -0,0 +1,7 @@ + + + + + + P + diff --git a/linux/assets/file-icons/icons/light/files/postgres.svg b/linux/assets/file-icons/icons/light/files/postgres.svg new file mode 100644 index 000000000..368daa77f --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/postgres.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/powerpoint.svg b/linux/assets/file-icons/icons/light/files/powerpoint.svg new file mode 100644 index 000000000..2e8a55782 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/powerpoint.svg @@ -0,0 +1,7 @@ + + + + + + PPT + diff --git a/linux/assets/file-icons/icons/light/files/prettier.svg b/linux/assets/file-icons/icons/light/files/prettier.svg new file mode 100644 index 000000000..8f24fa2de --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/prettier.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/prisma.svg b/linux/assets/file-icons/icons/light/files/prisma.svg new file mode 100644 index 000000000..3a575c6c6 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/prisma.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/proto.svg b/linux/assets/file-icons/icons/light/files/proto.svg new file mode 100644 index 000000000..6551ba995 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/proto.svg @@ -0,0 +1,7 @@ + + + + + + P + diff --git a/linux/assets/file-icons/icons/light/files/python.svg b/linux/assets/file-icons/icons/light/files/python.svg new file mode 100644 index 000000000..72f09abe9 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/python.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/qwik.svg b/linux/assets/file-icons/icons/light/files/qwik.svg new file mode 100644 index 000000000..5e09fe0eb --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/qwik.svg @@ -0,0 +1,7 @@ + + + + + + Q + diff --git a/linux/assets/file-icons/icons/light/files/r.svg b/linux/assets/file-icons/icons/light/files/r.svg new file mode 100644 index 000000000..b496fd493 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/r.svg @@ -0,0 +1,7 @@ + + + + + + R + diff --git a/linux/assets/file-icons/icons/light/files/react.svg b/linux/assets/file-icons/icons/light/files/react.svg new file mode 100644 index 000000000..aac59a6c4 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/react.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/redis.svg b/linux/assets/file-icons/icons/light/files/redis.svg new file mode 100644 index 000000000..7cd4c2f24 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/redis.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/remix.svg b/linux/assets/file-icons/icons/light/files/remix.svg new file mode 100644 index 000000000..975fda0a2 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/remix.svg @@ -0,0 +1,7 @@ + + + + + + R + diff --git a/linux/assets/file-icons/icons/light/files/renovate.svg b/linux/assets/file-icons/icons/light/files/renovate.svg new file mode 100644 index 000000000..a95016811 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/renovate.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/rescript.svg b/linux/assets/file-icons/icons/light/files/rescript.svg new file mode 100644 index 000000000..eb898c1d1 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/rescript.svg @@ -0,0 +1,7 @@ + + + + + + RE + diff --git a/linux/assets/file-icons/icons/light/files/rollup.svg b/linux/assets/file-icons/icons/light/files/rollup.svg new file mode 100644 index 000000000..8c846b294 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/rollup.svg @@ -0,0 +1,7 @@ + + + + + + R + diff --git a/linux/assets/file-icons/icons/light/files/route.svg b/linux/assets/file-icons/icons/light/files/route.svg new file mode 100644 index 000000000..a1db3d411 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/route.svg @@ -0,0 +1,7 @@ + + + + + + RT + diff --git a/linux/assets/file-icons/icons/light/files/rspack.svg b/linux/assets/file-icons/icons/light/files/rspack.svg new file mode 100644 index 000000000..a33654646 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/rspack.svg @@ -0,0 +1,7 @@ + + + + + + R + diff --git a/linux/assets/file-icons/icons/light/files/ruby.svg b/linux/assets/file-icons/icons/light/files/ruby.svg new file mode 100644 index 000000000..d245a2255 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/ruby.svg @@ -0,0 +1,7 @@ + + + + + + RB + diff --git a/linux/assets/file-icons/icons/light/files/rust.svg b/linux/assets/file-icons/icons/light/files/rust.svg new file mode 100644 index 000000000..519da0c0d --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/rust.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/sass.svg b/linux/assets/file-icons/icons/light/files/sass.svg new file mode 100644 index 000000000..753e00605 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/sass.svg @@ -0,0 +1,7 @@ + + + + + + S + diff --git a/linux/assets/file-icons/icons/light/files/scala.svg b/linux/assets/file-icons/icons/light/files/scala.svg new file mode 100644 index 000000000..f90003510 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/scala.svg @@ -0,0 +1,7 @@ + + + + + + S + diff --git a/linux/assets/file-icons/icons/light/files/security.svg b/linux/assets/file-icons/icons/light/files/security.svg new file mode 100644 index 000000000..349ea6213 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/security.svg @@ -0,0 +1,7 @@ + + + + + + SEC + diff --git a/linux/assets/file-icons/icons/light/files/shell.svg b/linux/assets/file-icons/icons/light/files/shell.svg new file mode 100644 index 000000000..90d4ad418 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/shell.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/sketch.svg b/linux/assets/file-icons/icons/light/files/sketch.svg new file mode 100644 index 000000000..740150901 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/sketch.svg @@ -0,0 +1,7 @@ + + + + + + S + diff --git a/linux/assets/file-icons/icons/light/files/solid.svg b/linux/assets/file-icons/icons/light/files/solid.svg new file mode 100644 index 000000000..e0d41430a --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/solid.svg @@ -0,0 +1,7 @@ + + + + + + S + diff --git a/linux/assets/file-icons/icons/light/files/solidity.svg b/linux/assets/file-icons/icons/light/files/solidity.svg new file mode 100644 index 000000000..646c19201 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/solidity.svg @@ -0,0 +1,7 @@ + + + + + + S + diff --git a/linux/assets/file-icons/icons/light/files/spreadsheet.svg b/linux/assets/file-icons/icons/light/files/spreadsheet.svg new file mode 100644 index 000000000..72a4c1e5e --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/spreadsheet.svg @@ -0,0 +1,7 @@ + + + + + + XLS + diff --git a/linux/assets/file-icons/icons/light/files/sql.svg b/linux/assets/file-icons/icons/light/files/sql.svg new file mode 100644 index 000000000..dbaad7bf8 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/sql.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/storybook.svg b/linux/assets/file-icons/icons/light/files/storybook.svg new file mode 100644 index 000000000..23f0d98b4 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/storybook.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/stylelint.svg b/linux/assets/file-icons/icons/light/files/stylelint.svg new file mode 100644 index 000000000..bfca7276e --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/stylelint.svg @@ -0,0 +1,7 @@ + + + + + + SL + diff --git a/linux/assets/file-icons/icons/light/files/supabase.svg b/linux/assets/file-icons/icons/light/files/supabase.svg new file mode 100644 index 000000000..4d37adde0 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/supabase.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/svelte.svg b/linux/assets/file-icons/icons/light/files/svelte.svg new file mode 100644 index 000000000..2ca6817c6 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/svelte.svg @@ -0,0 +1,7 @@ + + + + + + S + diff --git a/linux/assets/file-icons/icons/light/files/svg.svg b/linux/assets/file-icons/icons/light/files/svg.svg new file mode 100644 index 000000000..360f68910 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/svg.svg @@ -0,0 +1,7 @@ + + + + + + S + diff --git a/linux/assets/file-icons/icons/light/files/swc.svg b/linux/assets/file-icons/icons/light/files/swc.svg new file mode 100644 index 000000000..79eb8ecfb --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/swc.svg @@ -0,0 +1,7 @@ + + + + + + S + diff --git a/linux/assets/file-icons/icons/light/files/swift.svg b/linux/assets/file-icons/icons/light/files/swift.svg new file mode 100644 index 000000000..80d17c02c --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/swift.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/tailwind.svg b/linux/assets/file-icons/icons/light/files/tailwind.svg new file mode 100644 index 000000000..c6b21b1d7 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/tailwind.svg @@ -0,0 +1,7 @@ + + + + + + T + diff --git a/linux/assets/file-icons/icons/light/files/tauri.svg b/linux/assets/file-icons/icons/light/files/tauri.svg new file mode 100644 index 000000000..9a7394cb7 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/tauri.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/terraform.svg b/linux/assets/file-icons/icons/light/files/terraform.svg new file mode 100644 index 000000000..6318401f8 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/terraform.svg @@ -0,0 +1,7 @@ + + + + + + TF + diff --git a/linux/assets/file-icons/icons/light/files/test.svg b/linux/assets/file-icons/icons/light/files/test.svg new file mode 100644 index 000000000..fb610d1ea --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/test.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/text.svg b/linux/assets/file-icons/icons/light/files/text.svg new file mode 100644 index 000000000..7755a7710 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/text.svg @@ -0,0 +1,7 @@ + + + + + + TXT + diff --git a/linux/assets/file-icons/icons/light/files/toml.svg b/linux/assets/file-icons/icons/light/files/toml.svg new file mode 100644 index 000000000..96fb34215 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/toml.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/turborepo.svg b/linux/assets/file-icons/icons/light/files/turborepo.svg new file mode 100644 index 000000000..6ef2337d7 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/turborepo.svg @@ -0,0 +1,7 @@ + + + + + + T + diff --git a/linux/assets/file-icons/icons/light/files/typescript.svg b/linux/assets/file-icons/icons/light/files/typescript.svg new file mode 100644 index 000000000..d52a028e3 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/typescript.svg @@ -0,0 +1,7 @@ + + + + + + TS + diff --git a/linux/assets/file-icons/icons/light/files/vercel-config.svg b/linux/assets/file-icons/icons/light/files/vercel-config.svg new file mode 100644 index 000000000..6c5eb2b1f --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/vercel-config.svg @@ -0,0 +1,7 @@ + + + + + + VC + diff --git a/linux/assets/file-icons/icons/light/files/vercel.svg b/linux/assets/file-icons/icons/light/files/vercel.svg new file mode 100644 index 000000000..64752bab9 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/vercel.svg @@ -0,0 +1,7 @@ + + + + + + V + diff --git a/linux/assets/file-icons/icons/light/files/video.svg b/linux/assets/file-icons/icons/light/files/video.svg new file mode 100644 index 000000000..86cd60bb3 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/video.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/vite.svg b/linux/assets/file-icons/icons/light/files/vite.svg new file mode 100644 index 000000000..3a4e803c6 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/vite.svg @@ -0,0 +1,7 @@ + + + + + + V + diff --git a/linux/assets/file-icons/icons/light/files/vitest.svg b/linux/assets/file-icons/icons/light/files/vitest.svg new file mode 100644 index 000000000..cc4872157 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/vitest.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/vue.svg b/linux/assets/file-icons/icons/light/files/vue.svg new file mode 100644 index 000000000..644da25b9 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/vue.svg @@ -0,0 +1,7 @@ + + + + + + V + diff --git a/linux/assets/file-icons/icons/light/files/warning.svg b/linux/assets/file-icons/icons/light/files/warning.svg new file mode 100644 index 000000000..9e9cb6330 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/warning.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/wasm.svg b/linux/assets/file-icons/icons/light/files/wasm.svg new file mode 100644 index 000000000..3a63d47a9 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/wasm.svg @@ -0,0 +1,7 @@ + + + + + + W + diff --git a/linux/assets/file-icons/icons/light/files/webpack.svg b/linux/assets/file-icons/icons/light/files/webpack.svg new file mode 100644 index 000000000..14cb684b2 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/webpack.svg @@ -0,0 +1,7 @@ + + + + + + W + diff --git a/linux/assets/file-icons/icons/light/files/windows.svg b/linux/assets/file-icons/icons/light/files/windows.svg new file mode 100644 index 000000000..92afb257d --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/windows.svg @@ -0,0 +1,7 @@ + + + + + + W + diff --git a/linux/assets/file-icons/icons/light/files/word.svg b/linux/assets/file-icons/icons/light/files/word.svg new file mode 100644 index 000000000..7f54bb1fc --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/word.svg @@ -0,0 +1,7 @@ + + + + + + DOC + diff --git a/linux/assets/file-icons/icons/light/files/xcode.svg b/linux/assets/file-icons/icons/light/files/xcode.svg new file mode 100644 index 000000000..51a37e666 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/xcode.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/files/xml.svg b/linux/assets/file-icons/icons/light/files/xml.svg new file mode 100644 index 000000000..f1c38169f --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/xml.svg @@ -0,0 +1,7 @@ + + + + + + <> + diff --git a/linux/assets/file-icons/icons/light/files/yaml.svg b/linux/assets/file-icons/icons/light/files/yaml.svg new file mode 100644 index 000000000..8465e7e70 --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/yaml.svg @@ -0,0 +1,7 @@ + + + + + + Y + diff --git a/linux/assets/file-icons/icons/light/files/yarn.svg b/linux/assets/file-icons/icons/light/files/yarn.svg new file mode 100644 index 000000000..05219d53a --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/yarn.svg @@ -0,0 +1,7 @@ + + + + + + Y + diff --git a/linux/assets/file-icons/icons/light/files/zig.svg b/linux/assets/file-icons/icons/light/files/zig.svg new file mode 100644 index 000000000..c529bd03a --- /dev/null +++ b/linux/assets/file-icons/icons/light/files/zig.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-ai-open.svg b/linux/assets/file-icons/icons/light/folders/folder-ai-open.svg new file mode 100644 index 000000000..5e951884f --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-ai-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-ai.svg b/linux/assets/file-icons/icons/light/folders/folder-ai.svg new file mode 100644 index 000000000..44f8c72aa --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-ai.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-assets-open.svg b/linux/assets/file-icons/icons/light/folders/folder-assets-open.svg new file mode 100644 index 000000000..88538645e --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-assets-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-assets.svg b/linux/assets/file-icons/icons/light/folders/folder-assets.svg new file mode 100644 index 000000000..94d68d0b7 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-assets.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-build-open.svg b/linux/assets/file-icons/icons/light/folders/folder-build-open.svg new file mode 100644 index 000000000..690053176 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-build-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-build.svg b/linux/assets/file-icons/icons/light/folders/folder-build.svg new file mode 100644 index 000000000..8e17e72f2 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-build.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-cloud-open.svg b/linux/assets/file-icons/icons/light/folders/folder-cloud-open.svg new file mode 100644 index 000000000..52f4a926a --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-cloud-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-cloud.svg b/linux/assets/file-icons/icons/light/folders/folder-cloud.svg new file mode 100644 index 000000000..c0bce4b87 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-cloud.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-components-open.svg b/linux/assets/file-icons/icons/light/folders/folder-components-open.svg new file mode 100644 index 000000000..7ddb8b0d3 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-components-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-components.svg b/linux/assets/file-icons/icons/light/folders/folder-components.svg new file mode 100644 index 000000000..95b2529da --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-components.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-config-open.svg b/linux/assets/file-icons/icons/light/folders/folder-config-open.svg new file mode 100644 index 000000000..b2bd27620 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-config-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-config.svg b/linux/assets/file-icons/icons/light/folders/folder-config.svg new file mode 100644 index 000000000..2a9817835 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-config.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-database-open.svg b/linux/assets/file-icons/icons/light/folders/folder-database-open.svg new file mode 100644 index 000000000..6755b5694 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-database-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-database.svg b/linux/assets/file-icons/icons/light/folders/folder-database.svg new file mode 100644 index 000000000..8ac9462ab --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-database.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-docs-open.svg b/linux/assets/file-icons/icons/light/folders/folder-docs-open.svg new file mode 100644 index 000000000..f244dc895 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-docs-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-docs.svg b/linux/assets/file-icons/icons/light/folders/folder-docs.svg new file mode 100644 index 000000000..88f62bd59 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-docs.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-extensions-open.svg b/linux/assets/file-icons/icons/light/folders/folder-extensions-open.svg new file mode 100644 index 000000000..3119706f1 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-extensions-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-extensions.svg b/linux/assets/file-icons/icons/light/folders/folder-extensions.svg new file mode 100644 index 000000000..2e24257d9 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-extensions.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-git-open.svg b/linux/assets/file-icons/icons/light/folders/folder-git-open.svg new file mode 100644 index 000000000..946e9cdeb --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-git-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-git.svg b/linux/assets/file-icons/icons/light/folders/folder-git.svg new file mode 100644 index 000000000..f0b06065a --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-git.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-locales-open.svg b/linux/assets/file-icons/icons/light/folders/folder-locales-open.svg new file mode 100644 index 000000000..73c3d1386 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-locales-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-locales.svg b/linux/assets/file-icons/icons/light/folders/folder-locales.svg new file mode 100644 index 000000000..6fc668015 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-locales.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-mobile-open.svg b/linux/assets/file-icons/icons/light/folders/folder-mobile-open.svg new file mode 100644 index 000000000..243086c22 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-mobile-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-mobile.svg b/linux/assets/file-icons/icons/light/folders/folder-mobile.svg new file mode 100644 index 000000000..7fa4504b5 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-mobile.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-open.svg b/linux/assets/file-icons/icons/light/folders/folder-open.svg new file mode 100644 index 000000000..2dc12ef87 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-open.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-packages-open.svg b/linux/assets/file-icons/icons/light/folders/folder-packages-open.svg new file mode 100644 index 000000000..1c7e10af4 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-packages-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-packages.svg b/linux/assets/file-icons/icons/light/folders/folder-packages.svg new file mode 100644 index 000000000..0663dd214 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-packages.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-routes-open.svg b/linux/assets/file-icons/icons/light/folders/folder-routes-open.svg new file mode 100644 index 000000000..2e6036a73 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-routes-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-routes.svg b/linux/assets/file-icons/icons/light/folders/folder-routes.svg new file mode 100644 index 000000000..c88e28f75 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-routes.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-rust-open.svg b/linux/assets/file-icons/icons/light/folders/folder-rust-open.svg new file mode 100644 index 000000000..297e2377c --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-rust-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-rust.svg b/linux/assets/file-icons/icons/light/folders/folder-rust.svg new file mode 100644 index 000000000..35e5e2327 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-rust.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-scripts-open.svg b/linux/assets/file-icons/icons/light/folders/folder-scripts-open.svg new file mode 100644 index 000000000..519b6138c --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-scripts-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-scripts.svg b/linux/assets/file-icons/icons/light/folders/folder-scripts.svg new file mode 100644 index 000000000..78fa6406f --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-scripts.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-security-open.svg b/linux/assets/file-icons/icons/light/folders/folder-security-open.svg new file mode 100644 index 000000000..4888dcd72 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-security-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-security.svg b/linux/assets/file-icons/icons/light/folders/folder-security.svg new file mode 100644 index 000000000..1aa7f1769 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-security.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-source-open.svg b/linux/assets/file-icons/icons/light/folders/folder-source-open.svg new file mode 100644 index 000000000..1da3d2d4b --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-source-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-source.svg b/linux/assets/file-icons/icons/light/folders/folder-source.svg new file mode 100644 index 000000000..dd71d5eb7 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-source.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-styles-open.svg b/linux/assets/file-icons/icons/light/folders/folder-styles-open.svg new file mode 100644 index 000000000..b3bf8b742 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-styles-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-styles.svg b/linux/assets/file-icons/icons/light/folders/folder-styles.svg new file mode 100644 index 000000000..060ce9d00 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-styles.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-test-open.svg b/linux/assets/file-icons/icons/light/folders/folder-test-open.svg new file mode 100644 index 000000000..7adcf0d59 --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-test-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder-test.svg b/linux/assets/file-icons/icons/light/folders/folder-test.svg new file mode 100644 index 000000000..2fd97785a --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder-test.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/light/folders/folder.svg b/linux/assets/file-icons/icons/light/folders/folder.svg new file mode 100644 index 000000000..a127080bf --- /dev/null +++ b/linux/assets/file-icons/icons/light/folders/folder.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/assets/file-icons/map.json b/linux/assets/file-icons/map.json new file mode 100644 index 000000000..53f5e9925 --- /dev/null +++ b/linux/assets/file-icons/map.json @@ -0,0 +1 @@ +{"defaultFile": "file", "defaultFolder": "folder", "defaultFolderOpen": "folder-open", "iconDefinitions": {"file": "./icons/files/file.svg", "text": "./icons/files/text.svg", "document": "./icons/files/document.svg", "markdown": "./icons/files/markdown.svg", "html": "./icons/files/html.svg", "css": "./icons/files/css.svg", "sass": "./icons/files/sass.svg", "javascript": "./icons/files/javascript.svg", "typescript": "./icons/files/typescript.svg", "react": "./icons/files/react.svg", "vue": "./icons/files/vue.svg", "svelte": "./icons/files/svelte.svg", "astro": "./icons/files/astro.svg", "json": "./icons/files/json.svg", "yaml": "./icons/files/yaml.svg", "toml": "./icons/files/toml.svg", "xml": "./icons/files/xml.svg", "rust": "./icons/files/rust.svg", "python": "./icons/files/python.svg", "go": "./icons/files/go.svg", "java": "./icons/files/java.svg", "c": "./icons/files/c.svg", "cpp": "./icons/files/cpp.svg", "csharp": "./icons/files/csharp.svg", "swift": "./icons/files/swift.svg", "zig": "./icons/files/zig.svg", "ruby": "./icons/files/ruby.svg", "php": "./icons/files/php.svg", "shell": "./icons/files/shell.svg", "sql": "./icons/files/sql.svg", "database": "./icons/files/database.svg", "prisma": "./icons/files/prisma.svg", "graphql": "./icons/files/graphql.svg", "docker": "./icons/files/docker.svg", "git": "./icons/files/git.svg", "github": "./icons/files/github.svg", "package": "./icons/files/package.svg", "node": "./icons/files/node.svg", "bun": "./icons/files/bun.svg", "deno": "./icons/files/deno.svg", "lock": "./icons/files/lock.svg", "config": "./icons/files/config.svg", "env": "./icons/files/env.svg", "test": "./icons/files/test.svg", "vite": "./icons/files/vite.svg", "tailwind": "./icons/files/tailwind.svg", "image": "./icons/files/image.svg", "svg": "./icons/files/svg.svg", "audio": "./icons/files/audio.svg", "video": "./icons/files/video.svg", "font": "./icons/files/font.svg", "pdf": "./icons/files/pdf.svg", "notebook": "./icons/files/notebook.svg", "next": "./icons/files/next.svg", "nuxt": "./icons/files/nuxt.svg", "angular": "./icons/files/angular.svg", "solid": "./icons/files/solid.svg", "remix": "./icons/files/remix.svg", "qwik": "./icons/files/qwik.svg", "lit": "./icons/files/lit.svg", "storybook": "./icons/files/storybook.svg", "jest": "./icons/files/jest.svg", "vitest": "./icons/files/vitest.svg", "playwright": "./icons/files/playwright.svg", "cypress": "./icons/files/cypress.svg", "eslint": "./icons/files/eslint.svg", "prettier": "./icons/files/prettier.svg", "biome": "./icons/files/biome.svg", "babel": "./icons/files/babel.svg", "swc": "./icons/files/swc.svg", "webpack": "./icons/files/webpack.svg", "rollup": "./icons/files/rollup.svg", "rspack": "./icons/files/rspack.svg", "turborepo": "./icons/files/turborepo.svg", "nx": "./icons/files/nx.svg", "npm": "./icons/files/npm.svg", "pnpm": "./icons/files/pnpm.svg", "yarn": "./icons/files/yarn.svg", "maven": "./icons/files/maven.svg", "gradle": "./icons/files/gradle.svg", "kotlin": "./icons/files/kotlin.svg", "dart": "./icons/files/dart.svg", "lua": "./icons/files/lua.svg", "elixir": "./icons/files/elixir.svg", "erlang": "./icons/files/erlang.svg", "haskell": "./icons/files/haskell.svg", "scala": "./icons/files/scala.svg", "clojure": "./icons/files/clojure.svg", "nim": "./icons/files/nim.svg", "nix": "./icons/files/nix.svg", "terraform": "./icons/files/terraform.svg", "kubernetes": "./icons/files/kubernetes.svg", "helm": "./icons/files/helm.svg", "ansible": "./icons/files/ansible.svg", "cloudflare": "./icons/files/cloudflare.svg", "netlify": "./icons/files/netlify.svg", "vercel": "./icons/files/vercel.svg", "firebase": "./icons/files/firebase.svg", "supabase": "./icons/files/supabase.svg", "mongo": "./icons/files/mongo.svg", "redis": "./icons/files/redis.svg", "postgres": "./icons/files/postgres.svg", "drizzle": "./icons/files/drizzle.svg", "figma": "./icons/files/figma.svg", "sketch": "./icons/files/sketch.svg", "adobe": "./icons/files/adobe.svg", "csv": "./icons/files/csv.svg", "spreadsheet": "./icons/files/spreadsheet.svg", "word": "./icons/files/word.svg", "powerpoint": "./icons/files/powerpoint.svg", "archive": "./icons/files/archive.svg", "certificate": "./icons/files/certificate.svg", "key": "./icons/files/key.svg", "log": "./icons/files/log.svg", "diff": "./icons/files/diff.svg", "patch": "./icons/files/patch.svg", "license": "./icons/files/license.svg", "makefile": "./icons/files/makefile.svg", "cmake": "./icons/files/cmake.svg", "proto": "./icons/files/proto.svg", "wasm": "./icons/files/wasm.svg", "rescript": "./icons/files/rescript.svg", "ocaml": "./icons/files/ocaml.svg", "solidity": "./icons/files/solidity.svg", "r": "./icons/files/r.svg", "julia": "./icons/files/julia.svg", "perl": "./icons/files/perl.svg", "lithe": "./icons/files/lithe.svg", "codex": "./icons/files/codex.svg", "claude": "./icons/files/claude.svg", "cursor": "./icons/files/cursor.svg", "tauri": "./icons/files/tauri.svg", "electron": "./icons/files/electron.svg", "xcode": "./icons/files/xcode.svg", "android": "./icons/files/android.svg", "apple": "./icons/files/apple.svg", "windows": "./icons/files/windows.svg", "linux": "./icons/files/linux.svg", "changelog": "./icons/files/changelog.svg", "authors": "./icons/files/authors.svg", "security": "./icons/files/security.svg", "warning": "./icons/files/warning.svg", "agents": "./icons/files/agents.svg", "copilot": "./icons/files/copilot.svg", "gemini": "./icons/files/gemini.svg", "cline": "./icons/files/cline.svg", "mcp": "./icons/files/mcp.svg", "editorconfig": "./icons/files/editorconfig.svg", "stylelint": "./icons/files/stylelint.svg", "markdownlint": "./icons/files/markdownlint.svg", "cspell": "./icons/files/cspell.svg", "commitlint": "./icons/files/commitlint.svg", "lintstaged": "./icons/files/lintstaged.svg", "renovate": "./icons/files/renovate.svg", "dependabot": "./icons/files/dependabot.svg", "docker-compose": "./icons/files/docker-compose.svg", "devcontainer": "./icons/files/devcontainer.svg", "github-actions": "./icons/files/github-actions.svg", "gitlab": "./icons/files/gitlab.svg", "bitbucket": "./icons/files/bitbucket.svg", "jenkins": "./icons/files/jenkins.svg", "vercel-config": "./icons/files/vercel-config.svg", "nginx": "./icons/files/nginx.svg", "http": "./icons/files/http.svg", "hurl": "./icons/files/hurl.svg", "graphql-schema": "./icons/files/graphql-schema.svg", "jsconfig": "./icons/files/jsconfig.svg", "index-js": "./icons/files/index-js.svg", "index-ts": "./icons/files/index-ts.svg", "layout": "./icons/files/layout.svg", "page": "./icons/files/page.svg", "route": "./icons/files/route.svg", "loading": "./icons/files/loading.svg", "not-found": "./icons/files/not-found.svg", "error": "./icons/files/error.svg", "docusaurus": "./icons/files/docusaurus.svg", "gatsby": "./icons/files/gatsby.svg", "laravel": "./icons/files/laravel.svg", "django": "./icons/files/django.svg", "flask": "./icons/files/flask.svg", "fastapi": "./icons/files/fastapi.svg", "arduino": "./icons/files/arduino.svg", "blender": "./icons/files/blender.svg", "drawio": "./icons/files/drawio.svg", "excalidraw": "./icons/files/excalidraw.svg", "mermaid": "./icons/files/mermaid.svg", "folder": "./icons/folders/folder.svg", "folder-open": "./icons/folders/folder-open.svg", "folder-source": "./icons/folders/folder-source.svg", "folder-source-open": "./icons/folders/folder-source-open.svg", "folder-components": "./icons/folders/folder-components.svg", "folder-components-open": "./icons/folders/folder-components-open.svg", "folder-test": "./icons/folders/folder-test.svg", "folder-test-open": "./icons/folders/folder-test-open.svg", "folder-config": "./icons/folders/folder-config.svg", "folder-config-open": "./icons/folders/folder-config-open.svg", "folder-assets": "./icons/folders/folder-assets.svg", "folder-assets-open": "./icons/folders/folder-assets-open.svg", "folder-docs": "./icons/folders/folder-docs.svg", "folder-docs-open": "./icons/folders/folder-docs-open.svg", "folder-scripts": "./icons/folders/folder-scripts.svg", "folder-scripts-open": "./icons/folders/folder-scripts-open.svg", "folder-rust": "./icons/folders/folder-rust.svg", "folder-rust-open": "./icons/folders/folder-rust-open.svg", "folder-packages": "./icons/folders/folder-packages.svg", "folder-packages-open": "./icons/folders/folder-packages-open.svg", "folder-git": "./icons/folders/folder-git.svg", "folder-git-open": "./icons/folders/folder-git-open.svg", "folder-build": "./icons/folders/folder-build.svg", "folder-build-open": "./icons/folders/folder-build-open.svg", "folder-database": "./icons/folders/folder-database.svg", "folder-database-open": "./icons/folders/folder-database-open.svg", "folder-routes": "./icons/folders/folder-routes.svg", "folder-routes-open": "./icons/folders/folder-routes-open.svg", "folder-styles": "./icons/folders/folder-styles.svg", "folder-styles-open": "./icons/folders/folder-styles-open.svg", "folder-locales": "./icons/folders/folder-locales.svg", "folder-locales-open": "./icons/folders/folder-locales-open.svg", "folder-cloud": "./icons/folders/folder-cloud.svg", "folder-cloud-open": "./icons/folders/folder-cloud-open.svg", "folder-mobile": "./icons/folders/folder-mobile.svg", "folder-mobile-open": "./icons/folders/folder-mobile-open.svg", "folder-security": "./icons/folders/folder-security.svg", "folder-security-open": "./icons/folders/folder-security-open.svg", "folder-ai": "./icons/folders/folder-ai.svg", "folder-ai-open": "./icons/folders/folder-ai-open.svg", "folder-extensions": "./icons/folders/folder-extensions.svg", "folder-extensions-open": "./icons/folders/folder-extensions-open.svg"}, "lightIconDefinitions": {"file": "./icons/light/files/file.svg", "text": "./icons/light/files/text.svg", "document": "./icons/light/files/document.svg", "markdown": "./icons/light/files/markdown.svg", "html": "./icons/light/files/html.svg", "css": "./icons/light/files/css.svg", "sass": "./icons/light/files/sass.svg", "javascript": "./icons/light/files/javascript.svg", "typescript": "./icons/light/files/typescript.svg", "react": "./icons/light/files/react.svg", "vue": "./icons/light/files/vue.svg", "svelte": "./icons/light/files/svelte.svg", "astro": "./icons/light/files/astro.svg", "json": "./icons/light/files/json.svg", "yaml": "./icons/light/files/yaml.svg", "toml": "./icons/light/files/toml.svg", "xml": "./icons/light/files/xml.svg", "rust": "./icons/light/files/rust.svg", "python": "./icons/light/files/python.svg", "go": "./icons/light/files/go.svg", "java": "./icons/light/files/java.svg", "c": "./icons/light/files/c.svg", "cpp": "./icons/light/files/cpp.svg", "csharp": "./icons/light/files/csharp.svg", "swift": "./icons/light/files/swift.svg", "zig": "./icons/light/files/zig.svg", "ruby": "./icons/light/files/ruby.svg", "php": "./icons/light/files/php.svg", "shell": "./icons/light/files/shell.svg", "sql": "./icons/light/files/sql.svg", "database": "./icons/light/files/database.svg", "prisma": "./icons/light/files/prisma.svg", "graphql": "./icons/light/files/graphql.svg", "docker": "./icons/light/files/docker.svg", "git": "./icons/light/files/git.svg", "github": "./icons/light/files/github.svg", "package": "./icons/light/files/package.svg", "node": "./icons/light/files/node.svg", "bun": "./icons/light/files/bun.svg", "deno": "./icons/light/files/deno.svg", "lock": "./icons/light/files/lock.svg", "config": "./icons/light/files/config.svg", "env": "./icons/light/files/env.svg", "test": "./icons/light/files/test.svg", "vite": "./icons/light/files/vite.svg", "tailwind": "./icons/light/files/tailwind.svg", "image": "./icons/light/files/image.svg", "svg": "./icons/light/files/svg.svg", "audio": "./icons/light/files/audio.svg", "video": "./icons/light/files/video.svg", "font": "./icons/light/files/font.svg", "pdf": "./icons/light/files/pdf.svg", "notebook": "./icons/light/files/notebook.svg", "next": "./icons/light/files/next.svg", "nuxt": "./icons/light/files/nuxt.svg", "angular": "./icons/light/files/angular.svg", "solid": "./icons/light/files/solid.svg", "remix": "./icons/light/files/remix.svg", "qwik": "./icons/light/files/qwik.svg", "lit": "./icons/light/files/lit.svg", "storybook": "./icons/light/files/storybook.svg", "jest": "./icons/light/files/jest.svg", "vitest": "./icons/light/files/vitest.svg", "playwright": "./icons/light/files/playwright.svg", "cypress": "./icons/light/files/cypress.svg", "eslint": "./icons/light/files/eslint.svg", "prettier": "./icons/light/files/prettier.svg", "biome": "./icons/light/files/biome.svg", "babel": "./icons/light/files/babel.svg", "swc": "./icons/light/files/swc.svg", "webpack": "./icons/light/files/webpack.svg", "rollup": "./icons/light/files/rollup.svg", "rspack": "./icons/light/files/rspack.svg", "turborepo": "./icons/light/files/turborepo.svg", "nx": "./icons/light/files/nx.svg", "npm": "./icons/light/files/npm.svg", "pnpm": "./icons/light/files/pnpm.svg", "yarn": "./icons/light/files/yarn.svg", "maven": "./icons/light/files/maven.svg", "gradle": "./icons/light/files/gradle.svg", "kotlin": "./icons/light/files/kotlin.svg", "dart": "./icons/light/files/dart.svg", "lua": "./icons/light/files/lua.svg", "elixir": "./icons/light/files/elixir.svg", "erlang": "./icons/light/files/erlang.svg", "haskell": "./icons/light/files/haskell.svg", "scala": "./icons/light/files/scala.svg", "clojure": "./icons/light/files/clojure.svg", "nim": "./icons/light/files/nim.svg", "nix": "./icons/light/files/nix.svg", "terraform": "./icons/light/files/terraform.svg", "kubernetes": "./icons/light/files/kubernetes.svg", "helm": "./icons/light/files/helm.svg", "ansible": "./icons/light/files/ansible.svg", "cloudflare": "./icons/light/files/cloudflare.svg", "netlify": "./icons/light/files/netlify.svg", "vercel": "./icons/light/files/vercel.svg", "firebase": "./icons/light/files/firebase.svg", "supabase": "./icons/light/files/supabase.svg", "mongo": "./icons/light/files/mongo.svg", "redis": "./icons/light/files/redis.svg", "postgres": "./icons/light/files/postgres.svg", "drizzle": "./icons/light/files/drizzle.svg", "figma": "./icons/light/files/figma.svg", "sketch": "./icons/light/files/sketch.svg", "adobe": "./icons/light/files/adobe.svg", "csv": "./icons/light/files/csv.svg", "spreadsheet": "./icons/light/files/spreadsheet.svg", "word": "./icons/light/files/word.svg", "powerpoint": "./icons/light/files/powerpoint.svg", "archive": "./icons/light/files/archive.svg", "certificate": "./icons/light/files/certificate.svg", "key": "./icons/light/files/key.svg", "log": "./icons/light/files/log.svg", "diff": "./icons/light/files/diff.svg", "patch": "./icons/light/files/patch.svg", "license": "./icons/light/files/license.svg", "makefile": "./icons/light/files/makefile.svg", "cmake": "./icons/light/files/cmake.svg", "proto": "./icons/light/files/proto.svg", "wasm": "./icons/light/files/wasm.svg", "rescript": "./icons/light/files/rescript.svg", "ocaml": "./icons/light/files/ocaml.svg", "solidity": "./icons/light/files/solidity.svg", "r": "./icons/light/files/r.svg", "julia": "./icons/light/files/julia.svg", "perl": "./icons/light/files/perl.svg", "lithe": "./icons/light/files/lithe.svg", "codex": "./icons/light/files/codex.svg", "claude": "./icons/light/files/claude.svg", "cursor": "./icons/light/files/cursor.svg", "tauri": "./icons/light/files/tauri.svg", "electron": "./icons/light/files/electron.svg", "xcode": "./icons/light/files/xcode.svg", "android": "./icons/light/files/android.svg", "apple": "./icons/light/files/apple.svg", "windows": "./icons/light/files/windows.svg", "linux": "./icons/light/files/linux.svg", "changelog": "./icons/light/files/changelog.svg", "authors": "./icons/light/files/authors.svg", "security": "./icons/light/files/security.svg", "warning": "./icons/light/files/warning.svg", "agents": "./icons/light/files/agents.svg", "copilot": "./icons/light/files/copilot.svg", "gemini": "./icons/light/files/gemini.svg", "cline": "./icons/light/files/cline.svg", "mcp": "./icons/light/files/mcp.svg", "editorconfig": "./icons/light/files/editorconfig.svg", "stylelint": "./icons/light/files/stylelint.svg", "markdownlint": "./icons/light/files/markdownlint.svg", "cspell": "./icons/light/files/cspell.svg", "commitlint": "./icons/light/files/commitlint.svg", "lintstaged": "./icons/light/files/lintstaged.svg", "renovate": "./icons/light/files/renovate.svg", "dependabot": "./icons/light/files/dependabot.svg", "docker-compose": "./icons/light/files/docker-compose.svg", "devcontainer": "./icons/light/files/devcontainer.svg", "github-actions": "./icons/light/files/github-actions.svg", "gitlab": "./icons/light/files/gitlab.svg", "bitbucket": "./icons/light/files/bitbucket.svg", "jenkins": "./icons/light/files/jenkins.svg", "vercel-config": "./icons/light/files/vercel-config.svg", "nginx": "./icons/light/files/nginx.svg", "http": "./icons/light/files/http.svg", "hurl": "./icons/light/files/hurl.svg", "graphql-schema": "./icons/light/files/graphql-schema.svg", "jsconfig": "./icons/light/files/jsconfig.svg", "index-js": "./icons/light/files/index-js.svg", "index-ts": "./icons/light/files/index-ts.svg", "layout": "./icons/light/files/layout.svg", "page": "./icons/light/files/page.svg", "route": "./icons/light/files/route.svg", "loading": "./icons/light/files/loading.svg", "not-found": "./icons/light/files/not-found.svg", "error": "./icons/light/files/error.svg", "docusaurus": "./icons/light/files/docusaurus.svg", "gatsby": "./icons/light/files/gatsby.svg", "laravel": "./icons/light/files/laravel.svg", "django": "./icons/light/files/django.svg", "flask": "./icons/light/files/flask.svg", "fastapi": "./icons/light/files/fastapi.svg", "arduino": "./icons/light/files/arduino.svg", "blender": "./icons/light/files/blender.svg", "drawio": "./icons/light/files/drawio.svg", "excalidraw": "./icons/light/files/excalidraw.svg", "mermaid": "./icons/light/files/mermaid.svg", "folder": "./icons/light/folders/folder.svg", "folder-open": "./icons/light/folders/folder-open.svg", "folder-source": "./icons/light/folders/folder-source.svg", "folder-source-open": "./icons/light/folders/folder-source-open.svg", "folder-components": "./icons/light/folders/folder-components.svg", "folder-components-open": "./icons/light/folders/folder-components-open.svg", "folder-test": "./icons/light/folders/folder-test.svg", "folder-test-open": "./icons/light/folders/folder-test-open.svg", "folder-config": "./icons/light/folders/folder-config.svg", "folder-config-open": "./icons/light/folders/folder-config-open.svg", "folder-assets": "./icons/light/folders/folder-assets.svg", "folder-assets-open": "./icons/light/folders/folder-assets-open.svg", "folder-docs": "./icons/light/folders/folder-docs.svg", "folder-docs-open": "./icons/light/folders/folder-docs-open.svg", "folder-scripts": "./icons/light/folders/folder-scripts.svg", "folder-scripts-open": "./icons/light/folders/folder-scripts-open.svg", "folder-rust": "./icons/light/folders/folder-rust.svg", "folder-rust-open": "./icons/light/folders/folder-rust-open.svg", "folder-packages": "./icons/light/folders/folder-packages.svg", "folder-packages-open": "./icons/light/folders/folder-packages-open.svg", "folder-git": "./icons/light/folders/folder-git.svg", "folder-git-open": "./icons/light/folders/folder-git-open.svg", "folder-build": "./icons/light/folders/folder-build.svg", "folder-build-open": "./icons/light/folders/folder-build-open.svg", "folder-database": "./icons/light/folders/folder-database.svg", "folder-database-open": "./icons/light/folders/folder-database-open.svg", "folder-routes": "./icons/light/folders/folder-routes.svg", "folder-routes-open": "./icons/light/folders/folder-routes-open.svg", "folder-styles": "./icons/light/folders/folder-styles.svg", "folder-styles-open": "./icons/light/folders/folder-styles-open.svg", "folder-locales": "./icons/light/folders/folder-locales.svg", "folder-locales-open": "./icons/light/folders/folder-locales-open.svg", "folder-cloud": "./icons/light/folders/folder-cloud.svg", "folder-cloud-open": "./icons/light/folders/folder-cloud-open.svg", "folder-mobile": "./icons/light/folders/folder-mobile.svg", "folder-mobile-open": "./icons/light/folders/folder-mobile-open.svg", "folder-security": "./icons/light/folders/folder-security.svg", "folder-security-open": "./icons/light/folders/folder-security-open.svg", "folder-ai": "./icons/light/folders/folder-ai.svg", "folder-ai-open": "./icons/light/folders/folder-ai-open.svg", "folder-extensions": "./icons/light/folders/folder-extensions.svg", "folder-extensions-open": "./icons/light/folders/folder-extensions-open.svg"}, "fileExtensions": {".txt": "text", ".md": "markdown", ".mdx": "markdown", ".html": "html", ".htm": "html", ".css": "css", ".scss": "sass", ".sass": "sass", ".js": "javascript", ".mjs": "javascript", ".cjs": "javascript", ".jsx": "react", ".ts": "typescript", ".mts": "typescript", ".cts": "typescript", ".tsx": "react", ".vue": "vue", ".svelte": "svelte", ".astro": "astro", ".json": "json", ".jsonc": "json", ".yaml": "yaml", ".yml": "yaml", ".toml": "toml", ".xml": "xml", ".svg": "svg", ".rs": "rust", ".ron": "rust", ".py": "python", ".go": "go", ".java": "java", ".c": "c", ".h": "c", ".cpp": "cpp", ".cxx": "cpp", ".cc": "cpp", ".hpp": "cpp", ".cs": "csharp", ".swift": "swift", ".zig": "zig", ".rb": "ruby", ".php": "php", ".sh": "shell", ".bash": "shell", ".zsh": "shell", ".fish": "shell", ".sql": "sql", ".sqlite": "database", ".sqlite3": "database", ".db": "database", ".prisma": "prisma", ".graphql": "graphql", ".gql": "graphql", ".dockerfile": "docker", ".lock": "lock", ".env": "env", ".test.js": "test", ".test.ts": "test", ".test.tsx": "test", ".spec.js": "test", ".spec.ts": "test", ".spec.tsx": "test", ".png": "image", ".jpg": "image", ".jpeg": "image", ".webp": "image", ".gif": "image", ".ico": "image", ".mp3": "audio", ".wav": "audio", ".flac": "audio", ".mp4": "video", ".mov": "video", ".webm": "video", ".ttf": "font", ".otf": "font", ".woff": "font", ".woff2": "font", ".pdf": "pdf", ".ipynb": "notebook", ".vue.ts": "vue", ".svelte.ts": "svelte", ".stories.js": "storybook", ".stories.jsx": "storybook", ".stories.ts": "storybook", ".stories.tsx": "storybook", ".story.js": "storybook", ".story.jsx": "storybook", ".story.ts": "storybook", ".story.tsx": "storybook", ".cy.js": "cypress", ".cy.ts": "cypress", ".cy.tsx": "cypress", ".playwright.js": "playwright", ".playwright.ts": "playwright", ".test.mjs": "test", ".spec.mjs": "test", ".kt": "kotlin", ".kts": "kotlin", ".dart": "dart", ".lua": "lua", ".ex": "elixir", ".exs": "elixir", ".erl": "erlang", ".hrl": "erlang", ".hs": "haskell", ".scala": "scala", ".sc": "scala", ".clj": "clojure", ".cljs": "clojure", ".nim": "nim", ".nix": "nix", ".tf": "terraform", ".tfvars": "terraform", ".hcl": "terraform", ".k8s.yaml": "kubernetes", ".helm.yaml": "helm", ".yarnrc": "yarn", ".npmrc": "npm", ".csv": "csv", ".tsv": "csv", ".xlsx": "spreadsheet", ".xls": "spreadsheet", ".doc": "word", ".docx": "word", ".ppt": "powerpoint", ".pptx": "powerpoint", ".zip": "archive", ".tar": "archive", ".gz": "archive", ".tgz": "archive", ".rar": "archive", ".7z": "archive", ".pem": "certificate", ".crt": "certificate", ".cer": "certificate", ".key": "key", ".log": "log", ".diff": "diff", ".patch": "patch", ".proto": "proto", ".wasm": "wasm", ".res": "rescript", ".resi": "rescript", ".ml": "ocaml", ".mli": "ocaml", ".sol": "solidity", ".r": "r", ".rmd": "r", ".jl": "julia", ".pl": "perl", ".app": "apple", ".ipa": "apple", ".apk": "android", ".aab": "android", ".exe": "windows", ".msi": "windows", ".dll": "windows", ".so": "linux", ".http": "http", ".rest": "http", ".hurl": "hurl", ".drawio": "drawio", ".excalidraw": "excalidraw", ".mmd": "mermaid", ".mermaid": "mermaid", ".blend": "blender", ".ino": "arduino"}, "filenames": {"package.json": "node", "package-lock.json": "node", "bun.lock": "bun", "bun.lockb": "bun", "bunfig.toml": "bun", "deno.json": "deno", "deno.jsonc": "deno", "tsconfig.json": "typescript", "vite.config.js": "vite", "vite.config.ts": "vite", "tailwind.config.js": "tailwind", "tailwind.config.ts": "tailwind", "dockerfile": "docker", ".dockerignore": "docker", ".gitignore": "git", ".gitattributes": "git", ".env": "env", ".env.example": "env", "cargo.toml": "rust", "cargo.lock": "rust", "readme.md": "markdown", "license": "license", "license.md": "license", "next.config.js": "next", "next.config.mjs": "next", "next.config.ts": "next", "nuxt.config.js": "nuxt", "nuxt.config.ts": "nuxt", "angular.json": "angular", "remix.config.js": "remix", "remix.config.ts": "remix", "qwik.config.js": "qwik", "qwik.config.ts": "qwik", "lit.config.js": "lit", "storybook.config.js": "storybook", "jest.config.js": "jest", "jest.config.ts": "jest", "vitest.config.js": "vitest", "vitest.config.ts": "vitest", "playwright.config.js": "playwright", "playwright.config.ts": "playwright", "cypress.config.js": "cypress", "cypress.config.ts": "cypress", ".eslintrc": "eslint", ".eslintrc.json": "eslint", "eslint.config.js": "eslint", "eslint.config.mjs": "eslint", "eslint.config.ts": "eslint", ".prettierrc": "prettier", ".prettierrc.json": "prettier", "prettier.config.js": "prettier", "biome.json": "biome", "biome.jsonc": "biome", "babel.config.js": "babel", ".babelrc": "babel", ".swcrc": "swc", "webpack.config.js": "webpack", "webpack.config.ts": "webpack", "rollup.config.js": "rollup", "rollup.config.ts": "rollup", "rspack.config.js": "rspack", "rspack.config.ts": "rspack", "turbo.json": "turborepo", "nx.json": "nx", "pnpm-lock.yaml": "pnpm", "pnpm-workspace.yaml": "pnpm", "yarn.lock": "yarn", "pom.xml": "maven", "build.gradle": "gradle", "build.gradle.kts": "gradle", "gradle.properties": "gradle", "gradlew": "gradle", "flake.nix": "nix", "terraform.tfvars": "terraform", "chart.yaml": "helm", "ansible.cfg": "ansible", "wrangler.toml": "cloudflare", "netlify.toml": "netlify", "vercel.json": "vercel", "firebase.json": "firebase", "supabase.toml": "supabase", "drizzle.config.ts": "drizzle", ".fig": "figma", "makefile": "makefile", "cmakelists.txt": "cmake", "copying": "license", "lithe.json": "lithe", ".codex": "codex", "agents.md": "codex", "claude.md": "claude", ".cursorrules": "cursor", "tauri.conf.json": "tauri", "electron-builder.json": "electron", "xcodeproj": "xcode", "androidmanifest.xml": "android", "changelog.md": "changelog", "authors": "authors", "authors.md": "authors", "security.md": "security", "codeowners": "security", ".agents": "agents", "agents.json": "agents", "agents.toml": "agents", "copilot-instructions.md": "copilot", ".mcp.json": "mcp", "mcp.json": "mcp", ".editorconfig": "editorconfig", ".stylelintrc": "stylelint", ".stylelintrc.json": "stylelint", "stylelint.config.js": "stylelint", "stylelint.config.mjs": "stylelint", ".markdownlint.json": "markdownlint", ".markdownlint.yaml": "markdownlint", ".markdownlintignore": "markdownlint", "cspell.json": "cspell", ".cspell.json": "cspell", "commitlint.config.js": "commitlint", "commitlint.config.ts": "commitlint", ".lintstagedrc": "lintstaged", "lint-staged.config.js": "lintstaged", "renovate.json": "renovate", "dependabot.yml": "dependabot", "docker-compose.yml": "docker-compose", "docker-compose.yaml": "docker-compose", ".devcontainer.json": "devcontainer", "devcontainer.json": "devcontainer", "action.yml": "github-actions", "action.yaml": "github-actions", ".gitlab-ci.yml": "gitlab", "bitbucket-pipelines.yml": "bitbucket", "jenkinsfile": "jenkins", "nginx.conf": "nginx", "index.js": "index-js", "index.ts": "index-ts", "index.tsx": "index-ts", "layout.js": "layout", "layout.jsx": "layout", "layout.ts": "layout", "layout.tsx": "layout", "page.js": "page", "page.jsx": "page", "page.ts": "page", "page.tsx": "page", "route.js": "route", "route.ts": "route", "loading.js": "loading", "loading.tsx": "loading", "not-found.js": "not-found", "not-found.tsx": "not-found", "error.js": "error", "error.tsx": "error", "docusaurus.config.js": "docusaurus", "docusaurus.config.ts": "docusaurus", "gatsby-config.js": "gatsby", "gatsby-node.js": "gatsby", "artisan": "laravel", "manage.py": "django", "app.py": "flask", "main.py": "python", "requirements.txt": "python"}, "folders": {"src": "folder-source", "source": "folder-source", "components": "folder-components", "component": "folder-components", "hooks": "folder-source", "utils": "folder-source", "util": "folder-source", "tests": "folder-test", "test": "folder-test", "__tests__": "folder-test", "config": "folder-config", "configs": "folder-config", "assets": "folder-assets", "public": "folder-assets", "images": "folder-assets", "img": "folder-assets", "docs": "folder-docs", "scripts": "folder-scripts", "crates": "folder-rust", "rust": "folder-rust", "src-tauri": "folder-rust", "tauri": "folder-rust", "node_modules": "folder-packages", ".git": "folder-git", ".github": "folder-git", ".vscode": "folder-config", "build": "folder-build", "dist": "folder-build", "database": "folder-database", "databases": "folder-database", "api": "folder-routes", "routes": "folder-routes", "router": "folder-routes", "stores": "folder-source", "store": "folder-source", "features": "folder-source", "ui": "folder-components", "pages": "folder-components", "app": "folder-components", "views": "folder-components", "view": "folder-components", "lib": "folder-source", "libs": "folder-source", "services": "folder-source", "service": "folder-source", "types": "folder-source", "typings": "folder-source", "styles": "folder-styles", "style": "folder-styles", "css": "folder-styles", "locales": "folder-locales", "i18n": "folder-locales", "terminal": "folder-scripts", "shell": "folder-scripts", "auth": "folder-security", "security": "folder-security", ".circleci": "folder-git", ".buildkite": "folder-git", ".docker": "folder-cloud", "docker": "folder-cloud", "k8s": "folder-cloud", "kubernetes": "folder-cloud", "helm": "folder-cloud", "cloud": "folder-cloud", ".firebase": "folder-database", "firebase": "folder-database", ".supabase": "folder-database", "supabase": "folder-database", "prisma": "folder-database", "cache": "folder-build", "logs": "folder-build", "log": "folder-build", "tmp": "folder-build", "temp": "folder-build", "mobile": "folder-mobile", "ios": "folder-mobile", "android": "folder-mobile", ".storybook": "folder-components", "storybook": "folder-components", "fixtures": "folder-test", "mocks": "folder-test", "mock": "folder-test", "generated": "folder-build", "gen": "folder-build", "benchmark": "folder-test", "benchmarks": "folder-test", "extensions": "folder-extensions", "extension": "folder-extensions", "themes": "folder-styles", "theme": "folder-styles", "ai": "folder-ai", ".agents": "folder-ai", "agents": "folder-ai", ".codex": "folder-ai", "codex": "folder-ai", ".claude": "folder-ai", "claude": "folder-ai", "packages": "folder-packages", "package": "folder-packages", "examples": "folder-docs", "example": "folder-docs", "playground": "folder-test", "play": "folder-test", "commands": "folder-scripts", "command": "folder-scripts", "plugins": "folder-extensions", "plugin": "folder-extensions", "workflows": "folder-git", "workflow": "folder-git", ".devcontainer": "folder-cloud", "devcontainer": "folder-cloud", "web": "folder-mobile", "www": "folder-mobile", "server": "folder-routes", "client": "folder-mobile", "shared": "folder-source", "common": "folder-source"}, "expandedFolders": {"src": "folder-source-open", "source": "folder-source-open", "components": "folder-components-open", "component": "folder-components-open", "hooks": "folder-source-open", "utils": "folder-source-open", "util": "folder-source-open", "tests": "folder-test-open", "test": "folder-test-open", "__tests__": "folder-test-open", "config": "folder-config-open", "configs": "folder-config-open", "assets": "folder-assets-open", "public": "folder-assets-open", "images": "folder-assets-open", "img": "folder-assets-open", "docs": "folder-docs-open", "scripts": "folder-scripts-open", "crates": "folder-rust-open", "rust": "folder-rust-open", "src-tauri": "folder-rust-open", "tauri": "folder-rust-open", "node_modules": "folder-packages-open", ".git": "folder-git-open", ".github": "folder-git-open", ".vscode": "folder-config-open", "build": "folder-build-open", "dist": "folder-build-open", "database": "folder-database-open", "databases": "folder-database-open", "api": "folder-routes-open", "routes": "folder-routes-open", "router": "folder-routes-open", "stores": "folder-source-open", "store": "folder-source-open", "features": "folder-source-open", "ui": "folder-components-open", "pages": "folder-components-open", "app": "folder-components-open", "views": "folder-components-open", "view": "folder-components-open", "lib": "folder-source-open", "libs": "folder-source-open", "services": "folder-source-open", "service": "folder-source-open", "types": "folder-source-open", "typings": "folder-source-open", "styles": "folder-styles-open", "style": "folder-styles-open", "css": "folder-styles-open", "locales": "folder-locales-open", "i18n": "folder-locales-open", "terminal": "folder-scripts-open", "shell": "folder-scripts-open", "auth": "folder-security-open", "security": "folder-security-open", ".circleci": "folder-git-open", ".buildkite": "folder-git-open", ".docker": "folder-cloud-open", "docker": "folder-cloud-open", "k8s": "folder-cloud-open", "kubernetes": "folder-cloud-open", "helm": "folder-cloud-open", "cloud": "folder-cloud-open", ".firebase": "folder-database-open", "firebase": "folder-database-open", ".supabase": "folder-database-open", "supabase": "folder-database-open", "prisma": "folder-database-open", "cache": "folder-build-open", "logs": "folder-build-open", "log": "folder-build-open", "tmp": "folder-build-open", "temp": "folder-build-open", "mobile": "folder-mobile-open", "ios": "folder-mobile-open", "android": "folder-mobile-open", ".storybook": "folder-components-open", "storybook": "folder-components-open", "fixtures": "folder-test-open", "mocks": "folder-test-open", "mock": "folder-test-open", "generated": "folder-build-open", "gen": "folder-build-open", "benchmark": "folder-test-open", "benchmarks": "folder-test-open", "extensions": "folder-extensions-open", "extension": "folder-extensions-open", "themes": "folder-styles-open", "theme": "folder-styles-open", "ai": "folder-ai-open", ".agents": "folder-ai-open", "agents": "folder-ai-open", ".codex": "folder-ai-open", "codex": "folder-ai-open", ".claude": "folder-ai-open", "claude": "folder-ai-open", "packages": "folder-packages-open", "package": "folder-packages-open", "examples": "folder-docs-open", "example": "folder-docs-open", "playground": "folder-test-open", "play": "folder-test-open", "commands": "folder-scripts-open", "command": "folder-scripts-open", "plugins": "folder-extensions-open", "plugin": "folder-extensions-open", "workflows": "folder-git-open", "workflow": "folder-git-open", ".devcontainer": "folder-cloud-open", "devcontainer": "folder-cloud-open", "web": "folder-mobile-open", "www": "folder-mobile-open", "server": "folder-routes-open", "client": "folder-mobile-open", "shared": "folder-source-open", "common": "folder-source-open"}} \ No newline at end of file diff --git a/linux/build.rs b/linux/build.rs new file mode 100644 index 000000000..60f3b27db --- /dev/null +++ b/linux/build.rs @@ -0,0 +1,325 @@ +//! 构建脚本:把 `assets/file-icons/map.json` 编译为静态查找表与 SVG 内嵌字节。 +//! +//! 生成物 `$OUT_DIR/file_icons_generated.rs` 提供默认定义 id、各查找表的 +//! `match` 查询函数,以及 `svg_bytes_for_def`(`include_bytes!` 内嵌 SVG)。 +//! 运行期不再解析 JSON;`map.json` 缺失或形状不符时生成空实现(查询一律 +//! 返回 `None`),保证缺资产仍可编译。 + +use std::path::PathBuf; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=assets/file-icons/map.json"); + println!("cargo:rerun-if-changed=assets/file-icons/icons"); + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let out_path = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR 未设置")) + .join("file_icons_generated.rs"); + let code = match std::fs::read_to_string(manifest_dir.join("assets/file-icons/map.json")) { + Ok(json) => generate(&json), + Err(_) => generate(""), + }; + std::fs::write(&out_path, code).expect("写入 file_icons_generated.rs 失败"); +} + +/// 顶层值:字符串或扁平字符串表(`map.json` 的实际形状)。 +enum TopValue { + Str(String), + Map(Vec<(String, String)>), +} + +/// 跳过 JSON 空白。 +fn skip_ws(b: &[u8], pos: &mut usize) { + while *pos < b.len() && matches!(b[*pos], b' ' | b'\t' | b'\n' | b'\r') { + *pos += 1; + } +} + +/// 解析 `"..."` 字符串,处理常见转义(含 `\uXXXX`)。 +fn parse_string(b: &[u8], pos: &mut usize) -> Option { + if b.get(*pos) != Some(&b'"') { + return None; + } + *pos += 1; + let mut out = String::new(); + while let Some(&c) = b.get(*pos) { + *pos += 1; + match c { + b'"' => return Some(out), + b'\\' => { + let e = *b.get(*pos)?; + *pos += 1; + match e { + b'"' => out.push('"'), + b'\\' => out.push('\\'), + b'/' => out.push('/'), + b'b' => out.push('\u{0008}'), + b'f' => out.push('\u{000C}'), + b'n' => out.push('\n'), + b'r' => out.push('\r'), + b't' => out.push('\t'), + b'u' => { + let hex = std::str::from_utf8(b.get(*pos..*pos + 4)?).ok()?; + let cp = u32::from_str_radix(hex, 16).ok()?; + *pos += 4; + out.push(char::from_u32(cp)?); + } + _ => return None, + } + } + _ => { + // 按 UTF-8 边界收一个字符(键与路径多为 ASCII,该分支只做兜底)。 + let s = std::str::from_utf8(&b[*pos - 1..]).ok()?; + let ch = s.chars().next()?; + *pos += ch.len_utf8() - 1; + out.push(ch); + } + } + } + None +} + +/// 跳过任意 JSON 值(对象/数组/字符串/字面量),单个坏条目不污染整表。 +fn skip_value(b: &[u8], pos: &mut usize) -> Option<()> { + skip_ws(b, pos); + match *b.get(*pos)? { + b'"' => { + parse_string(b, pos)?; + } + b'{' | b'[' => { + let mut stack = vec![b[*pos]]; + *pos += 1; + while let Some(&top) = stack.last() { + let c = *b.get(*pos)?; + if c == b'"' { + parse_string(b, pos)?; + continue; + } + *pos += 1; + match c { + b'{' | b'[' => stack.push(c), + b'}' if top == b'{' => { + stack.pop(); + } + b']' if top == b'[' => { + stack.pop(); + } + _ => {} + } + } + } + _ => { + while let Some(&c) = b.get(*pos) { + if matches!(c, b',' | b'}' | b']') || c.is_ascii_whitespace() { + break; + } + *pos += 1; + } + } + } + Some(()) +} + +/// 解析 `{ "k": "v", ... }` 扁平字符串表;非字符串值跳过该条目。 +fn parse_string_map(b: &[u8], pos: &mut usize) -> Option> { + if b.get(*pos) != Some(&b'{') { + return None; + } + *pos += 1; + let mut pairs = Vec::new(); + loop { + skip_ws(b, pos); + if b.get(*pos) == Some(&b'}') { + *pos += 1; + return Some(pairs); + } + let key = parse_string(b, pos)?; + skip_ws(b, pos); + if b.get(*pos) != Some(&b':') { + return None; + } + *pos += 1; + skip_ws(b, pos); + if let Some(value) = parse_string(b, pos) { + pairs.push((key, value)); + } else { + skip_value(b, pos)?; + } + skip_ws(b, pos); + match b.get(*pos) { + Some(b',') => *pos += 1, + Some(b'}') => continue, + _ => return None, + } + } +} + +/// 解析顶层对象,只保留字符串与扁平字符串表,其余跳过;形状不符时返回已收部分。 +fn parse_top(json: &str) -> Vec<(String, TopValue)> { + let b = json.as_bytes(); + let mut pos = 0; + let mut out = Vec::new(); + skip_ws(b, &mut pos); + if b.get(pos) != Some(&b'{') { + return out; + } + pos += 1; + loop { + skip_ws(b, &mut pos); + if b.get(pos) == Some(&b'}') { + return out; + } + let key = match parse_string(b, &mut pos) { + Some(k) => k, + None => return out, + }; + skip_ws(b, &mut pos); + if b.get(pos) != Some(&b':') { + return out; + } + pos += 1; + skip_ws(b, &mut pos); + if b.get(pos) == Some(&b'{') { + match parse_string_map(b, &mut pos) { + Some(map) => out.push((key, TopValue::Map(map))), + None => return out, + } + } else if let Some(s) = parse_string(b, &mut pos) { + out.push((key, TopValue::Str(s))); + } else if skip_value(b, &mut pos).is_none() { + return out; + } + skip_ws(b, &mut pos); + match b.get(pos) { + Some(b',') => pos += 1, + Some(b'}') => continue, + _ => return out, + } + } +} + +/// 去重:后出现的覆盖先出现(与 Windows `normalizeLookupMap` 一致),保留首次出现顺序。 +fn dedupe(pairs: Vec<(String, String)>) -> Vec<(String, String)> { + let mut out: Vec<(String, String)> = Vec::new(); + for (k, v) in pairs { + if let Some(slot) = out.iter_mut().find(|(ek, _)| *ek == k) { + slot.1 = v; + } else { + out.push((k, v)); + } + } + out +} + +/// 生成 `fn name(key: &str) -> Option<&'static str>` 形式的 `match` 查找函数。 +fn emit_lookup_fn(name: &str, pairs: &[(String, String)]) -> String { + let mut s = format!("fn {name}(key: &str) -> Option<&'static str> {{\n match key {{\n"); + for (k, v) in pairs { + s.push_str(&format!(" {k:?} => Some({v:?}),\n")); + } + s.push_str(" _ => None,\n }\n}\n"); + s +} + +/// 生成 `svg_bytes_for_def`:深色取深色 definition,浅色取浅色 definition。 +/// 浅色缺专属路径时的回退由调用方处理,此处只生成精确分支。 +fn emit_svg_fn(dark: &[(String, String)], light: &[(String, String)]) -> String { + let mut s = String::from( + "fn svg_bytes_for_def(def: &str, is_dark: bool) -> Option<&'static [u8]> {\n match (def, is_dark) {\n", + ); + for (id, path) in dark { + s.push_str(&format!( + " ({id:?}, true) => Some(include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/assets/file-icons/\", {path:?})) as &'static [u8]),\n" + )); + } + for (id, path) in light { + s.push_str(&format!( + " ({id:?}, false) => Some(include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/assets/file-icons/\", {path:?})) as &'static [u8]),\n" + )); + } + s.push_str(" _ => None,\n }\n}\n"); + s +} + +/// 生成 `$OUT_DIR/file_icons_generated.rs` 全文;输入为空或形状不符时退化为空实现。 +fn generate(json: &str) -> String { + let top = parse_top(json); + let str_field = |key: &str| -> &str { + top.iter() + .find_map(|(k, v)| match v { + TopValue::Str(s) if k == key => Some(s.as_str()), + _ => None, + }) + .unwrap_or("") + }; + let raw_table = |key: &str| -> Vec<(String, String)> { + top.iter() + .find_map(|(k, v)| match v { + TopValue::Map(m) if k == key => Some(m.clone()), + _ => None, + }) + .unwrap_or_default() + }; + // 键统一转小写(对齐 Windows `normalizeLookupMap`),扩展名保证前导 `.`。 + let lower = |pairs: Vec<(String, String)>| { + dedupe( + pairs + .into_iter() + .map(|(k, v)| (k.to_lowercase(), v)) + .collect(), + ) + }; + let filenames = lower(raw_table("filenames")); + let file_extensions = dedupe( + raw_table("fileExtensions") + .into_iter() + .map(|(k, v)| { + let k = k.to_lowercase(); + let k = if k.starts_with('.') { + k + } else { + format!(".{k}") + }; + (k, v) + }) + .collect(), + ); + let folders = lower(raw_table("folders")); + let expanded_folders = lower(raw_table("expandedFolders")); + // definition 路径去掉 `./` 后拼到 `linux/assets/file-icons/` 即真实文件。 + let strip_dot = |pairs: Vec<(String, String)>| { + dedupe( + pairs + .into_iter() + .map(|(k, v)| { + let v = v.strip_prefix("./").unwrap_or(&v).to_string(); + (k, v) + }) + .collect(), + ) + }; + let dark = strip_dot(raw_table("iconDefinitions")); + // 浅色表与深色表是同一批 id 的两套路径:每个 id 生成 `(id, true)` 与 + // `(id, false)` 两支(元组第二元不同,不冲突);深色独有的 id 浅色分支由调用方回退深色。 + let light = strip_dot(raw_table("lightIconDefinitions")); + + let mut s = + String::from("// 由 linux/build.rs 按 assets/file-icons/map.json 生成,不要手改。\n"); + s.push_str(&format!( + "fn default_file_def() -> &'static str {{ {:?} }}\n", + str_field("defaultFile") + )); + s.push_str(&format!( + "fn default_folder_def() -> &'static str {{ {:?} }}\n", + str_field("defaultFolder") + )); + s.push_str(&format!( + "fn default_folder_open_def() -> &'static str {{ {:?} }}\n", + str_field("defaultFolderOpen") + )); + s.push_str(&emit_lookup_fn("filename_def", &filenames)); + s.push_str(&emit_lookup_fn("file_extension_def", &file_extensions)); + s.push_str(&emit_lookup_fn("folder_def", &folders)); + s.push_str(&emit_lookup_fn("expanded_folder_def", &expanded_folders)); + s.push_str(&emit_svg_fn(&dark, &light)); + s +} diff --git a/linux/src/workbench/editor.rs b/linux/src/workbench/editor.rs index 3bdfbc82a..a8c31fab7 100644 --- a/linux/src/workbench/editor.rs +++ b/linux/src/workbench/editor.rs @@ -882,6 +882,10 @@ impl Render for EditorView { let active_tab = self .active_tab_index .and_then(|idx| self.tabs.get(idx).cloned()); + let is_dark = { + let s = settings::get(cx); + !crate::theme::ThemePalette::is_light(&settings::resolved_theme_id(s, false)) + }; let editor_state = self.editor_state.clone(); @@ -902,11 +906,6 @@ impl Render for EditorView { let is_active = self.active_tab_index == Some(idx); let title = tab.title.clone(); let is_dirty = tab.is_dirty; - let icon_name = if is_code_file(&title) { - IconName::FileCode - } else { - IconName::FileText - }; h_flex() .id(idx) @@ -926,13 +925,31 @@ impl Render for EditorView { cx.notify(); })) .child( - Icon::new(icon_name) - .size(px(14.0)) - .text_color(if is_active { - ThemeColors::accent_blue() + if let Some(arc) = + crate::workbench::file_icon::file_image(&title, is_dark) + { + div() + .size(px(14.0)) + .child( + gpui_kit::img(gpui_kit::ImageSource::Image(arc)) + .size_full(), + ) + .into_any_element() + } else { + let icon_name = if is_code_file(&title) { + IconName::FileCode } else { - ThemeColors::text_muted() - }), + IconName::FileText + }; + Icon::new(icon_name) + .size(px(14.0)) + .text_color(if is_active { + ThemeColors::accent_blue() + } else { + ThemeColors::text_muted() + }) + .into_any_element() + }, ) .child( div() diff --git a/linux/src/workbench/file_icon.rs b/linux/src/workbench/file_icon.rs new file mode 100644 index 000000000..2c102ac00 --- /dev/null +++ b/linux/src/workbench/file_icon.rs @@ -0,0 +1,107 @@ +//! 工作台文件/目录图标查找:复刻 Windows 图标主题语义。 +//! +//! basename 转小写后先查 `filenames` 精确匹配,再按 `.` 切分做多后缀候选 +//! (如 `a.d.ts` 依次试 `.d.ts`、`.ts`,空首段无候选)查 `fileExtensions`; +//! 目录按小写目录名查 `folders`/`expandedFolders`;都未命中用默认图标; +//! 浅色主题优先 `lightIconDefinitions` 同 id 的路径,无则回退深色路径。 +//! 查找表与 SVG 字节由 `linux/build.rs` 在编译期生成,运行期只做查表、 +//! 构造与进程级缓存。 + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +include!(concat!(env!("OUT_DIR"), "/file_icons_generated.rs")); + +/// 进程级解码图标缓存:键为(图标定义 id,是否深色主题)。 +static ICON_CACHE: OnceLock>>> = OnceLock::new(); + +/// 取路径最后一段,同时处理 `/` 与 `\`(与 Windows 端 basename 语义一致)。 +fn basename(path: &str) -> &str { + path.rsplit(|c| c == '/' || c == '\\') + .next() + .unwrap_or(path) +} + +/// 转小写待查名;basename 为空时回退整串转小写(对齐 Windows 端的 `||` 兜底)。 +fn lookup_name(path: &str) -> String { + let base = basename(path); + if base.is_empty() { + path.to_lowercase() + } else { + base.to_lowercase() + } +} + +/// 多后缀候选:`a.d.ts` 依次产出 `.d.ts`、`.ts`;首段为空(点文件)时无候选。 +fn extension_candidates(lower_name: &str) -> Vec { + let parts: Vec<&str> = lower_name.split('.').collect(); + if parts.len() < 2 || parts[0].is_empty() { + return Vec::new(); + } + (1..parts.len()) + .map(|i| format!(".{}", parts[i..].join("."))) + .collect() +} + +/// 按定义 id 取 SVG 字节并构造缓存的 [`gpui_kit::Image`];SVG 缺失返回 `None`。 +/// +/// 浅色主题先查浅色 definition,无专属路径时回退深色路径。 +fn load_icon(def: &str, is_dark: bool) -> Option> { + if def.is_empty() { + return None; + } + let cache = ICON_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + let key = (def.to_string(), is_dark); + if let Some(hit) = cache.lock().expect("文件图标缓存锁中毒").get(&key) { + return Some(hit.clone()); + } + let bytes = svg_bytes_for_def(def, is_dark) + .or_else(|| (!is_dark).then(|| svg_bytes_for_def(def, true)).flatten())?; + let image = Arc::new(gpui_kit::Image::from_bytes( + gpui_kit::ImageFormat::Svg, + bytes.to_vec(), + )); + cache + .lock() + .expect("文件图标缓存锁中毒") + .insert(key, image.clone()); + Some(image) +} + +/// 按文件名查找文件图标;只取 basename,未命中任何表时用默认文件图标。 +/// +/// 入参与查表键均转小写匹配;对应 SVG 缺失时返回 `None`。 +pub fn file_image(file_name: &str, is_dark: bool) -> Option> { + let name = lookup_name(file_name); + let def = filename_def(&name) + .or_else(|| { + extension_candidates(&name) + .iter() + .find_map(|ext| file_extension_def(ext)) + }) + .unwrap_or_else(default_file_def); + load_icon(def, is_dark) +} + +/// 按目录名查找目录图标;只取 basename,未命中时用默认目录图标。 +/// +/// `expanded` 为真时优先查 `expandedFolders`,再查 `folders`,最后用 +/// `defaultFolderOpen`(缺失则用 `defaultFolder`);为假时只查 `folders`。 +pub fn folder_image(dir_name: &str, expanded: bool, is_dark: bool) -> Option> { + let name = lookup_name(dir_name); + let def = if expanded { + expanded_folder_def(&name) + .or_else(|| folder_def(&name)) + .unwrap_or_else(|| { + let open = default_folder_open_def(); + if open.is_empty() { + default_folder_def() + } else { + open + } + }) + } else { + folder_def(&name).unwrap_or_else(default_folder_def) + }; + load_icon(def, is_dark) +} diff --git a/linux/src/workbench/mod.rs b/linux/src/workbench/mod.rs index 069f485e4..d8af16c5f 100644 --- a/linux/src/workbench/mod.rs +++ b/linux/src/workbench/mod.rs @@ -4,6 +4,7 @@ pub mod branch_manager; pub mod command_palette; pub mod editor; pub mod extensions_panel; +pub mod file_icon; pub mod go_to_line; pub mod maven; pub mod notifications; @@ -33,6 +34,8 @@ pub use editor::EditorView; #[allow(unused_imports)] pub use extensions_panel::{ExtensionsEvent, ExtensionsView}; #[allow(unused_imports)] +pub use file_icon::{file_image, folder_image}; +#[allow(unused_imports)] pub use go_to_line::{GoToLineEvent, GoToLineModal}; #[allow(unused_imports)] pub use maven::{MavenEvent, MavenView}; diff --git a/linux/src/workbench/quick_open.rs b/linux/src/workbench/quick_open.rs index a9d7138fd..123e9ca2d 100644 --- a/linux/src/workbench/quick_open.rs +++ b/linux/src/workbench/quick_open.rs @@ -239,6 +239,10 @@ impl Render for QuickOpenModal { let rows = self.build_rows(); let current_index = self.current_index(); let mode = self.mode; + let is_dark = { + let s = crate::settings::get(cx); + !crate::theme::ThemePalette::is_light(&crate::settings::resolved_theme_id(s, false)) + }; // 全屏半透明遮罩:点击空白处关闭 div() @@ -412,13 +416,30 @@ impl Render for QuickOpenModal { row.hover(|h| h.bg(ThemeColors::accent())) }) .child( - Icon::new(IconName::FileText) - .size(px(14.0)) - .text_color(if is_selected { - ThemeColors::primary() - } else { - ThemeColors::muted_foreground() - }), + if let Some(arc) = + crate::workbench::file_icon::file_image( + &name, is_dark, + ) + { + div() + .size(px(14.0)) + .child( + gpui_kit::img( + gpui_kit::ImageSource::Image(arc), + ) + .size_full(), + ) + .into_any_element() + } else { + Icon::new(IconName::FileText) + .size(px(14.0)) + .text_color(if is_selected { + ThemeColors::primary() + } else { + ThemeColors::muted_foreground() + }) + .into_any_element() + }, ) .child( div() diff --git a/linux/src/workbench/sidebar.rs b/linux/src/workbench/sidebar.rs index 630bfa696..8b7882287 100644 --- a/linux/src/workbench/sidebar.rs +++ b/linux/src/workbench/sidebar.rs @@ -800,6 +800,7 @@ impl SidebarView { let show_hidden = s.show_hidden_files_in_file_tree; let indent_size = s.file_tree_indent_size; let show_icons = s.show_file_icons_in_file_tree; + let is_dark = !crate::theme::ThemePalette::is_light(&settings::resolved_theme_id(s, false)); let query = self.tree_filter.trim().to_lowercase(); let mut visible_items = Vec::new(); @@ -895,9 +896,24 @@ impl SidebarView { .text_color(ThemeColors::text_muted()), ) .child( - Icon::new(IconName::FolderOpen) - .size(px(14.0)) - .text_color(ThemeColors::accent_blue()), + if let Some(arc) = crate::workbench::file_icon::folder_image( + &item.name, + item.is_expanded, + is_dark, + ) { + div() + .size(px(14.0)) + .child( + gpui_kit::img(gpui_kit::ImageSource::Image(arc)) + .size_full(), + ) + .into_any_element() + } else { + Icon::new(IconName::FolderOpen) + .size(px(14.0)) + .text_color(ThemeColors::accent_blue()) + .into_any_element() + }, ) .into_any_element() } else { @@ -910,27 +926,55 @@ impl SidebarView { .text_color(ThemeColors::text_muted()), ) .child( - Icon::new(IconName::Folder) - .size(px(14.0)) - .text_color(ThemeColors::accent_blue()), + if let Some(arc) = crate::workbench::file_icon::folder_image( + &item.name, + item.is_expanded, + is_dark, + ) { + div() + .size(px(14.0)) + .child( + gpui_kit::img(gpui_kit::ImageSource::Image(arc)) + .size_full(), + ) + .into_any_element() + } else { + Icon::new(IconName::Folder) + .size(px(14.0)) + .text_color(ThemeColors::accent_blue()) + .into_any_element() + }, ) .into_any_element() } } else { - let icon_name = if is_code_file(&item.name) { - IconName::FileCode - } else { - IconName::FileText - }; h_flex() .items_center() .gap_1() .child(div().w(px(12.0))) .when(show_icons, |row| { row.child( - Icon::new(icon_name) - .size(px(14.0)) - .text_color(ThemeColors::text_muted()), + if let Some(arc) = + crate::workbench::file_icon::file_image(&item.name, is_dark) + { + div() + .size(px(14.0)) + .child( + gpui_kit::img(gpui_kit::ImageSource::Image(arc)) + .size_full(), + ) + .into_any_element() + } else { + let icon_name = if is_code_file(&item.name) { + IconName::FileCode + } else { + IconName::FileText + }; + Icon::new(icon_name) + .size(px(14.0)) + .text_color(ThemeColors::text_muted()) + .into_any_element() + }, ) }) .into_any_element() From 9392c98ab54f67756aa189ff48cf9ee2b91e6be3 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Thu, 24 Sep 2026 23:54:39 +0800 Subject: [PATCH 028/109] =?UTF-8?q?feat(linux):=20=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E5=88=87=20Windows=20=E9=BB=98=E8=AE=A4=20idea=20=E5=A5=97?= =?UTF-8?q?=E8=A3=85=E5=B9=B6=E5=90=AF=E7=94=A8=20tree-sitter=20=E9=AB=98?= =?UTF-8?q?=E4=BA=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- linux/Cargo.toml | 14 +++++++++++++- .../file-icons/icons/expui/fileTypes/Csharp.svg | 3 +++ .../icons/expui/fileTypes/Csharp_dark.svg | 3 +++ .../file-icons/icons/expui/fileTypes/archive.svg | 8 ++++++++ .../icons/expui/fileTypes/archive_dark.svg | 8 ++++++++ .../assets/file-icons/icons/expui/fileTypes/c.svg | 4 ++++ .../file-icons/icons/expui/fileTypes/c_dark.svg | 4 ++++ .../file-icons/icons/expui/fileTypes/cpp.svg | 7 +++++++ .../file-icons/icons/expui/fileTypes/cpp_dark.svg | 7 +++++++ .../file-icons/icons/expui/fileTypes/css.svg | 5 +++++ .../file-icons/icons/expui/fileTypes/css_dark.svg | 5 +++++ .../file-icons/icons/expui/fileTypes/csv.svg | 7 +++++++ .../file-icons/icons/expui/fileTypes/csv_dark.svg | 7 +++++++ .../file-icons/icons/expui/fileTypes/docker.svg | 5 +++++ .../icons/expui/fileTypes/docker_dark.svg | 5 +++++ .../icons/expui/fileTypes/editorConfig.svg | 4 ++++ .../icons/expui/fileTypes/editorConfig_dark.svg | 4 ++++ .../file-icons/icons/expui/fileTypes/font.svg | 5 +++++ .../file-icons/icons/expui/fileTypes/font_dark.svg | 5 +++++ .../file-icons/icons/expui/fileTypes/gitignore.svg | 4 ++++ .../file-icons/icons/expui/fileTypes/gradle.svg | 5 +++++ .../icons/expui/fileTypes/gradle_dark.svg | 5 +++++ .../file-icons/icons/expui/fileTypes/graphql.svg | 4 ++++ .../icons/expui/fileTypes/graphql_dark.svg | 4 ++++ .../file-icons/icons/expui/fileTypes/groovy.svg | 5 +++++ .../icons/expui/fileTypes/groovy_dark.svg | 5 +++++ .../assets/file-icons/icons/expui/fileTypes/h.svg | 6 ++++++ .../file-icons/icons/expui/fileTypes/h_dark.svg | 6 ++++++ .../file-icons/icons/expui/fileTypes/html.svg | 5 +++++ .../file-icons/icons/expui/fileTypes/html_dark.svg | 5 +++++ .../file-icons/icons/expui/fileTypes/http.svg | 6 ++++++ .../file-icons/icons/expui/fileTypes/image.svg | 6 ++++++ .../icons/expui/fileTypes/image_dark.svg | 6 ++++++ .../file-icons/icons/expui/fileTypes/java.svg | 6 ++++++ .../icons/expui/fileTypes/javaScript.svg | 6 ++++++ .../icons/expui/fileTypes/javaScript_dark.svg | 6 ++++++ .../file-icons/icons/expui/fileTypes/java_dark.svg | 6 ++++++ .../file-icons/icons/expui/fileTypes/json.svg | 5 +++++ .../file-icons/icons/expui/fileTypes/json_dark.svg | 5 +++++ .../file-icons/icons/expui/fileTypes/jsp.svg | 9 +++++++++ .../file-icons/icons/expui/fileTypes/jsp_dark.svg | 9 +++++++++ .../file-icons/icons/expui/fileTypes/markdown.svg | 5 +++++ .../icons/expui/fileTypes/markdown_dark.svg | 5 +++++ .../file-icons/icons/expui/fileTypes/maven.svg | 4 ++++ .../icons/expui/fileTypes/maven_dark.svg | 4 ++++ .../file-icons/icons/expui/fileTypes/patch.svg | 5 +++++ .../icons/expui/fileTypes/patch_dark.svg | 5 +++++ .../file-icons/icons/expui/fileTypes/perl.svg | 4 ++++ .../file-icons/icons/expui/fileTypes/perl_dark.svg | 4 ++++ .../icons/expui/fileTypes/powershell.svg | 7 +++++++ .../icons/expui/fileTypes/powershell_dark.svg | 7 +++++++ .../icons/expui/fileTypes/properties.svg | 4 ++++ .../icons/expui/fileTypes/properties_dark.svg | 4 ++++ .../file-icons/icons/expui/fileTypes/rst.svg | 8 ++++++++ .../file-icons/icons/expui/fileTypes/rst_dark.svg | 8 ++++++++ .../file-icons/icons/expui/fileTypes/shell.svg | 6 ++++++ .../icons/expui/fileTypes/shell_dark.svg | 6 ++++++ .../file-icons/icons/expui/fileTypes/sql.svg | 10 ++++++++++ .../file-icons/icons/expui/fileTypes/sql_dark.svg | 10 ++++++++++ .../file-icons/icons/expui/fileTypes/swiftLang.svg | 3 +++ .../icons/expui/fileTypes/swiftLang_dark.svg | 3 +++ .../file-icons/icons/expui/fileTypes/terraform.svg | 6 ++++++ .../icons/expui/fileTypes/terraform_dark.svg | 6 ++++++ .../file-icons/icons/expui/fileTypes/text.svg | 7 +++++++ .../file-icons/icons/expui/fileTypes/text_dark.svg | 7 +++++++ .../file-icons/icons/expui/fileTypes/toml.svg | 5 +++++ .../file-icons/icons/expui/fileTypes/toml_dark.svg | 5 +++++ .../icons/expui/fileTypes/typeScript.svg | 6 ++++++ .../icons/expui/fileTypes/typeScript_dark.svg | 6 ++++++ .../file-icons/icons/expui/fileTypes/vue.svg | 6 ++++++ .../file-icons/icons/expui/fileTypes/wsdl.svg | 8 ++++++++ .../file-icons/icons/expui/fileTypes/wsdl_dark.svg | 8 ++++++++ .../file-icons/icons/expui/fileTypes/xhtml.svg | 5 +++++ .../icons/expui/fileTypes/xhtml_dark.svg | 5 +++++ .../file-icons/icons/expui/fileTypes/xml.svg | 4 ++++ .../file-icons/icons/expui/fileTypes/xml_dark.svg | 4 ++++ .../file-icons/icons/expui/fileTypes/xsd.svg | 9 +++++++++ .../file-icons/icons/expui/fileTypes/xsd_dark.svg | 9 +++++++++ .../file-icons/icons/expui/fileTypes/yaml.svg | 7 +++++++ .../file-icons/icons/expui/fileTypes/yaml_dark.svg | 7 +++++++ .../file-icons/icons/expui/nodes/annotation.svg | 5 +++++ .../icons/expui/nodes/annotation_dark.svg | 5 +++++ .../assets/file-icons/icons/expui/nodes/class.svg | 5 +++++ .../file-icons/icons/expui/nodes/class_dark.svg | 5 +++++ linux/assets/file-icons/icons/expui/nodes/enum.svg | 5 +++++ .../file-icons/icons/expui/nodes/enum_dark.svg | 5 +++++ .../file-icons/icons/expui/nodes/exception.svg | 5 +++++ .../icons/expui/nodes/exception_dark.svg | 5 +++++ .../assets/file-icons/icons/expui/nodes/folder.svg | 4 ++++ .../file-icons/icons/expui/nodes/folder_dark.svg | 4 ++++ .../file-icons/icons/expui/nodes/interface.svg | 5 +++++ .../icons/expui/nodes/interface_dark.svg | 5 +++++ .../file-icons/icons/expui/nodes/package.svg | 5 +++++ .../file-icons/icons/expui/nodes/package_dark.svg | 5 +++++ .../assets/file-icons/icons/expui/nodes/record.svg | 5 +++++ .../file-icons/icons/expui/nodes/record_dark.svg | 5 +++++ .../file-icons/icons/expui/nodes/resourcesRoot.svg | 8 ++++++++ .../icons/expui/nodes/resourcesRoot_dark.svg | 8 ++++++++ .../file-icons/icons/expui/nodes/sourceRoot.svg | 4 ++++ .../icons/expui/nodes/sourceRoot_dark.svg | 4 ++++ .../icons/expui/nodes/testResourcesRoot.svg | 8 ++++++++ .../icons/expui/nodes/testResourcesRoot_dark.svg | 8 ++++++++ .../file-icons/icons/expui/nodes/testRoot.svg | 4 ++++ .../file-icons/icons/expui/nodes/testRoot_dark.svg | 4 ++++ linux/assets/file-icons/icons/files/adobe.svg | 7 ------- linux/assets/file-icons/icons/files/agents.svg | 7 ------- linux/assets/file-icons/icons/files/android.svg | 7 ------- linux/assets/file-icons/icons/files/angular.svg | 7 ------- linux/assets/file-icons/icons/files/ansible.svg | 7 ------- linux/assets/file-icons/icons/files/apple.svg | 7 ------- linux/assets/file-icons/icons/files/archive.svg | 7 ------- linux/assets/file-icons/icons/files/arduino.svg | 7 ------- linux/assets/file-icons/icons/files/astro.svg | 7 ------- linux/assets/file-icons/icons/files/audio.svg | 7 ------- linux/assets/file-icons/icons/files/authors.svg | 7 ------- linux/assets/file-icons/icons/files/babel.svg | 7 ------- linux/assets/file-icons/icons/files/biome.svg | 7 ------- linux/assets/file-icons/icons/files/bitbucket.svg | 7 ------- linux/assets/file-icons/icons/files/blender.svg | 7 ------- linux/assets/file-icons/icons/files/bun.svg | 7 ------- linux/assets/file-icons/icons/files/c.svg | 7 ------- .../assets/file-icons/icons/files/certificate.svg | 7 ------- linux/assets/file-icons/icons/files/changelog.svg | 7 ------- linux/assets/file-icons/icons/files/claude.svg | 7 ------- linux/assets/file-icons/icons/files/cline.svg | 7 ------- linux/assets/file-icons/icons/files/clojure.svg | 7 ------- linux/assets/file-icons/icons/files/cloudflare.svg | 7 ------- linux/assets/file-icons/icons/files/cmake.svg | 7 ------- linux/assets/file-icons/icons/files/codex.svg | 7 ------- linux/assets/file-icons/icons/files/commitlint.svg | 7 ------- linux/assets/file-icons/icons/files/config.svg | 7 ------- linux/assets/file-icons/icons/files/copilot.svg | 7 ------- linux/assets/file-icons/icons/files/cpp.svg | 7 ------- linux/assets/file-icons/icons/files/csharp.svg | 7 ------- linux/assets/file-icons/icons/files/cspell.svg | 7 ------- linux/assets/file-icons/icons/files/css.svg | 7 ------- linux/assets/file-icons/icons/files/csv.svg | 7 ------- linux/assets/file-icons/icons/files/cursor.svg | 7 ------- linux/assets/file-icons/icons/files/cypress.svg | 7 ------- linux/assets/file-icons/icons/files/dart.svg | 7 ------- linux/assets/file-icons/icons/files/database.svg | 7 ------- linux/assets/file-icons/icons/files/deno.svg | 7 ------- linux/assets/file-icons/icons/files/dependabot.svg | 7 ------- .../assets/file-icons/icons/files/devcontainer.svg | 7 ------- linux/assets/file-icons/icons/files/diff.svg | 7 ------- linux/assets/file-icons/icons/files/django.svg | 7 ------- .../file-icons/icons/files/docker-compose.svg | 7 ------- linux/assets/file-icons/icons/files/docker.svg | 7 ------- linux/assets/file-icons/icons/files/document.svg | 7 ------- linux/assets/file-icons/icons/files/docusaurus.svg | 7 ------- linux/assets/file-icons/icons/files/drawio.svg | 7 ------- linux/assets/file-icons/icons/files/drizzle.svg | 7 ------- .../assets/file-icons/icons/files/editorconfig.svg | 7 ------- linux/assets/file-icons/icons/files/electron.svg | 7 ------- linux/assets/file-icons/icons/files/elixir.svg | 7 ------- linux/assets/file-icons/icons/files/env.svg | 7 ------- linux/assets/file-icons/icons/files/erlang.svg | 7 ------- linux/assets/file-icons/icons/files/error.svg | 7 ------- linux/assets/file-icons/icons/files/eslint.svg | 7 ------- linux/assets/file-icons/icons/files/excalidraw.svg | 7 ------- linux/assets/file-icons/icons/files/fastapi.svg | 7 ------- linux/assets/file-icons/icons/files/figma.svg | 7 ------- linux/assets/file-icons/icons/files/file.svg | 7 ------- linux/assets/file-icons/icons/files/firebase.svg | 7 ------- linux/assets/file-icons/icons/files/flask.svg | 7 ------- linux/assets/file-icons/icons/files/font.svg | 7 ------- linux/assets/file-icons/icons/files/gatsby.svg | 7 ------- linux/assets/file-icons/icons/files/gemini.svg | 7 ------- linux/assets/file-icons/icons/files/git.svg | 7 ------- .../file-icons/icons/files/github-actions.svg | 7 ------- linux/assets/file-icons/icons/files/github.svg | 7 ------- linux/assets/file-icons/icons/files/gitlab.svg | 7 ------- linux/assets/file-icons/icons/files/go.svg | 7 ------- linux/assets/file-icons/icons/files/gradle.svg | 7 ------- .../file-icons/icons/files/graphql-schema.svg | 7 ------- linux/assets/file-icons/icons/files/graphql.svg | 7 ------- linux/assets/file-icons/icons/files/haskell.svg | 7 ------- linux/assets/file-icons/icons/files/helm.svg | 7 ------- linux/assets/file-icons/icons/files/html.svg | 7 ------- linux/assets/file-icons/icons/files/http.svg | 7 ------- linux/assets/file-icons/icons/files/hurl.svg | 7 ------- linux/assets/file-icons/icons/files/image.svg | 7 ------- linux/assets/file-icons/icons/files/index-js.svg | 7 ------- linux/assets/file-icons/icons/files/index-ts.svg | 7 ------- linux/assets/file-icons/icons/files/java.svg | 7 ------- linux/assets/file-icons/icons/files/javascript.svg | 7 ------- linux/assets/file-icons/icons/files/jenkins.svg | 7 ------- linux/assets/file-icons/icons/files/jest.svg | 7 ------- linux/assets/file-icons/icons/files/jsconfig.svg | 7 ------- linux/assets/file-icons/icons/files/json.svg | 7 ------- linux/assets/file-icons/icons/files/julia.svg | 7 ------- linux/assets/file-icons/icons/files/key.svg | 7 ------- linux/assets/file-icons/icons/files/kotlin.svg | 7 ------- linux/assets/file-icons/icons/files/kubernetes.svg | 7 ------- linux/assets/file-icons/icons/files/laravel.svg | 7 ------- linux/assets/file-icons/icons/files/layout.svg | 7 ------- linux/assets/file-icons/icons/files/license.svg | 7 ------- linux/assets/file-icons/icons/files/lintstaged.svg | 7 ------- linux/assets/file-icons/icons/files/linux.svg | 7 ------- linux/assets/file-icons/icons/files/lit.svg | 7 ------- linux/assets/file-icons/icons/files/lithe.svg | 7 ------- linux/assets/file-icons/icons/files/loading.svg | 7 ------- linux/assets/file-icons/icons/files/lock.svg | 7 ------- linux/assets/file-icons/icons/files/log.svg | 7 ------- linux/assets/file-icons/icons/files/lua.svg | 7 ------- linux/assets/file-icons/icons/files/makefile.svg | 7 ------- linux/assets/file-icons/icons/files/markdown.svg | 7 ------- .../assets/file-icons/icons/files/markdownlint.svg | 7 ------- linux/assets/file-icons/icons/files/maven.svg | 7 ------- linux/assets/file-icons/icons/files/mcp.svg | 7 ------- linux/assets/file-icons/icons/files/mermaid.svg | 7 ------- linux/assets/file-icons/icons/files/mongo.svg | 7 ------- linux/assets/file-icons/icons/files/netlify.svg | 7 ------- linux/assets/file-icons/icons/files/next.svg | 7 ------- linux/assets/file-icons/icons/files/nginx.svg | 7 ------- linux/assets/file-icons/icons/files/nim.svg | 7 ------- linux/assets/file-icons/icons/files/nix.svg | 7 ------- linux/assets/file-icons/icons/files/node.svg | 7 ------- linux/assets/file-icons/icons/files/not-found.svg | 7 ------- linux/assets/file-icons/icons/files/notebook.svg | 7 ------- linux/assets/file-icons/icons/files/npm.svg | 7 ------- linux/assets/file-icons/icons/files/nuxt.svg | 7 ------- linux/assets/file-icons/icons/files/nx.svg | 7 ------- linux/assets/file-icons/icons/files/ocaml.svg | 7 ------- linux/assets/file-icons/icons/files/package.svg | 7 ------- linux/assets/file-icons/icons/files/page.svg | 7 ------- linux/assets/file-icons/icons/files/patch.svg | 7 ------- linux/assets/file-icons/icons/files/pdf.svg | 7 ------- linux/assets/file-icons/icons/files/perl.svg | 7 ------- linux/assets/file-icons/icons/files/php.svg | 7 ------- linux/assets/file-icons/icons/files/playwright.svg | 7 ------- linux/assets/file-icons/icons/files/pnpm.svg | 7 ------- linux/assets/file-icons/icons/files/postgres.svg | 7 ------- linux/assets/file-icons/icons/files/powerpoint.svg | 7 ------- linux/assets/file-icons/icons/files/prettier.svg | 7 ------- linux/assets/file-icons/icons/files/prisma.svg | 7 ------- linux/assets/file-icons/icons/files/proto.svg | 7 ------- linux/assets/file-icons/icons/files/python.svg | 7 ------- linux/assets/file-icons/icons/files/qwik.svg | 7 ------- linux/assets/file-icons/icons/files/r.svg | 7 ------- linux/assets/file-icons/icons/files/react.svg | 7 ------- linux/assets/file-icons/icons/files/redis.svg | 7 ------- linux/assets/file-icons/icons/files/remix.svg | 7 ------- linux/assets/file-icons/icons/files/renovate.svg | 7 ------- linux/assets/file-icons/icons/files/rescript.svg | 7 ------- linux/assets/file-icons/icons/files/rollup.svg | 7 ------- linux/assets/file-icons/icons/files/route.svg | 7 ------- linux/assets/file-icons/icons/files/rspack.svg | 7 ------- linux/assets/file-icons/icons/files/ruby.svg | 7 ------- linux/assets/file-icons/icons/files/rust.svg | 7 ------- linux/assets/file-icons/icons/files/sass.svg | 7 ------- linux/assets/file-icons/icons/files/scala.svg | 7 ------- linux/assets/file-icons/icons/files/security.svg | 7 ------- linux/assets/file-icons/icons/files/shell.svg | 7 ------- linux/assets/file-icons/icons/files/sketch.svg | 7 ------- linux/assets/file-icons/icons/files/solid.svg | 7 ------- linux/assets/file-icons/icons/files/solidity.svg | 7 ------- .../assets/file-icons/icons/files/spreadsheet.svg | 7 ------- linux/assets/file-icons/icons/files/sql.svg | 7 ------- linux/assets/file-icons/icons/files/storybook.svg | 7 ------- linux/assets/file-icons/icons/files/stylelint.svg | 7 ------- linux/assets/file-icons/icons/files/supabase.svg | 7 ------- linux/assets/file-icons/icons/files/svelte.svg | 7 ------- linux/assets/file-icons/icons/files/svg.svg | 7 ------- linux/assets/file-icons/icons/files/swc.svg | 7 ------- linux/assets/file-icons/icons/files/swift.svg | 7 ------- linux/assets/file-icons/icons/files/tailwind.svg | 7 ------- linux/assets/file-icons/icons/files/tauri.svg | 7 ------- linux/assets/file-icons/icons/files/terraform.svg | 7 ------- linux/assets/file-icons/icons/files/test.svg | 7 ------- linux/assets/file-icons/icons/files/text.svg | 7 ------- linux/assets/file-icons/icons/files/toml.svg | 7 ------- linux/assets/file-icons/icons/files/turborepo.svg | 7 ------- linux/assets/file-icons/icons/files/typescript.svg | 7 ------- .../file-icons/icons/files/vercel-config.svg | 7 ------- linux/assets/file-icons/icons/files/vercel.svg | 7 ------- linux/assets/file-icons/icons/files/video.svg | 7 ------- linux/assets/file-icons/icons/files/vite.svg | 7 ------- linux/assets/file-icons/icons/files/vitest.svg | 7 ------- linux/assets/file-icons/icons/files/vue.svg | 7 ------- linux/assets/file-icons/icons/files/warning.svg | 7 ------- linux/assets/file-icons/icons/files/wasm.svg | 7 ------- linux/assets/file-icons/icons/files/webpack.svg | 7 ------- linux/assets/file-icons/icons/files/windows.svg | 7 ------- linux/assets/file-icons/icons/files/word.svg | 7 ------- linux/assets/file-icons/icons/files/xcode.svg | 7 ------- linux/assets/file-icons/icons/files/xml.svg | 7 ------- linux/assets/file-icons/icons/files/yaml.svg | 7 ------- linux/assets/file-icons/icons/files/yarn.svg | 7 ------- linux/assets/file-icons/icons/files/zig.svg | 7 ------- .../file-icons/icons/folders/folder-ai-open.svg | 5 ----- .../assets/file-icons/icons/folders/folder-ai.svg | 5 ----- .../icons/folders/folder-assets-open.svg | 5 ----- .../file-icons/icons/folders/folder-assets.svg | 5 ----- .../file-icons/icons/folders/folder-build-open.svg | 5 ----- .../file-icons/icons/folders/folder-build.svg | 5 ----- .../file-icons/icons/folders/folder-cloud-open.svg | 5 ----- .../file-icons/icons/folders/folder-cloud.svg | 5 ----- .../icons/folders/folder-components-open.svg | 5 ----- .../file-icons/icons/folders/folder-components.svg | 5 ----- .../icons/folders/folder-config-open.svg | 5 ----- .../file-icons/icons/folders/folder-config.svg | 5 ----- .../icons/folders/folder-database-open.svg | 5 ----- .../file-icons/icons/folders/folder-database.svg | 5 ----- .../file-icons/icons/folders/folder-docs-open.svg | 5 ----- .../file-icons/icons/folders/folder-docs.svg | 5 ----- .../icons/folders/folder-extensions-open.svg | 5 ----- .../file-icons/icons/folders/folder-extensions.svg | 5 ----- .../file-icons/icons/folders/folder-git-open.svg | 5 ----- .../assets/file-icons/icons/folders/folder-git.svg | 5 ----- .../icons/folders/folder-locales-open.svg | 5 ----- .../file-icons/icons/folders/folder-locales.svg | 5 ----- .../icons/folders/folder-mobile-open.svg | 5 ----- .../file-icons/icons/folders/folder-mobile.svg | 5 ----- .../file-icons/icons/folders/folder-open.svg | 4 ---- .../icons/folders/folder-packages-open.svg | 5 ----- .../file-icons/icons/folders/folder-packages.svg | 5 ----- .../icons/folders/folder-routes-open.svg | 5 ----- .../file-icons/icons/folders/folder-routes.svg | 5 ----- .../file-icons/icons/folders/folder-rust-open.svg | 5 ----- .../file-icons/icons/folders/folder-rust.svg | 5 ----- .../icons/folders/folder-scripts-open.svg | 5 ----- .../file-icons/icons/folders/folder-scripts.svg | 5 ----- .../icons/folders/folder-security-open.svg | 5 ----- .../file-icons/icons/folders/folder-security.svg | 5 ----- .../icons/folders/folder-source-open.svg | 5 ----- .../file-icons/icons/folders/folder-source.svg | 5 ----- .../icons/folders/folder-styles-open.svg | 5 ----- .../file-icons/icons/folders/folder-styles.svg | 5 ----- .../file-icons/icons/folders/folder-test-open.svg | 5 ----- .../file-icons/icons/folders/folder-test.svg | 5 ----- linux/assets/file-icons/icons/folders/folder.svg | 4 ---- .../assets/file-icons/icons/light/files/adobe.svg | 7 ------- .../assets/file-icons/icons/light/files/agents.svg | 7 ------- .../file-icons/icons/light/files/android.svg | 7 ------- .../file-icons/icons/light/files/angular.svg | 7 ------- .../file-icons/icons/light/files/ansible.svg | 7 ------- .../assets/file-icons/icons/light/files/apple.svg | 7 ------- .../file-icons/icons/light/files/archive.svg | 7 ------- .../file-icons/icons/light/files/arduino.svg | 7 ------- .../assets/file-icons/icons/light/files/astro.svg | 7 ------- .../assets/file-icons/icons/light/files/audio.svg | 7 ------- .../file-icons/icons/light/files/authors.svg | 7 ------- .../assets/file-icons/icons/light/files/babel.svg | 7 ------- .../assets/file-icons/icons/light/files/biome.svg | 7 ------- .../file-icons/icons/light/files/bitbucket.svg | 7 ------- .../file-icons/icons/light/files/blender.svg | 7 ------- linux/assets/file-icons/icons/light/files/bun.svg | 7 ------- linux/assets/file-icons/icons/light/files/c.svg | 7 ------- .../file-icons/icons/light/files/certificate.svg | 7 ------- .../file-icons/icons/light/files/changelog.svg | 7 ------- .../assets/file-icons/icons/light/files/claude.svg | 7 ------- .../assets/file-icons/icons/light/files/cline.svg | 7 ------- .../file-icons/icons/light/files/clojure.svg | 7 ------- .../file-icons/icons/light/files/cloudflare.svg | 7 ------- .../assets/file-icons/icons/light/files/cmake.svg | 7 ------- .../assets/file-icons/icons/light/files/codex.svg | 7 ------- .../file-icons/icons/light/files/commitlint.svg | 7 ------- .../assets/file-icons/icons/light/files/config.svg | 7 ------- .../file-icons/icons/light/files/copilot.svg | 7 ------- linux/assets/file-icons/icons/light/files/cpp.svg | 7 ------- .../assets/file-icons/icons/light/files/csharp.svg | 7 ------- .../assets/file-icons/icons/light/files/cspell.svg | 7 ------- linux/assets/file-icons/icons/light/files/css.svg | 7 ------- linux/assets/file-icons/icons/light/files/csv.svg | 7 ------- .../assets/file-icons/icons/light/files/cursor.svg | 7 ------- .../file-icons/icons/light/files/cypress.svg | 7 ------- linux/assets/file-icons/icons/light/files/dart.svg | 7 ------- .../file-icons/icons/light/files/database.svg | 7 ------- linux/assets/file-icons/icons/light/files/deno.svg | 7 ------- .../file-icons/icons/light/files/dependabot.svg | 7 ------- .../file-icons/icons/light/files/devcontainer.svg | 7 ------- linux/assets/file-icons/icons/light/files/diff.svg | 7 ------- .../assets/file-icons/icons/light/files/django.svg | 7 ------- .../icons/light/files/docker-compose.svg | 7 ------- .../assets/file-icons/icons/light/files/docker.svg | 7 ------- .../file-icons/icons/light/files/document.svg | 7 ------- .../file-icons/icons/light/files/docusaurus.svg | 7 ------- .../assets/file-icons/icons/light/files/drawio.svg | 7 ------- .../file-icons/icons/light/files/drizzle.svg | 7 ------- .../file-icons/icons/light/files/editorconfig.svg | 7 ------- .../file-icons/icons/light/files/electron.svg | 7 ------- .../assets/file-icons/icons/light/files/elixir.svg | 7 ------- linux/assets/file-icons/icons/light/files/env.svg | 7 ------- .../assets/file-icons/icons/light/files/erlang.svg | 7 ------- .../assets/file-icons/icons/light/files/error.svg | 7 ------- .../assets/file-icons/icons/light/files/eslint.svg | 7 ------- .../file-icons/icons/light/files/excalidraw.svg | 7 ------- .../file-icons/icons/light/files/fastapi.svg | 7 ------- .../assets/file-icons/icons/light/files/figma.svg | 7 ------- linux/assets/file-icons/icons/light/files/file.svg | 7 ------- .../file-icons/icons/light/files/firebase.svg | 7 ------- .../assets/file-icons/icons/light/files/flask.svg | 7 ------- linux/assets/file-icons/icons/light/files/font.svg | 7 ------- .../assets/file-icons/icons/light/files/gatsby.svg | 7 ------- .../assets/file-icons/icons/light/files/gemini.svg | 7 ------- linux/assets/file-icons/icons/light/files/git.svg | 7 ------- .../icons/light/files/github-actions.svg | 7 ------- .../assets/file-icons/icons/light/files/github.svg | 7 ------- .../assets/file-icons/icons/light/files/gitlab.svg | 7 ------- linux/assets/file-icons/icons/light/files/go.svg | 7 ------- .../assets/file-icons/icons/light/files/gradle.svg | 7 ------- .../icons/light/files/graphql-schema.svg | 7 ------- .../file-icons/icons/light/files/graphql.svg | 7 ------- .../file-icons/icons/light/files/haskell.svg | 7 ------- linux/assets/file-icons/icons/light/files/helm.svg | 7 ------- linux/assets/file-icons/icons/light/files/html.svg | 7 ------- linux/assets/file-icons/icons/light/files/http.svg | 7 ------- linux/assets/file-icons/icons/light/files/hurl.svg | 7 ------- .../assets/file-icons/icons/light/files/image.svg | 7 ------- .../file-icons/icons/light/files/index-js.svg | 7 ------- .../file-icons/icons/light/files/index-ts.svg | 7 ------- linux/assets/file-icons/icons/light/files/java.svg | 7 ------- .../file-icons/icons/light/files/javascript.svg | 7 ------- .../file-icons/icons/light/files/jenkins.svg | 7 ------- linux/assets/file-icons/icons/light/files/jest.svg | 7 ------- .../file-icons/icons/light/files/jsconfig.svg | 7 ------- linux/assets/file-icons/icons/light/files/json.svg | 7 ------- .../assets/file-icons/icons/light/files/julia.svg | 7 ------- linux/assets/file-icons/icons/light/files/key.svg | 7 ------- .../assets/file-icons/icons/light/files/kotlin.svg | 7 ------- .../file-icons/icons/light/files/kubernetes.svg | 7 ------- .../file-icons/icons/light/files/laravel.svg | 7 ------- .../assets/file-icons/icons/light/files/layout.svg | 7 ------- .../file-icons/icons/light/files/license.svg | 7 ------- .../file-icons/icons/light/files/lintstaged.svg | 7 ------- .../assets/file-icons/icons/light/files/linux.svg | 7 ------- linux/assets/file-icons/icons/light/files/lit.svg | 7 ------- .../assets/file-icons/icons/light/files/lithe.svg | 7 ------- .../file-icons/icons/light/files/loading.svg | 7 ------- linux/assets/file-icons/icons/light/files/lock.svg | 7 ------- linux/assets/file-icons/icons/light/files/log.svg | 7 ------- linux/assets/file-icons/icons/light/files/lua.svg | 7 ------- .../file-icons/icons/light/files/makefile.svg | 7 ------- .../file-icons/icons/light/files/markdown.svg | 7 ------- .../file-icons/icons/light/files/markdownlint.svg | 7 ------- .../assets/file-icons/icons/light/files/maven.svg | 7 ------- linux/assets/file-icons/icons/light/files/mcp.svg | 7 ------- .../file-icons/icons/light/files/mermaid.svg | 7 ------- .../assets/file-icons/icons/light/files/mongo.svg | 7 ------- .../file-icons/icons/light/files/netlify.svg | 7 ------- linux/assets/file-icons/icons/light/files/next.svg | 7 ------- .../assets/file-icons/icons/light/files/nginx.svg | 7 ------- linux/assets/file-icons/icons/light/files/nim.svg | 7 ------- linux/assets/file-icons/icons/light/files/nix.svg | 7 ------- linux/assets/file-icons/icons/light/files/node.svg | 7 ------- .../file-icons/icons/light/files/not-found.svg | 7 ------- .../file-icons/icons/light/files/notebook.svg | 7 ------- linux/assets/file-icons/icons/light/files/npm.svg | 7 ------- linux/assets/file-icons/icons/light/files/nuxt.svg | 7 ------- linux/assets/file-icons/icons/light/files/nx.svg | 7 ------- .../assets/file-icons/icons/light/files/ocaml.svg | 7 ------- .../file-icons/icons/light/files/package.svg | 7 ------- linux/assets/file-icons/icons/light/files/page.svg | 7 ------- .../assets/file-icons/icons/light/files/patch.svg | 7 ------- linux/assets/file-icons/icons/light/files/pdf.svg | 7 ------- linux/assets/file-icons/icons/light/files/perl.svg | 7 ------- linux/assets/file-icons/icons/light/files/php.svg | 7 ------- .../file-icons/icons/light/files/playwright.svg | 7 ------- linux/assets/file-icons/icons/light/files/pnpm.svg | 7 ------- .../file-icons/icons/light/files/postgres.svg | 7 ------- .../file-icons/icons/light/files/powerpoint.svg | 7 ------- .../file-icons/icons/light/files/prettier.svg | 7 ------- .../assets/file-icons/icons/light/files/prisma.svg | 7 ------- .../assets/file-icons/icons/light/files/proto.svg | 7 ------- .../assets/file-icons/icons/light/files/python.svg | 7 ------- linux/assets/file-icons/icons/light/files/qwik.svg | 7 ------- linux/assets/file-icons/icons/light/files/r.svg | 7 ------- .../assets/file-icons/icons/light/files/react.svg | 7 ------- .../assets/file-icons/icons/light/files/redis.svg | 7 ------- .../assets/file-icons/icons/light/files/remix.svg | 7 ------- .../file-icons/icons/light/files/renovate.svg | 7 ------- .../file-icons/icons/light/files/rescript.svg | 7 ------- .../assets/file-icons/icons/light/files/rollup.svg | 7 ------- .../assets/file-icons/icons/light/files/route.svg | 7 ------- .../assets/file-icons/icons/light/files/rspack.svg | 7 ------- linux/assets/file-icons/icons/light/files/ruby.svg | 7 ------- linux/assets/file-icons/icons/light/files/rust.svg | 7 ------- linux/assets/file-icons/icons/light/files/sass.svg | 7 ------- .../assets/file-icons/icons/light/files/scala.svg | 7 ------- .../file-icons/icons/light/files/security.svg | 7 ------- .../assets/file-icons/icons/light/files/shell.svg | 7 ------- .../assets/file-icons/icons/light/files/sketch.svg | 7 ------- .../assets/file-icons/icons/light/files/solid.svg | 7 ------- .../file-icons/icons/light/files/solidity.svg | 7 ------- .../file-icons/icons/light/files/spreadsheet.svg | 7 ------- linux/assets/file-icons/icons/light/files/sql.svg | 7 ------- .../file-icons/icons/light/files/storybook.svg | 7 ------- .../file-icons/icons/light/files/stylelint.svg | 7 ------- .../file-icons/icons/light/files/supabase.svg | 7 ------- .../assets/file-icons/icons/light/files/svelte.svg | 7 ------- linux/assets/file-icons/icons/light/files/svg.svg | 7 ------- linux/assets/file-icons/icons/light/files/swc.svg | 7 ------- .../assets/file-icons/icons/light/files/swift.svg | 7 ------- .../file-icons/icons/light/files/tailwind.svg | 7 ------- .../assets/file-icons/icons/light/files/tauri.svg | 7 ------- .../file-icons/icons/light/files/terraform.svg | 7 ------- linux/assets/file-icons/icons/light/files/test.svg | 7 ------- linux/assets/file-icons/icons/light/files/text.svg | 7 ------- linux/assets/file-icons/icons/light/files/toml.svg | 7 ------- .../file-icons/icons/light/files/turborepo.svg | 7 ------- .../file-icons/icons/light/files/typescript.svg | 7 ------- .../file-icons/icons/light/files/vercel-config.svg | 7 ------- .../assets/file-icons/icons/light/files/vercel.svg | 7 ------- .../assets/file-icons/icons/light/files/video.svg | 7 ------- linux/assets/file-icons/icons/light/files/vite.svg | 7 ------- .../assets/file-icons/icons/light/files/vitest.svg | 7 ------- linux/assets/file-icons/icons/light/files/vue.svg | 7 ------- .../file-icons/icons/light/files/warning.svg | 7 ------- linux/assets/file-icons/icons/light/files/wasm.svg | 7 ------- .../file-icons/icons/light/files/webpack.svg | 7 ------- .../file-icons/icons/light/files/windows.svg | 7 ------- linux/assets/file-icons/icons/light/files/word.svg | 7 ------- .../assets/file-icons/icons/light/files/xcode.svg | 7 ------- linux/assets/file-icons/icons/light/files/xml.svg | 7 ------- linux/assets/file-icons/icons/light/files/yaml.svg | 7 ------- linux/assets/file-icons/icons/light/files/yarn.svg | 7 ------- linux/assets/file-icons/icons/light/files/zig.svg | 7 ------- .../icons/light/folders/folder-ai-open.svg | 5 ----- .../file-icons/icons/light/folders/folder-ai.svg | 5 ----- .../icons/light/folders/folder-assets-open.svg | 5 ----- .../icons/light/folders/folder-assets.svg | 5 ----- .../icons/light/folders/folder-build-open.svg | 5 ----- .../icons/light/folders/folder-build.svg | 5 ----- .../icons/light/folders/folder-cloud-open.svg | 5 ----- .../icons/light/folders/folder-cloud.svg | 5 ----- .../icons/light/folders/folder-components-open.svg | 5 ----- .../icons/light/folders/folder-components.svg | 5 ----- .../icons/light/folders/folder-config-open.svg | 5 ----- .../icons/light/folders/folder-config.svg | 5 ----- .../icons/light/folders/folder-database-open.svg | 5 ----- .../icons/light/folders/folder-database.svg | 5 ----- .../icons/light/folders/folder-docs-open.svg | 5 ----- .../file-icons/icons/light/folders/folder-docs.svg | 5 ----- .../icons/light/folders/folder-extensions-open.svg | 5 ----- .../icons/light/folders/folder-extensions.svg | 5 ----- .../icons/light/folders/folder-git-open.svg | 5 ----- .../file-icons/icons/light/folders/folder-git.svg | 5 ----- .../icons/light/folders/folder-locales-open.svg | 5 ----- .../icons/light/folders/folder-locales.svg | 5 ----- .../icons/light/folders/folder-mobile-open.svg | 5 ----- .../icons/light/folders/folder-mobile.svg | 5 ----- .../file-icons/icons/light/folders/folder-open.svg | 4 ---- .../icons/light/folders/folder-packages-open.svg | 5 ----- .../icons/light/folders/folder-packages.svg | 5 ----- .../icons/light/folders/folder-routes-open.svg | 5 ----- .../icons/light/folders/folder-routes.svg | 5 ----- .../icons/light/folders/folder-rust-open.svg | 5 ----- .../file-icons/icons/light/folders/folder-rust.svg | 5 ----- .../icons/light/folders/folder-scripts-open.svg | 5 ----- .../icons/light/folders/folder-scripts.svg | 5 ----- .../icons/light/folders/folder-security-open.svg | 5 ----- .../icons/light/folders/folder-security.svg | 5 ----- .../icons/light/folders/folder-source-open.svg | 5 ----- .../icons/light/folders/folder-source.svg | 5 ----- .../icons/light/folders/folder-styles-open.svg | 5 ----- .../icons/light/folders/folder-styles.svg | 5 ----- .../icons/light/folders/folder-test-open.svg | 5 ----- .../file-icons/icons/light/folders/folder-test.svg | 5 ----- .../file-icons/icons/light/folders/folder.svg | 4 ---- linux/assets/file-icons/map.json | 2 +- linux/src/workbench/editor.rs | 7 +++++++ 562 files changed, 599 insertions(+), 3022 deletions(-) create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/Csharp.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/Csharp_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/archive.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/archive_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/c.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/c_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/cpp.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/cpp_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/css.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/css_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/csv.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/csv_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/docker.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/docker_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/editorConfig.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/editorConfig_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/font.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/font_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/gitignore.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/gradle.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/gradle_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/graphql.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/graphql_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/groovy.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/groovy_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/h.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/h_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/html.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/html_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/http.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/image.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/image_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/java.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/javaScript.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/javaScript_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/java_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/json.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/json_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/jsp.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/jsp_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/markdown.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/markdown_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/maven.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/maven_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/patch.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/patch_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/perl.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/perl_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/powershell.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/powershell_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/properties.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/properties_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/rst.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/rst_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/shell.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/shell_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/sql.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/sql_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/swiftLang.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/swiftLang_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/terraform.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/terraform_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/text.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/text_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/toml.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/toml_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/typeScript.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/typeScript_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/vue.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/wsdl.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/wsdl_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/xhtml.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/xhtml_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/xml.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/xml_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/xsd.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/xsd_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/yaml.svg create mode 100644 linux/assets/file-icons/icons/expui/fileTypes/yaml_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/nodes/annotation.svg create mode 100644 linux/assets/file-icons/icons/expui/nodes/annotation_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/nodes/class.svg create mode 100644 linux/assets/file-icons/icons/expui/nodes/class_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/nodes/enum.svg create mode 100644 linux/assets/file-icons/icons/expui/nodes/enum_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/nodes/exception.svg create mode 100644 linux/assets/file-icons/icons/expui/nodes/exception_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/nodes/folder.svg create mode 100644 linux/assets/file-icons/icons/expui/nodes/folder_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/nodes/interface.svg create mode 100644 linux/assets/file-icons/icons/expui/nodes/interface_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/nodes/package.svg create mode 100644 linux/assets/file-icons/icons/expui/nodes/package_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/nodes/record.svg create mode 100644 linux/assets/file-icons/icons/expui/nodes/record_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/nodes/resourcesRoot.svg create mode 100644 linux/assets/file-icons/icons/expui/nodes/resourcesRoot_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/nodes/sourceRoot.svg create mode 100644 linux/assets/file-icons/icons/expui/nodes/sourceRoot_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/nodes/testResourcesRoot.svg create mode 100644 linux/assets/file-icons/icons/expui/nodes/testResourcesRoot_dark.svg create mode 100644 linux/assets/file-icons/icons/expui/nodes/testRoot.svg create mode 100644 linux/assets/file-icons/icons/expui/nodes/testRoot_dark.svg delete mode 100644 linux/assets/file-icons/icons/files/adobe.svg delete mode 100644 linux/assets/file-icons/icons/files/agents.svg delete mode 100644 linux/assets/file-icons/icons/files/android.svg delete mode 100644 linux/assets/file-icons/icons/files/angular.svg delete mode 100644 linux/assets/file-icons/icons/files/ansible.svg delete mode 100644 linux/assets/file-icons/icons/files/apple.svg delete mode 100644 linux/assets/file-icons/icons/files/archive.svg delete mode 100644 linux/assets/file-icons/icons/files/arduino.svg delete mode 100644 linux/assets/file-icons/icons/files/astro.svg delete mode 100644 linux/assets/file-icons/icons/files/audio.svg delete mode 100644 linux/assets/file-icons/icons/files/authors.svg delete mode 100644 linux/assets/file-icons/icons/files/babel.svg delete mode 100644 linux/assets/file-icons/icons/files/biome.svg delete mode 100644 linux/assets/file-icons/icons/files/bitbucket.svg delete mode 100644 linux/assets/file-icons/icons/files/blender.svg delete mode 100644 linux/assets/file-icons/icons/files/bun.svg delete mode 100644 linux/assets/file-icons/icons/files/c.svg delete mode 100644 linux/assets/file-icons/icons/files/certificate.svg delete mode 100644 linux/assets/file-icons/icons/files/changelog.svg delete mode 100644 linux/assets/file-icons/icons/files/claude.svg delete mode 100644 linux/assets/file-icons/icons/files/cline.svg delete mode 100644 linux/assets/file-icons/icons/files/clojure.svg delete mode 100644 linux/assets/file-icons/icons/files/cloudflare.svg delete mode 100644 linux/assets/file-icons/icons/files/cmake.svg delete mode 100644 linux/assets/file-icons/icons/files/codex.svg delete mode 100644 linux/assets/file-icons/icons/files/commitlint.svg delete mode 100644 linux/assets/file-icons/icons/files/config.svg delete mode 100644 linux/assets/file-icons/icons/files/copilot.svg delete mode 100644 linux/assets/file-icons/icons/files/cpp.svg delete mode 100644 linux/assets/file-icons/icons/files/csharp.svg delete mode 100644 linux/assets/file-icons/icons/files/cspell.svg delete mode 100644 linux/assets/file-icons/icons/files/css.svg delete mode 100644 linux/assets/file-icons/icons/files/csv.svg delete mode 100644 linux/assets/file-icons/icons/files/cursor.svg delete mode 100644 linux/assets/file-icons/icons/files/cypress.svg delete mode 100644 linux/assets/file-icons/icons/files/dart.svg delete mode 100644 linux/assets/file-icons/icons/files/database.svg delete mode 100644 linux/assets/file-icons/icons/files/deno.svg delete mode 100644 linux/assets/file-icons/icons/files/dependabot.svg delete mode 100644 linux/assets/file-icons/icons/files/devcontainer.svg delete mode 100644 linux/assets/file-icons/icons/files/diff.svg delete mode 100644 linux/assets/file-icons/icons/files/django.svg delete mode 100644 linux/assets/file-icons/icons/files/docker-compose.svg delete mode 100644 linux/assets/file-icons/icons/files/docker.svg delete mode 100644 linux/assets/file-icons/icons/files/document.svg delete mode 100644 linux/assets/file-icons/icons/files/docusaurus.svg delete mode 100644 linux/assets/file-icons/icons/files/drawio.svg delete mode 100644 linux/assets/file-icons/icons/files/drizzle.svg delete mode 100644 linux/assets/file-icons/icons/files/editorconfig.svg delete mode 100644 linux/assets/file-icons/icons/files/electron.svg delete mode 100644 linux/assets/file-icons/icons/files/elixir.svg delete mode 100644 linux/assets/file-icons/icons/files/env.svg delete mode 100644 linux/assets/file-icons/icons/files/erlang.svg delete mode 100644 linux/assets/file-icons/icons/files/error.svg delete mode 100644 linux/assets/file-icons/icons/files/eslint.svg delete mode 100644 linux/assets/file-icons/icons/files/excalidraw.svg delete mode 100644 linux/assets/file-icons/icons/files/fastapi.svg delete mode 100644 linux/assets/file-icons/icons/files/figma.svg delete mode 100644 linux/assets/file-icons/icons/files/file.svg delete mode 100644 linux/assets/file-icons/icons/files/firebase.svg delete mode 100644 linux/assets/file-icons/icons/files/flask.svg delete mode 100644 linux/assets/file-icons/icons/files/font.svg delete mode 100644 linux/assets/file-icons/icons/files/gatsby.svg delete mode 100644 linux/assets/file-icons/icons/files/gemini.svg delete mode 100644 linux/assets/file-icons/icons/files/git.svg delete mode 100644 linux/assets/file-icons/icons/files/github-actions.svg delete mode 100644 linux/assets/file-icons/icons/files/github.svg delete mode 100644 linux/assets/file-icons/icons/files/gitlab.svg delete mode 100644 linux/assets/file-icons/icons/files/go.svg delete mode 100644 linux/assets/file-icons/icons/files/gradle.svg delete mode 100644 linux/assets/file-icons/icons/files/graphql-schema.svg delete mode 100644 linux/assets/file-icons/icons/files/graphql.svg delete mode 100644 linux/assets/file-icons/icons/files/haskell.svg delete mode 100644 linux/assets/file-icons/icons/files/helm.svg delete mode 100644 linux/assets/file-icons/icons/files/html.svg delete mode 100644 linux/assets/file-icons/icons/files/http.svg delete mode 100644 linux/assets/file-icons/icons/files/hurl.svg delete mode 100644 linux/assets/file-icons/icons/files/image.svg delete mode 100644 linux/assets/file-icons/icons/files/index-js.svg delete mode 100644 linux/assets/file-icons/icons/files/index-ts.svg delete mode 100644 linux/assets/file-icons/icons/files/java.svg delete mode 100644 linux/assets/file-icons/icons/files/javascript.svg delete mode 100644 linux/assets/file-icons/icons/files/jenkins.svg delete mode 100644 linux/assets/file-icons/icons/files/jest.svg delete mode 100644 linux/assets/file-icons/icons/files/jsconfig.svg delete mode 100644 linux/assets/file-icons/icons/files/json.svg delete mode 100644 linux/assets/file-icons/icons/files/julia.svg delete mode 100644 linux/assets/file-icons/icons/files/key.svg delete mode 100644 linux/assets/file-icons/icons/files/kotlin.svg delete mode 100644 linux/assets/file-icons/icons/files/kubernetes.svg delete mode 100644 linux/assets/file-icons/icons/files/laravel.svg delete mode 100644 linux/assets/file-icons/icons/files/layout.svg delete mode 100644 linux/assets/file-icons/icons/files/license.svg delete mode 100644 linux/assets/file-icons/icons/files/lintstaged.svg delete mode 100644 linux/assets/file-icons/icons/files/linux.svg delete mode 100644 linux/assets/file-icons/icons/files/lit.svg delete mode 100644 linux/assets/file-icons/icons/files/lithe.svg delete mode 100644 linux/assets/file-icons/icons/files/loading.svg delete mode 100644 linux/assets/file-icons/icons/files/lock.svg delete mode 100644 linux/assets/file-icons/icons/files/log.svg delete mode 100644 linux/assets/file-icons/icons/files/lua.svg delete mode 100644 linux/assets/file-icons/icons/files/makefile.svg delete mode 100644 linux/assets/file-icons/icons/files/markdown.svg delete mode 100644 linux/assets/file-icons/icons/files/markdownlint.svg delete mode 100644 linux/assets/file-icons/icons/files/maven.svg delete mode 100644 linux/assets/file-icons/icons/files/mcp.svg delete mode 100644 linux/assets/file-icons/icons/files/mermaid.svg delete mode 100644 linux/assets/file-icons/icons/files/mongo.svg delete mode 100644 linux/assets/file-icons/icons/files/netlify.svg delete mode 100644 linux/assets/file-icons/icons/files/next.svg delete mode 100644 linux/assets/file-icons/icons/files/nginx.svg delete mode 100644 linux/assets/file-icons/icons/files/nim.svg delete mode 100644 linux/assets/file-icons/icons/files/nix.svg delete mode 100644 linux/assets/file-icons/icons/files/node.svg delete mode 100644 linux/assets/file-icons/icons/files/not-found.svg delete mode 100644 linux/assets/file-icons/icons/files/notebook.svg delete mode 100644 linux/assets/file-icons/icons/files/npm.svg delete mode 100644 linux/assets/file-icons/icons/files/nuxt.svg delete mode 100644 linux/assets/file-icons/icons/files/nx.svg delete mode 100644 linux/assets/file-icons/icons/files/ocaml.svg delete mode 100644 linux/assets/file-icons/icons/files/package.svg delete mode 100644 linux/assets/file-icons/icons/files/page.svg delete mode 100644 linux/assets/file-icons/icons/files/patch.svg delete mode 100644 linux/assets/file-icons/icons/files/pdf.svg delete mode 100644 linux/assets/file-icons/icons/files/perl.svg delete mode 100644 linux/assets/file-icons/icons/files/php.svg delete mode 100644 linux/assets/file-icons/icons/files/playwright.svg delete mode 100644 linux/assets/file-icons/icons/files/pnpm.svg delete mode 100644 linux/assets/file-icons/icons/files/postgres.svg delete mode 100644 linux/assets/file-icons/icons/files/powerpoint.svg delete mode 100644 linux/assets/file-icons/icons/files/prettier.svg delete mode 100644 linux/assets/file-icons/icons/files/prisma.svg delete mode 100644 linux/assets/file-icons/icons/files/proto.svg delete mode 100644 linux/assets/file-icons/icons/files/python.svg delete mode 100644 linux/assets/file-icons/icons/files/qwik.svg delete mode 100644 linux/assets/file-icons/icons/files/r.svg delete mode 100644 linux/assets/file-icons/icons/files/react.svg delete mode 100644 linux/assets/file-icons/icons/files/redis.svg delete mode 100644 linux/assets/file-icons/icons/files/remix.svg delete mode 100644 linux/assets/file-icons/icons/files/renovate.svg delete mode 100644 linux/assets/file-icons/icons/files/rescript.svg delete mode 100644 linux/assets/file-icons/icons/files/rollup.svg delete mode 100644 linux/assets/file-icons/icons/files/route.svg delete mode 100644 linux/assets/file-icons/icons/files/rspack.svg delete mode 100644 linux/assets/file-icons/icons/files/ruby.svg delete mode 100644 linux/assets/file-icons/icons/files/rust.svg delete mode 100644 linux/assets/file-icons/icons/files/sass.svg delete mode 100644 linux/assets/file-icons/icons/files/scala.svg delete mode 100644 linux/assets/file-icons/icons/files/security.svg delete mode 100644 linux/assets/file-icons/icons/files/shell.svg delete mode 100644 linux/assets/file-icons/icons/files/sketch.svg delete mode 100644 linux/assets/file-icons/icons/files/solid.svg delete mode 100644 linux/assets/file-icons/icons/files/solidity.svg delete mode 100644 linux/assets/file-icons/icons/files/spreadsheet.svg delete mode 100644 linux/assets/file-icons/icons/files/sql.svg delete mode 100644 linux/assets/file-icons/icons/files/storybook.svg delete mode 100644 linux/assets/file-icons/icons/files/stylelint.svg delete mode 100644 linux/assets/file-icons/icons/files/supabase.svg delete mode 100644 linux/assets/file-icons/icons/files/svelte.svg delete mode 100644 linux/assets/file-icons/icons/files/svg.svg delete mode 100644 linux/assets/file-icons/icons/files/swc.svg delete mode 100644 linux/assets/file-icons/icons/files/swift.svg delete mode 100644 linux/assets/file-icons/icons/files/tailwind.svg delete mode 100644 linux/assets/file-icons/icons/files/tauri.svg delete mode 100644 linux/assets/file-icons/icons/files/terraform.svg delete mode 100644 linux/assets/file-icons/icons/files/test.svg delete mode 100644 linux/assets/file-icons/icons/files/text.svg delete mode 100644 linux/assets/file-icons/icons/files/toml.svg delete mode 100644 linux/assets/file-icons/icons/files/turborepo.svg delete mode 100644 linux/assets/file-icons/icons/files/typescript.svg delete mode 100644 linux/assets/file-icons/icons/files/vercel-config.svg delete mode 100644 linux/assets/file-icons/icons/files/vercel.svg delete mode 100644 linux/assets/file-icons/icons/files/video.svg delete mode 100644 linux/assets/file-icons/icons/files/vite.svg delete mode 100644 linux/assets/file-icons/icons/files/vitest.svg delete mode 100644 linux/assets/file-icons/icons/files/vue.svg delete mode 100644 linux/assets/file-icons/icons/files/warning.svg delete mode 100644 linux/assets/file-icons/icons/files/wasm.svg delete mode 100644 linux/assets/file-icons/icons/files/webpack.svg delete mode 100644 linux/assets/file-icons/icons/files/windows.svg delete mode 100644 linux/assets/file-icons/icons/files/word.svg delete mode 100644 linux/assets/file-icons/icons/files/xcode.svg delete mode 100644 linux/assets/file-icons/icons/files/xml.svg delete mode 100644 linux/assets/file-icons/icons/files/yaml.svg delete mode 100644 linux/assets/file-icons/icons/files/yarn.svg delete mode 100644 linux/assets/file-icons/icons/files/zig.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-ai-open.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-ai.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-assets-open.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-assets.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-build-open.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-build.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-cloud-open.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-cloud.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-components-open.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-components.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-config-open.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-config.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-database-open.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-database.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-docs-open.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-docs.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-extensions-open.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-extensions.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-git-open.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-git.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-locales-open.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-locales.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-mobile-open.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-mobile.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-open.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-packages-open.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-packages.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-routes-open.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-routes.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-rust-open.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-rust.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-scripts-open.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-scripts.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-security-open.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-security.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-source-open.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-source.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-styles-open.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-styles.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-test-open.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder-test.svg delete mode 100644 linux/assets/file-icons/icons/folders/folder.svg delete mode 100644 linux/assets/file-icons/icons/light/files/adobe.svg delete mode 100644 linux/assets/file-icons/icons/light/files/agents.svg delete mode 100644 linux/assets/file-icons/icons/light/files/android.svg delete mode 100644 linux/assets/file-icons/icons/light/files/angular.svg delete mode 100644 linux/assets/file-icons/icons/light/files/ansible.svg delete mode 100644 linux/assets/file-icons/icons/light/files/apple.svg delete mode 100644 linux/assets/file-icons/icons/light/files/archive.svg delete mode 100644 linux/assets/file-icons/icons/light/files/arduino.svg delete mode 100644 linux/assets/file-icons/icons/light/files/astro.svg delete mode 100644 linux/assets/file-icons/icons/light/files/audio.svg delete mode 100644 linux/assets/file-icons/icons/light/files/authors.svg delete mode 100644 linux/assets/file-icons/icons/light/files/babel.svg delete mode 100644 linux/assets/file-icons/icons/light/files/biome.svg delete mode 100644 linux/assets/file-icons/icons/light/files/bitbucket.svg delete mode 100644 linux/assets/file-icons/icons/light/files/blender.svg delete mode 100644 linux/assets/file-icons/icons/light/files/bun.svg delete mode 100644 linux/assets/file-icons/icons/light/files/c.svg delete mode 100644 linux/assets/file-icons/icons/light/files/certificate.svg delete mode 100644 linux/assets/file-icons/icons/light/files/changelog.svg delete mode 100644 linux/assets/file-icons/icons/light/files/claude.svg delete mode 100644 linux/assets/file-icons/icons/light/files/cline.svg delete mode 100644 linux/assets/file-icons/icons/light/files/clojure.svg delete mode 100644 linux/assets/file-icons/icons/light/files/cloudflare.svg delete mode 100644 linux/assets/file-icons/icons/light/files/cmake.svg delete mode 100644 linux/assets/file-icons/icons/light/files/codex.svg delete mode 100644 linux/assets/file-icons/icons/light/files/commitlint.svg delete mode 100644 linux/assets/file-icons/icons/light/files/config.svg delete mode 100644 linux/assets/file-icons/icons/light/files/copilot.svg delete mode 100644 linux/assets/file-icons/icons/light/files/cpp.svg delete mode 100644 linux/assets/file-icons/icons/light/files/csharp.svg delete mode 100644 linux/assets/file-icons/icons/light/files/cspell.svg delete mode 100644 linux/assets/file-icons/icons/light/files/css.svg delete mode 100644 linux/assets/file-icons/icons/light/files/csv.svg delete mode 100644 linux/assets/file-icons/icons/light/files/cursor.svg delete mode 100644 linux/assets/file-icons/icons/light/files/cypress.svg delete mode 100644 linux/assets/file-icons/icons/light/files/dart.svg delete mode 100644 linux/assets/file-icons/icons/light/files/database.svg delete mode 100644 linux/assets/file-icons/icons/light/files/deno.svg delete mode 100644 linux/assets/file-icons/icons/light/files/dependabot.svg delete mode 100644 linux/assets/file-icons/icons/light/files/devcontainer.svg delete mode 100644 linux/assets/file-icons/icons/light/files/diff.svg delete mode 100644 linux/assets/file-icons/icons/light/files/django.svg delete mode 100644 linux/assets/file-icons/icons/light/files/docker-compose.svg delete mode 100644 linux/assets/file-icons/icons/light/files/docker.svg delete mode 100644 linux/assets/file-icons/icons/light/files/document.svg delete mode 100644 linux/assets/file-icons/icons/light/files/docusaurus.svg delete mode 100644 linux/assets/file-icons/icons/light/files/drawio.svg delete mode 100644 linux/assets/file-icons/icons/light/files/drizzle.svg delete mode 100644 linux/assets/file-icons/icons/light/files/editorconfig.svg delete mode 100644 linux/assets/file-icons/icons/light/files/electron.svg delete mode 100644 linux/assets/file-icons/icons/light/files/elixir.svg delete mode 100644 linux/assets/file-icons/icons/light/files/env.svg delete mode 100644 linux/assets/file-icons/icons/light/files/erlang.svg delete mode 100644 linux/assets/file-icons/icons/light/files/error.svg delete mode 100644 linux/assets/file-icons/icons/light/files/eslint.svg delete mode 100644 linux/assets/file-icons/icons/light/files/excalidraw.svg delete mode 100644 linux/assets/file-icons/icons/light/files/fastapi.svg delete mode 100644 linux/assets/file-icons/icons/light/files/figma.svg delete mode 100644 linux/assets/file-icons/icons/light/files/file.svg delete mode 100644 linux/assets/file-icons/icons/light/files/firebase.svg delete mode 100644 linux/assets/file-icons/icons/light/files/flask.svg delete mode 100644 linux/assets/file-icons/icons/light/files/font.svg delete mode 100644 linux/assets/file-icons/icons/light/files/gatsby.svg delete mode 100644 linux/assets/file-icons/icons/light/files/gemini.svg delete mode 100644 linux/assets/file-icons/icons/light/files/git.svg delete mode 100644 linux/assets/file-icons/icons/light/files/github-actions.svg delete mode 100644 linux/assets/file-icons/icons/light/files/github.svg delete mode 100644 linux/assets/file-icons/icons/light/files/gitlab.svg delete mode 100644 linux/assets/file-icons/icons/light/files/go.svg delete mode 100644 linux/assets/file-icons/icons/light/files/gradle.svg delete mode 100644 linux/assets/file-icons/icons/light/files/graphql-schema.svg delete mode 100644 linux/assets/file-icons/icons/light/files/graphql.svg delete mode 100644 linux/assets/file-icons/icons/light/files/haskell.svg delete mode 100644 linux/assets/file-icons/icons/light/files/helm.svg delete mode 100644 linux/assets/file-icons/icons/light/files/html.svg delete mode 100644 linux/assets/file-icons/icons/light/files/http.svg delete mode 100644 linux/assets/file-icons/icons/light/files/hurl.svg delete mode 100644 linux/assets/file-icons/icons/light/files/image.svg delete mode 100644 linux/assets/file-icons/icons/light/files/index-js.svg delete mode 100644 linux/assets/file-icons/icons/light/files/index-ts.svg delete mode 100644 linux/assets/file-icons/icons/light/files/java.svg delete mode 100644 linux/assets/file-icons/icons/light/files/javascript.svg delete mode 100644 linux/assets/file-icons/icons/light/files/jenkins.svg delete mode 100644 linux/assets/file-icons/icons/light/files/jest.svg delete mode 100644 linux/assets/file-icons/icons/light/files/jsconfig.svg delete mode 100644 linux/assets/file-icons/icons/light/files/json.svg delete mode 100644 linux/assets/file-icons/icons/light/files/julia.svg delete mode 100644 linux/assets/file-icons/icons/light/files/key.svg delete mode 100644 linux/assets/file-icons/icons/light/files/kotlin.svg delete mode 100644 linux/assets/file-icons/icons/light/files/kubernetes.svg delete mode 100644 linux/assets/file-icons/icons/light/files/laravel.svg delete mode 100644 linux/assets/file-icons/icons/light/files/layout.svg delete mode 100644 linux/assets/file-icons/icons/light/files/license.svg delete mode 100644 linux/assets/file-icons/icons/light/files/lintstaged.svg delete mode 100644 linux/assets/file-icons/icons/light/files/linux.svg delete mode 100644 linux/assets/file-icons/icons/light/files/lit.svg delete mode 100644 linux/assets/file-icons/icons/light/files/lithe.svg delete mode 100644 linux/assets/file-icons/icons/light/files/loading.svg delete mode 100644 linux/assets/file-icons/icons/light/files/lock.svg delete mode 100644 linux/assets/file-icons/icons/light/files/log.svg delete mode 100644 linux/assets/file-icons/icons/light/files/lua.svg delete mode 100644 linux/assets/file-icons/icons/light/files/makefile.svg delete mode 100644 linux/assets/file-icons/icons/light/files/markdown.svg delete mode 100644 linux/assets/file-icons/icons/light/files/markdownlint.svg delete mode 100644 linux/assets/file-icons/icons/light/files/maven.svg delete mode 100644 linux/assets/file-icons/icons/light/files/mcp.svg delete mode 100644 linux/assets/file-icons/icons/light/files/mermaid.svg delete mode 100644 linux/assets/file-icons/icons/light/files/mongo.svg delete mode 100644 linux/assets/file-icons/icons/light/files/netlify.svg delete mode 100644 linux/assets/file-icons/icons/light/files/next.svg delete mode 100644 linux/assets/file-icons/icons/light/files/nginx.svg delete mode 100644 linux/assets/file-icons/icons/light/files/nim.svg delete mode 100644 linux/assets/file-icons/icons/light/files/nix.svg delete mode 100644 linux/assets/file-icons/icons/light/files/node.svg delete mode 100644 linux/assets/file-icons/icons/light/files/not-found.svg delete mode 100644 linux/assets/file-icons/icons/light/files/notebook.svg delete mode 100644 linux/assets/file-icons/icons/light/files/npm.svg delete mode 100644 linux/assets/file-icons/icons/light/files/nuxt.svg delete mode 100644 linux/assets/file-icons/icons/light/files/nx.svg delete mode 100644 linux/assets/file-icons/icons/light/files/ocaml.svg delete mode 100644 linux/assets/file-icons/icons/light/files/package.svg delete mode 100644 linux/assets/file-icons/icons/light/files/page.svg delete mode 100644 linux/assets/file-icons/icons/light/files/patch.svg delete mode 100644 linux/assets/file-icons/icons/light/files/pdf.svg delete mode 100644 linux/assets/file-icons/icons/light/files/perl.svg delete mode 100644 linux/assets/file-icons/icons/light/files/php.svg delete mode 100644 linux/assets/file-icons/icons/light/files/playwright.svg delete mode 100644 linux/assets/file-icons/icons/light/files/pnpm.svg delete mode 100644 linux/assets/file-icons/icons/light/files/postgres.svg delete mode 100644 linux/assets/file-icons/icons/light/files/powerpoint.svg delete mode 100644 linux/assets/file-icons/icons/light/files/prettier.svg delete mode 100644 linux/assets/file-icons/icons/light/files/prisma.svg delete mode 100644 linux/assets/file-icons/icons/light/files/proto.svg delete mode 100644 linux/assets/file-icons/icons/light/files/python.svg delete mode 100644 linux/assets/file-icons/icons/light/files/qwik.svg delete mode 100644 linux/assets/file-icons/icons/light/files/r.svg delete mode 100644 linux/assets/file-icons/icons/light/files/react.svg delete mode 100644 linux/assets/file-icons/icons/light/files/redis.svg delete mode 100644 linux/assets/file-icons/icons/light/files/remix.svg delete mode 100644 linux/assets/file-icons/icons/light/files/renovate.svg delete mode 100644 linux/assets/file-icons/icons/light/files/rescript.svg delete mode 100644 linux/assets/file-icons/icons/light/files/rollup.svg delete mode 100644 linux/assets/file-icons/icons/light/files/route.svg delete mode 100644 linux/assets/file-icons/icons/light/files/rspack.svg delete mode 100644 linux/assets/file-icons/icons/light/files/ruby.svg delete mode 100644 linux/assets/file-icons/icons/light/files/rust.svg delete mode 100644 linux/assets/file-icons/icons/light/files/sass.svg delete mode 100644 linux/assets/file-icons/icons/light/files/scala.svg delete mode 100644 linux/assets/file-icons/icons/light/files/security.svg delete mode 100644 linux/assets/file-icons/icons/light/files/shell.svg delete mode 100644 linux/assets/file-icons/icons/light/files/sketch.svg delete mode 100644 linux/assets/file-icons/icons/light/files/solid.svg delete mode 100644 linux/assets/file-icons/icons/light/files/solidity.svg delete mode 100644 linux/assets/file-icons/icons/light/files/spreadsheet.svg delete mode 100644 linux/assets/file-icons/icons/light/files/sql.svg delete mode 100644 linux/assets/file-icons/icons/light/files/storybook.svg delete mode 100644 linux/assets/file-icons/icons/light/files/stylelint.svg delete mode 100644 linux/assets/file-icons/icons/light/files/supabase.svg delete mode 100644 linux/assets/file-icons/icons/light/files/svelte.svg delete mode 100644 linux/assets/file-icons/icons/light/files/svg.svg delete mode 100644 linux/assets/file-icons/icons/light/files/swc.svg delete mode 100644 linux/assets/file-icons/icons/light/files/swift.svg delete mode 100644 linux/assets/file-icons/icons/light/files/tailwind.svg delete mode 100644 linux/assets/file-icons/icons/light/files/tauri.svg delete mode 100644 linux/assets/file-icons/icons/light/files/terraform.svg delete mode 100644 linux/assets/file-icons/icons/light/files/test.svg delete mode 100644 linux/assets/file-icons/icons/light/files/text.svg delete mode 100644 linux/assets/file-icons/icons/light/files/toml.svg delete mode 100644 linux/assets/file-icons/icons/light/files/turborepo.svg delete mode 100644 linux/assets/file-icons/icons/light/files/typescript.svg delete mode 100644 linux/assets/file-icons/icons/light/files/vercel-config.svg delete mode 100644 linux/assets/file-icons/icons/light/files/vercel.svg delete mode 100644 linux/assets/file-icons/icons/light/files/video.svg delete mode 100644 linux/assets/file-icons/icons/light/files/vite.svg delete mode 100644 linux/assets/file-icons/icons/light/files/vitest.svg delete mode 100644 linux/assets/file-icons/icons/light/files/vue.svg delete mode 100644 linux/assets/file-icons/icons/light/files/warning.svg delete mode 100644 linux/assets/file-icons/icons/light/files/wasm.svg delete mode 100644 linux/assets/file-icons/icons/light/files/webpack.svg delete mode 100644 linux/assets/file-icons/icons/light/files/windows.svg delete mode 100644 linux/assets/file-icons/icons/light/files/word.svg delete mode 100644 linux/assets/file-icons/icons/light/files/xcode.svg delete mode 100644 linux/assets/file-icons/icons/light/files/xml.svg delete mode 100644 linux/assets/file-icons/icons/light/files/yaml.svg delete mode 100644 linux/assets/file-icons/icons/light/files/yarn.svg delete mode 100644 linux/assets/file-icons/icons/light/files/zig.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-ai-open.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-ai.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-assets-open.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-assets.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-build-open.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-build.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-cloud-open.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-cloud.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-components-open.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-components.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-config-open.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-config.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-database-open.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-database.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-docs-open.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-docs.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-extensions-open.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-extensions.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-git-open.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-git.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-locales-open.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-locales.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-mobile-open.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-mobile.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-open.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-packages-open.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-packages.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-routes-open.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-routes.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-rust-open.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-rust.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-scripts-open.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-scripts.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-security-open.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-security.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-source-open.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-source.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-styles-open.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-styles.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-test-open.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder-test.svg delete mode 100644 linux/assets/file-icons/icons/light/folders/folder.svg diff --git a/linux/Cargo.toml b/linux/Cargo.toml index dba77ba7f..497950b15 100644 --- a/linux/Cargo.toml +++ b/linux/Cargo.toml @@ -5,7 +5,19 @@ edition = "2021" [dependencies] rfd = "0.16" -gpui-kit = "0.6" +gpui-kit = { version = "0.6", features = [ + "tree-sitter-bash", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-markdown", + "tree-sitter-markdown-inline", + "tree-sitter-rust", + "tree-sitter-sql", + "tree-sitter-toml", + "tree-sitter-tsx", + "tree-sitter-typescript", + "tree-sitter-yaml", +] } lithe-core = { path = "../rust/lithe-core" } portable-pty = "0.9" serde = { version = "1", features = ["derive"] } diff --git a/linux/assets/file-icons/icons/expui/fileTypes/Csharp.svg b/linux/assets/file-icons/icons/expui/fileTypes/Csharp.svg new file mode 100644 index 000000000..cc74ead1e --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/Csharp.svg @@ -0,0 +1,3 @@ + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/Csharp_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/Csharp_dark.svg new file mode 100644 index 000000000..c0b2a9df3 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/Csharp_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/archive.svg b/linux/assets/file-icons/icons/expui/fileTypes/archive.svg new file mode 100644 index 000000000..87bbbeece --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/archive.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/archive_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/archive_dark.svg new file mode 100644 index 000000000..ab3cae2cc --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/archive_dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/c.svg b/linux/assets/file-icons/icons/expui/fileTypes/c.svg new file mode 100644 index 000000000..b7a663f5a --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/c.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/c_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/c_dark.svg new file mode 100644 index 000000000..af5455250 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/c_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/cpp.svg b/linux/assets/file-icons/icons/expui/fileTypes/cpp.svg new file mode 100644 index 000000000..e8c8c1949 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/cpp.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/cpp_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/cpp_dark.svg new file mode 100644 index 000000000..3c4442388 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/cpp_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/css.svg b/linux/assets/file-icons/icons/expui/fileTypes/css.svg new file mode 100644 index 000000000..929203773 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/css.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/css_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/css_dark.svg new file mode 100644 index 000000000..f541ee8c7 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/css_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/csv.svg b/linux/assets/file-icons/icons/expui/fileTypes/csv.svg new file mode 100644 index 000000000..cc5fa2eaa --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/csv.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/csv_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/csv_dark.svg new file mode 100644 index 000000000..d038177a9 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/csv_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/docker.svg b/linux/assets/file-icons/icons/expui/fileTypes/docker.svg new file mode 100644 index 000000000..a4f9cab91 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/docker.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/docker_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/docker_dark.svg new file mode 100644 index 000000000..5369a264a --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/docker_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/editorConfig.svg b/linux/assets/file-icons/icons/expui/fileTypes/editorConfig.svg new file mode 100644 index 000000000..547bba721 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/editorConfig.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/editorConfig_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/editorConfig_dark.svg new file mode 100644 index 000000000..528d02ebe --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/editorConfig_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/font.svg b/linux/assets/file-icons/icons/expui/fileTypes/font.svg new file mode 100644 index 000000000..9e187b36f --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/font.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/font_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/font_dark.svg new file mode 100644 index 000000000..94fbd32d9 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/font_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/gitignore.svg b/linux/assets/file-icons/icons/expui/fileTypes/gitignore.svg new file mode 100644 index 000000000..86228c617 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/gitignore.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/gradle.svg b/linux/assets/file-icons/icons/expui/fileTypes/gradle.svg new file mode 100644 index 000000000..29f3e59ee --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/gradle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/gradle_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/gradle_dark.svg new file mode 100644 index 000000000..37c055abf --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/gradle_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/graphql.svg b/linux/assets/file-icons/icons/expui/fileTypes/graphql.svg new file mode 100644 index 000000000..d4dd36ac3 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/graphql.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/graphql_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/graphql_dark.svg new file mode 100644 index 000000000..a0095957d --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/graphql_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/groovy.svg b/linux/assets/file-icons/icons/expui/fileTypes/groovy.svg new file mode 100644 index 000000000..671aff55e --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/groovy.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/groovy_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/groovy_dark.svg new file mode 100644 index 000000000..0dfe6a462 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/groovy_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/h.svg b/linux/assets/file-icons/icons/expui/fileTypes/h.svg new file mode 100644 index 000000000..1905b901a --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/h.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/h_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/h_dark.svg new file mode 100644 index 000000000..935071870 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/h_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/html.svg b/linux/assets/file-icons/icons/expui/fileTypes/html.svg new file mode 100644 index 000000000..c446cf39e --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/html.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/html_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/html_dark.svg new file mode 100644 index 000000000..9e10d57f1 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/html_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/http.svg b/linux/assets/file-icons/icons/expui/fileTypes/http.svg new file mode 100644 index 000000000..906237407 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/http.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/image.svg b/linux/assets/file-icons/icons/expui/fileTypes/image.svg new file mode 100644 index 000000000..9a09720fa --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/image.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/image_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/image_dark.svg new file mode 100644 index 000000000..4f189a0e3 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/image_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/java.svg b/linux/assets/file-icons/icons/expui/fileTypes/java.svg new file mode 100644 index 000000000..00e1a4c96 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/java.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/javaScript.svg b/linux/assets/file-icons/icons/expui/fileTypes/javaScript.svg new file mode 100644 index 000000000..d7eb6cc8d --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/javaScript.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/javaScript_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/javaScript_dark.svg new file mode 100644 index 000000000..188617076 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/javaScript_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/java_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/java_dark.svg new file mode 100644 index 000000000..155295f59 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/java_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/json.svg b/linux/assets/file-icons/icons/expui/fileTypes/json.svg new file mode 100644 index 000000000..d8f707dd5 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/json.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/json_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/json_dark.svg new file mode 100644 index 000000000..29371ab71 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/json_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/jsp.svg b/linux/assets/file-icons/icons/expui/fileTypes/jsp.svg new file mode 100644 index 000000000..564a2190c --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/jsp.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/jsp_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/jsp_dark.svg new file mode 100644 index 000000000..1c04aa9cf --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/jsp_dark.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/markdown.svg b/linux/assets/file-icons/icons/expui/fileTypes/markdown.svg new file mode 100644 index 000000000..c603fd236 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/markdown.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/markdown_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/markdown_dark.svg new file mode 100644 index 000000000..2d86cd368 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/markdown_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/maven.svg b/linux/assets/file-icons/icons/expui/fileTypes/maven.svg new file mode 100644 index 000000000..e22b73598 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/maven.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/maven_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/maven_dark.svg new file mode 100644 index 000000000..8f8f35e5c --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/maven_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/patch.svg b/linux/assets/file-icons/icons/expui/fileTypes/patch.svg new file mode 100644 index 000000000..b3a1d040b --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/patch.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/patch_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/patch_dark.svg new file mode 100644 index 000000000..e6d13133e --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/patch_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/perl.svg b/linux/assets/file-icons/icons/expui/fileTypes/perl.svg new file mode 100644 index 000000000..565044076 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/perl.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/perl_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/perl_dark.svg new file mode 100644 index 000000000..870ac8faa --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/perl_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/powershell.svg b/linux/assets/file-icons/icons/expui/fileTypes/powershell.svg new file mode 100644 index 000000000..1039d72db --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/powershell.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/powershell_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/powershell_dark.svg new file mode 100644 index 000000000..2b8307594 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/powershell_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/properties.svg b/linux/assets/file-icons/icons/expui/fileTypes/properties.svg new file mode 100644 index 000000000..547bba721 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/properties.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/properties_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/properties_dark.svg new file mode 100644 index 000000000..528d02ebe --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/properties_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/rst.svg b/linux/assets/file-icons/icons/expui/fileTypes/rst.svg new file mode 100644 index 000000000..09c5b340e --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/rst.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/rst_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/rst_dark.svg new file mode 100644 index 000000000..ae54b4de1 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/rst_dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/shell.svg b/linux/assets/file-icons/icons/expui/fileTypes/shell.svg new file mode 100644 index 000000000..0676afdd7 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/shell.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/shell_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/shell_dark.svg new file mode 100644 index 000000000..7022d0c76 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/shell_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/sql.svg b/linux/assets/file-icons/icons/expui/fileTypes/sql.svg new file mode 100644 index 000000000..a57c04270 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/sql.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/sql_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/sql_dark.svg new file mode 100644 index 000000000..57d62ee80 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/sql_dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/swiftLang.svg b/linux/assets/file-icons/icons/expui/fileTypes/swiftLang.svg new file mode 100644 index 000000000..4b6e68b65 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/swiftLang.svg @@ -0,0 +1,3 @@ + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/swiftLang_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/swiftLang_dark.svg new file mode 100644 index 000000000..5d386f489 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/swiftLang_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/terraform.svg b/linux/assets/file-icons/icons/expui/fileTypes/terraform.svg new file mode 100644 index 000000000..ac3ca7839 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/terraform.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/linux/assets/file-icons/icons/expui/fileTypes/terraform_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/terraform_dark.svg new file mode 100644 index 000000000..4c4d9bfee --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/terraform_dark.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/linux/assets/file-icons/icons/expui/fileTypes/text.svg b/linux/assets/file-icons/icons/expui/fileTypes/text.svg new file mode 100644 index 000000000..110139cf6 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/text.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/text_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/text_dark.svg new file mode 100644 index 000000000..86468276b --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/text_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/toml.svg b/linux/assets/file-icons/icons/expui/fileTypes/toml.svg new file mode 100644 index 000000000..10789f0e9 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/toml.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/toml_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/toml_dark.svg new file mode 100644 index 000000000..b9e6aea69 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/toml_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/typeScript.svg b/linux/assets/file-icons/icons/expui/fileTypes/typeScript.svg new file mode 100644 index 000000000..b4d487f0d --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/typeScript.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/typeScript_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/typeScript_dark.svg new file mode 100644 index 000000000..5c74ff7f7 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/typeScript_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/vue.svg b/linux/assets/file-icons/icons/expui/fileTypes/vue.svg new file mode 100644 index 000000000..8625428aa --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/vue.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/wsdl.svg b/linux/assets/file-icons/icons/expui/fileTypes/wsdl.svg new file mode 100644 index 000000000..a92e9c5c0 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/wsdl.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/wsdl_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/wsdl_dark.svg new file mode 100644 index 000000000..3732545ba --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/wsdl_dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/xhtml.svg b/linux/assets/file-icons/icons/expui/fileTypes/xhtml.svg new file mode 100644 index 000000000..94b886f9b --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/xhtml.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/xhtml_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/xhtml_dark.svg new file mode 100644 index 000000000..432f62e24 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/xhtml_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/xml.svg b/linux/assets/file-icons/icons/expui/fileTypes/xml.svg new file mode 100644 index 000000000..154c1eded --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/xml.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/xml_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/xml_dark.svg new file mode 100644 index 000000000..8a2eb779d --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/xml_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/xsd.svg b/linux/assets/file-icons/icons/expui/fileTypes/xsd.svg new file mode 100644 index 000000000..0c87edc93 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/xsd.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/xsd_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/xsd_dark.svg new file mode 100644 index 000000000..c012bd1e6 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/xsd_dark.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/yaml.svg b/linux/assets/file-icons/icons/expui/fileTypes/yaml.svg new file mode 100644 index 000000000..9d4031ee2 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/yaml.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/fileTypes/yaml_dark.svg b/linux/assets/file-icons/icons/expui/fileTypes/yaml_dark.svg new file mode 100644 index 000000000..b173b95d9 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/fileTypes/yaml_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/nodes/annotation.svg b/linux/assets/file-icons/icons/expui/nodes/annotation.svg new file mode 100644 index 000000000..842956af7 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/nodes/annotation.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/nodes/annotation_dark.svg b/linux/assets/file-icons/icons/expui/nodes/annotation_dark.svg new file mode 100644 index 000000000..66abc9160 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/nodes/annotation_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/nodes/class.svg b/linux/assets/file-icons/icons/expui/nodes/class.svg new file mode 100644 index 000000000..77996e71b --- /dev/null +++ b/linux/assets/file-icons/icons/expui/nodes/class.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/nodes/class_dark.svg b/linux/assets/file-icons/icons/expui/nodes/class_dark.svg new file mode 100644 index 000000000..a8027940d --- /dev/null +++ b/linux/assets/file-icons/icons/expui/nodes/class_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/nodes/enum.svg b/linux/assets/file-icons/icons/expui/nodes/enum.svg new file mode 100644 index 000000000..13deae631 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/nodes/enum.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/nodes/enum_dark.svg b/linux/assets/file-icons/icons/expui/nodes/enum_dark.svg new file mode 100644 index 000000000..44d6e5cf8 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/nodes/enum_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/nodes/exception.svg b/linux/assets/file-icons/icons/expui/nodes/exception.svg new file mode 100644 index 000000000..ab5bae754 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/nodes/exception.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/nodes/exception_dark.svg b/linux/assets/file-icons/icons/expui/nodes/exception_dark.svg new file mode 100644 index 000000000..710a292ec --- /dev/null +++ b/linux/assets/file-icons/icons/expui/nodes/exception_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/nodes/folder.svg b/linux/assets/file-icons/icons/expui/nodes/folder.svg new file mode 100644 index 000000000..7ece6b711 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/nodes/folder.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/assets/file-icons/icons/expui/nodes/folder_dark.svg b/linux/assets/file-icons/icons/expui/nodes/folder_dark.svg new file mode 100644 index 000000000..0af492bad --- /dev/null +++ b/linux/assets/file-icons/icons/expui/nodes/folder_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/assets/file-icons/icons/expui/nodes/interface.svg b/linux/assets/file-icons/icons/expui/nodes/interface.svg new file mode 100644 index 000000000..b7436907d --- /dev/null +++ b/linux/assets/file-icons/icons/expui/nodes/interface.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/nodes/interface_dark.svg b/linux/assets/file-icons/icons/expui/nodes/interface_dark.svg new file mode 100644 index 000000000..53905c525 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/nodes/interface_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/nodes/package.svg b/linux/assets/file-icons/icons/expui/nodes/package.svg new file mode 100644 index 000000000..4fd24f914 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/nodes/package.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/nodes/package_dark.svg b/linux/assets/file-icons/icons/expui/nodes/package_dark.svg new file mode 100644 index 000000000..f86ed61ba --- /dev/null +++ b/linux/assets/file-icons/icons/expui/nodes/package_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/nodes/record.svg b/linux/assets/file-icons/icons/expui/nodes/record.svg new file mode 100644 index 000000000..bafa1940d --- /dev/null +++ b/linux/assets/file-icons/icons/expui/nodes/record.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/nodes/record_dark.svg b/linux/assets/file-icons/icons/expui/nodes/record_dark.svg new file mode 100644 index 000000000..c965cf713 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/nodes/record_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/linux/assets/file-icons/icons/expui/nodes/resourcesRoot.svg b/linux/assets/file-icons/icons/expui/nodes/resourcesRoot.svg new file mode 100644 index 000000000..a58f3256d --- /dev/null +++ b/linux/assets/file-icons/icons/expui/nodes/resourcesRoot.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/nodes/resourcesRoot_dark.svg b/linux/assets/file-icons/icons/expui/nodes/resourcesRoot_dark.svg new file mode 100644 index 000000000..0a042e94e --- /dev/null +++ b/linux/assets/file-icons/icons/expui/nodes/resourcesRoot_dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/nodes/sourceRoot.svg b/linux/assets/file-icons/icons/expui/nodes/sourceRoot.svg new file mode 100644 index 000000000..4a4b44608 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/nodes/sourceRoot.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/assets/file-icons/icons/expui/nodes/sourceRoot_dark.svg b/linux/assets/file-icons/icons/expui/nodes/sourceRoot_dark.svg new file mode 100644 index 000000000..7d3159c25 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/nodes/sourceRoot_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/assets/file-icons/icons/expui/nodes/testResourcesRoot.svg b/linux/assets/file-icons/icons/expui/nodes/testResourcesRoot.svg new file mode 100644 index 000000000..1098ebb34 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/nodes/testResourcesRoot.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/nodes/testResourcesRoot_dark.svg b/linux/assets/file-icons/icons/expui/nodes/testResourcesRoot_dark.svg new file mode 100644 index 000000000..221b77061 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/nodes/testResourcesRoot_dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/linux/assets/file-icons/icons/expui/nodes/testRoot.svg b/linux/assets/file-icons/icons/expui/nodes/testRoot.svg new file mode 100644 index 000000000..4bfdae1a5 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/nodes/testRoot.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/assets/file-icons/icons/expui/nodes/testRoot_dark.svg b/linux/assets/file-icons/icons/expui/nodes/testRoot_dark.svg new file mode 100644 index 000000000..21261bb49 --- /dev/null +++ b/linux/assets/file-icons/icons/expui/nodes/testRoot_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/assets/file-icons/icons/files/adobe.svg b/linux/assets/file-icons/icons/files/adobe.svg deleted file mode 100644 index b201a3451..000000000 --- a/linux/assets/file-icons/icons/files/adobe.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - A - diff --git a/linux/assets/file-icons/icons/files/agents.svg b/linux/assets/file-icons/icons/files/agents.svg deleted file mode 100644 index 6d6d2df2f..000000000 --- a/linux/assets/file-icons/icons/files/agents.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - AI - diff --git a/linux/assets/file-icons/icons/files/android.svg b/linux/assets/file-icons/icons/files/android.svg deleted file mode 100644 index 231f6d43d..000000000 --- a/linux/assets/file-icons/icons/files/android.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/angular.svg b/linux/assets/file-icons/icons/files/angular.svg deleted file mode 100644 index 30fec93cc..000000000 --- a/linux/assets/file-icons/icons/files/angular.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/ansible.svg b/linux/assets/file-icons/icons/files/ansible.svg deleted file mode 100644 index fff9e0b79..000000000 --- a/linux/assets/file-icons/icons/files/ansible.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - A - diff --git a/linux/assets/file-icons/icons/files/apple.svg b/linux/assets/file-icons/icons/files/apple.svg deleted file mode 100644 index a34cd9b5c..000000000 --- a/linux/assets/file-icons/icons/files/apple.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/archive.svg b/linux/assets/file-icons/icons/files/archive.svg deleted file mode 100644 index d3822e3c8..000000000 --- a/linux/assets/file-icons/icons/files/archive.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/arduino.svg b/linux/assets/file-icons/icons/files/arduino.svg deleted file mode 100644 index 63b1d77eb..000000000 --- a/linux/assets/file-icons/icons/files/arduino.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - A - diff --git a/linux/assets/file-icons/icons/files/astro.svg b/linux/assets/file-icons/icons/files/astro.svg deleted file mode 100644 index ecb1c91ec..000000000 --- a/linux/assets/file-icons/icons/files/astro.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - A - diff --git a/linux/assets/file-icons/icons/files/audio.svg b/linux/assets/file-icons/icons/files/audio.svg deleted file mode 100644 index 20e064dfe..000000000 --- a/linux/assets/file-icons/icons/files/audio.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/authors.svg b/linux/assets/file-icons/icons/files/authors.svg deleted file mode 100644 index c2e044276..000000000 --- a/linux/assets/file-icons/icons/files/authors.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - BY - diff --git a/linux/assets/file-icons/icons/files/babel.svg b/linux/assets/file-icons/icons/files/babel.svg deleted file mode 100644 index 3d01b0c9c..000000000 --- a/linux/assets/file-icons/icons/files/babel.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - B - diff --git a/linux/assets/file-icons/icons/files/biome.svg b/linux/assets/file-icons/icons/files/biome.svg deleted file mode 100644 index 8a9c038f0..000000000 --- a/linux/assets/file-icons/icons/files/biome.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/bitbucket.svg b/linux/assets/file-icons/icons/files/bitbucket.svg deleted file mode 100644 index b8cda2c49..000000000 --- a/linux/assets/file-icons/icons/files/bitbucket.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/blender.svg b/linux/assets/file-icons/icons/files/blender.svg deleted file mode 100644 index 55a19331a..000000000 --- a/linux/assets/file-icons/icons/files/blender.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - B - diff --git a/linux/assets/file-icons/icons/files/bun.svg b/linux/assets/file-icons/icons/files/bun.svg deleted file mode 100644 index 633a6c56a..000000000 --- a/linux/assets/file-icons/icons/files/bun.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - B - diff --git a/linux/assets/file-icons/icons/files/c.svg b/linux/assets/file-icons/icons/files/c.svg deleted file mode 100644 index 94161033b..000000000 --- a/linux/assets/file-icons/icons/files/c.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - C - diff --git a/linux/assets/file-icons/icons/files/certificate.svg b/linux/assets/file-icons/icons/files/certificate.svg deleted file mode 100644 index 7d7da3ae5..000000000 --- a/linux/assets/file-icons/icons/files/certificate.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - CRT - diff --git a/linux/assets/file-icons/icons/files/changelog.svg b/linux/assets/file-icons/icons/files/changelog.svg deleted file mode 100644 index c9f55d8d9..000000000 --- a/linux/assets/file-icons/icons/files/changelog.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - LOG - diff --git a/linux/assets/file-icons/icons/files/claude.svg b/linux/assets/file-icons/icons/files/claude.svg deleted file mode 100644 index 59fc7a417..000000000 --- a/linux/assets/file-icons/icons/files/claude.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - AI - diff --git a/linux/assets/file-icons/icons/files/cline.svg b/linux/assets/file-icons/icons/files/cline.svg deleted file mode 100644 index ac58f0392..000000000 --- a/linux/assets/file-icons/icons/files/cline.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/clojure.svg b/linux/assets/file-icons/icons/files/clojure.svg deleted file mode 100644 index 506bfd14e..000000000 --- a/linux/assets/file-icons/icons/files/clojure.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/cloudflare.svg b/linux/assets/file-icons/icons/files/cloudflare.svg deleted file mode 100644 index 82a406e40..000000000 --- a/linux/assets/file-icons/icons/files/cloudflare.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - CF - diff --git a/linux/assets/file-icons/icons/files/cmake.svg b/linux/assets/file-icons/icons/files/cmake.svg deleted file mode 100644 index bf7a4b776..000000000 --- a/linux/assets/file-icons/icons/files/cmake.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/codex.svg b/linux/assets/file-icons/icons/files/codex.svg deleted file mode 100644 index 33de1d910..000000000 --- a/linux/assets/file-icons/icons/files/codex.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/commitlint.svg b/linux/assets/file-icons/icons/files/commitlint.svg deleted file mode 100644 index 00b291e99..000000000 --- a/linux/assets/file-icons/icons/files/commitlint.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/config.svg b/linux/assets/file-icons/icons/files/config.svg deleted file mode 100644 index 8a2de5e52..000000000 --- a/linux/assets/file-icons/icons/files/config.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/copilot.svg b/linux/assets/file-icons/icons/files/copilot.svg deleted file mode 100644 index e1aef20fb..000000000 --- a/linux/assets/file-icons/icons/files/copilot.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - AI - diff --git a/linux/assets/file-icons/icons/files/cpp.svg b/linux/assets/file-icons/icons/files/cpp.svg deleted file mode 100644 index ade60b287..000000000 --- a/linux/assets/file-icons/icons/files/cpp.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - C+ - diff --git a/linux/assets/file-icons/icons/files/csharp.svg b/linux/assets/file-icons/icons/files/csharp.svg deleted file mode 100644 index 15d7d14cd..000000000 --- a/linux/assets/file-icons/icons/files/csharp.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - C# - diff --git a/linux/assets/file-icons/icons/files/cspell.svg b/linux/assets/file-icons/icons/files/cspell.svg deleted file mode 100644 index 0c710dac0..000000000 --- a/linux/assets/file-icons/icons/files/cspell.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/css.svg b/linux/assets/file-icons/icons/files/css.svg deleted file mode 100644 index 1bafc3494..000000000 --- a/linux/assets/file-icons/icons/files/css.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - # - diff --git a/linux/assets/file-icons/icons/files/csv.svg b/linux/assets/file-icons/icons/files/csv.svg deleted file mode 100644 index 00ff4d9c8..000000000 --- a/linux/assets/file-icons/icons/files/csv.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - CSV - diff --git a/linux/assets/file-icons/icons/files/cursor.svg b/linux/assets/file-icons/icons/files/cursor.svg deleted file mode 100644 index 0a0383480..000000000 --- a/linux/assets/file-icons/icons/files/cursor.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/cypress.svg b/linux/assets/file-icons/icons/files/cypress.svg deleted file mode 100644 index 3dd11b61d..000000000 --- a/linux/assets/file-icons/icons/files/cypress.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/dart.svg b/linux/assets/file-icons/icons/files/dart.svg deleted file mode 100644 index c326fc377..000000000 --- a/linux/assets/file-icons/icons/files/dart.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - D - diff --git a/linux/assets/file-icons/icons/files/database.svg b/linux/assets/file-icons/icons/files/database.svg deleted file mode 100644 index e313be47d..000000000 --- a/linux/assets/file-icons/icons/files/database.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/deno.svg b/linux/assets/file-icons/icons/files/deno.svg deleted file mode 100644 index d9f5b39d7..000000000 --- a/linux/assets/file-icons/icons/files/deno.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - D - diff --git a/linux/assets/file-icons/icons/files/dependabot.svg b/linux/assets/file-icons/icons/files/dependabot.svg deleted file mode 100644 index 2fac961c6..000000000 --- a/linux/assets/file-icons/icons/files/dependabot.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - D - diff --git a/linux/assets/file-icons/icons/files/devcontainer.svg b/linux/assets/file-icons/icons/files/devcontainer.svg deleted file mode 100644 index d927b1b63..000000000 --- a/linux/assets/file-icons/icons/files/devcontainer.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - DC - diff --git a/linux/assets/file-icons/icons/files/diff.svg b/linux/assets/file-icons/icons/files/diff.svg deleted file mode 100644 index fbbadc24d..000000000 --- a/linux/assets/file-icons/icons/files/diff.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - +- - diff --git a/linux/assets/file-icons/icons/files/django.svg b/linux/assets/file-icons/icons/files/django.svg deleted file mode 100644 index 29e0d32fc..000000000 --- a/linux/assets/file-icons/icons/files/django.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/docker-compose.svg b/linux/assets/file-icons/icons/files/docker-compose.svg deleted file mode 100644 index 68c63db31..000000000 --- a/linux/assets/file-icons/icons/files/docker-compose.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/docker.svg b/linux/assets/file-icons/icons/files/docker.svg deleted file mode 100644 index 9444a3892..000000000 --- a/linux/assets/file-icons/icons/files/docker.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/document.svg b/linux/assets/file-icons/icons/files/document.svg deleted file mode 100644 index 09c9f51bf..000000000 --- a/linux/assets/file-icons/icons/files/document.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - DOC - diff --git a/linux/assets/file-icons/icons/files/docusaurus.svg b/linux/assets/file-icons/icons/files/docusaurus.svg deleted file mode 100644 index 629f18e33..000000000 --- a/linux/assets/file-icons/icons/files/docusaurus.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/drawio.svg b/linux/assets/file-icons/icons/files/drawio.svg deleted file mode 100644 index c337cfccd..000000000 --- a/linux/assets/file-icons/icons/files/drawio.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - D - diff --git a/linux/assets/file-icons/icons/files/drizzle.svg b/linux/assets/file-icons/icons/files/drizzle.svg deleted file mode 100644 index 7041d34a5..000000000 --- a/linux/assets/file-icons/icons/files/drizzle.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/editorconfig.svg b/linux/assets/file-icons/icons/files/editorconfig.svg deleted file mode 100644 index 2250697b1..000000000 --- a/linux/assets/file-icons/icons/files/editorconfig.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/electron.svg b/linux/assets/file-icons/icons/files/electron.svg deleted file mode 100644 index 2bfb7ff1d..000000000 --- a/linux/assets/file-icons/icons/files/electron.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - E - diff --git a/linux/assets/file-icons/icons/files/elixir.svg b/linux/assets/file-icons/icons/files/elixir.svg deleted file mode 100644 index d331e75f3..000000000 --- a/linux/assets/file-icons/icons/files/elixir.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - EX - diff --git a/linux/assets/file-icons/icons/files/env.svg b/linux/assets/file-icons/icons/files/env.svg deleted file mode 100644 index 305241630..000000000 --- a/linux/assets/file-icons/icons/files/env.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/erlang.svg b/linux/assets/file-icons/icons/files/erlang.svg deleted file mode 100644 index 374a8b1e5..000000000 --- a/linux/assets/file-icons/icons/files/erlang.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - ER - diff --git a/linux/assets/file-icons/icons/files/error.svg b/linux/assets/file-icons/icons/files/error.svg deleted file mode 100644 index 6d1526ae9..000000000 --- a/linux/assets/file-icons/icons/files/error.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/eslint.svg b/linux/assets/file-icons/icons/files/eslint.svg deleted file mode 100644 index 2031a67f4..000000000 --- a/linux/assets/file-icons/icons/files/eslint.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - E - diff --git a/linux/assets/file-icons/icons/files/excalidraw.svg b/linux/assets/file-icons/icons/files/excalidraw.svg deleted file mode 100644 index f6c0eeed0..000000000 --- a/linux/assets/file-icons/icons/files/excalidraw.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/fastapi.svg b/linux/assets/file-icons/icons/files/fastapi.svg deleted file mode 100644 index 695f0b01e..000000000 --- a/linux/assets/file-icons/icons/files/fastapi.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - FA - diff --git a/linux/assets/file-icons/icons/files/figma.svg b/linux/assets/file-icons/icons/files/figma.svg deleted file mode 100644 index dbef799af..000000000 --- a/linux/assets/file-icons/icons/files/figma.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - F - diff --git a/linux/assets/file-icons/icons/files/file.svg b/linux/assets/file-icons/icons/files/file.svg deleted file mode 100644 index 59017652f..000000000 --- a/linux/assets/file-icons/icons/files/file.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/firebase.svg b/linux/assets/file-icons/icons/files/firebase.svg deleted file mode 100644 index c118fb9bf..000000000 --- a/linux/assets/file-icons/icons/files/firebase.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/flask.svg b/linux/assets/file-icons/icons/files/flask.svg deleted file mode 100644 index 28bdc7784..000000000 --- a/linux/assets/file-icons/icons/files/flask.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/font.svg b/linux/assets/file-icons/icons/files/font.svg deleted file mode 100644 index 1cc2912bf..000000000 --- a/linux/assets/file-icons/icons/files/font.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/gatsby.svg b/linux/assets/file-icons/icons/files/gatsby.svg deleted file mode 100644 index 7a1302efe..000000000 --- a/linux/assets/file-icons/icons/files/gatsby.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - G - diff --git a/linux/assets/file-icons/icons/files/gemini.svg b/linux/assets/file-icons/icons/files/gemini.svg deleted file mode 100644 index 59aea00b6..000000000 --- a/linux/assets/file-icons/icons/files/gemini.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - G - diff --git a/linux/assets/file-icons/icons/files/git.svg b/linux/assets/file-icons/icons/files/git.svg deleted file mode 100644 index e5cd60b77..000000000 --- a/linux/assets/file-icons/icons/files/git.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/github-actions.svg b/linux/assets/file-icons/icons/files/github-actions.svg deleted file mode 100644 index aab3f321d..000000000 --- a/linux/assets/file-icons/icons/files/github-actions.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - GH - diff --git a/linux/assets/file-icons/icons/files/github.svg b/linux/assets/file-icons/icons/files/github.svg deleted file mode 100644 index 8a1f67d7b..000000000 --- a/linux/assets/file-icons/icons/files/github.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/gitlab.svg b/linux/assets/file-icons/icons/files/gitlab.svg deleted file mode 100644 index 2500a36ae..000000000 --- a/linux/assets/file-icons/icons/files/gitlab.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/go.svg b/linux/assets/file-icons/icons/files/go.svg deleted file mode 100644 index 0174b5232..000000000 --- a/linux/assets/file-icons/icons/files/go.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/gradle.svg b/linux/assets/file-icons/icons/files/gradle.svg deleted file mode 100644 index 267a55ee3..000000000 --- a/linux/assets/file-icons/icons/files/gradle.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - G - diff --git a/linux/assets/file-icons/icons/files/graphql-schema.svg b/linux/assets/file-icons/icons/files/graphql-schema.svg deleted file mode 100644 index 64f8c0f84..000000000 --- a/linux/assets/file-icons/icons/files/graphql-schema.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - GS - diff --git a/linux/assets/file-icons/icons/files/graphql.svg b/linux/assets/file-icons/icons/files/graphql.svg deleted file mode 100644 index bab772115..000000000 --- a/linux/assets/file-icons/icons/files/graphql.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - G - diff --git a/linux/assets/file-icons/icons/files/haskell.svg b/linux/assets/file-icons/icons/files/haskell.svg deleted file mode 100644 index 52ddcaeee..000000000 --- a/linux/assets/file-icons/icons/files/haskell.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - HS - diff --git a/linux/assets/file-icons/icons/files/helm.svg b/linux/assets/file-icons/icons/files/helm.svg deleted file mode 100644 index 5be27ed28..000000000 --- a/linux/assets/file-icons/icons/files/helm.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - H - diff --git a/linux/assets/file-icons/icons/files/html.svg b/linux/assets/file-icons/icons/files/html.svg deleted file mode 100644 index c80ca7a79..000000000 --- a/linux/assets/file-icons/icons/files/html.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - H - diff --git a/linux/assets/file-icons/icons/files/http.svg b/linux/assets/file-icons/icons/files/http.svg deleted file mode 100644 index 5280b368d..000000000 --- a/linux/assets/file-icons/icons/files/http.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - HT - diff --git a/linux/assets/file-icons/icons/files/hurl.svg b/linux/assets/file-icons/icons/files/hurl.svg deleted file mode 100644 index bf70415fa..000000000 --- a/linux/assets/file-icons/icons/files/hurl.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - HU - diff --git a/linux/assets/file-icons/icons/files/image.svg b/linux/assets/file-icons/icons/files/image.svg deleted file mode 100644 index 1d2bb2639..000000000 --- a/linux/assets/file-icons/icons/files/image.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/index-js.svg b/linux/assets/file-icons/icons/files/index-js.svg deleted file mode 100644 index ec34e3867..000000000 --- a/linux/assets/file-icons/icons/files/index-js.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - IDX - diff --git a/linux/assets/file-icons/icons/files/index-ts.svg b/linux/assets/file-icons/icons/files/index-ts.svg deleted file mode 100644 index 5e532a7ed..000000000 --- a/linux/assets/file-icons/icons/files/index-ts.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - IDX - diff --git a/linux/assets/file-icons/icons/files/java.svg b/linux/assets/file-icons/icons/files/java.svg deleted file mode 100644 index 2bc93d42d..000000000 --- a/linux/assets/file-icons/icons/files/java.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/javascript.svg b/linux/assets/file-icons/icons/files/javascript.svg deleted file mode 100644 index 59e3fe6ec..000000000 --- a/linux/assets/file-icons/icons/files/javascript.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - JS - diff --git a/linux/assets/file-icons/icons/files/jenkins.svg b/linux/assets/file-icons/icons/files/jenkins.svg deleted file mode 100644 index 57d712ab0..000000000 --- a/linux/assets/file-icons/icons/files/jenkins.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/jest.svg b/linux/assets/file-icons/icons/files/jest.svg deleted file mode 100644 index 473457b11..000000000 --- a/linux/assets/file-icons/icons/files/jest.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/jsconfig.svg b/linux/assets/file-icons/icons/files/jsconfig.svg deleted file mode 100644 index ce9fe1edd..000000000 --- a/linux/assets/file-icons/icons/files/jsconfig.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/json.svg b/linux/assets/file-icons/icons/files/json.svg deleted file mode 100644 index 943bb57f6..000000000 --- a/linux/assets/file-icons/icons/files/json.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - {} - diff --git a/linux/assets/file-icons/icons/files/julia.svg b/linux/assets/file-icons/icons/files/julia.svg deleted file mode 100644 index 5a2b777b7..000000000 --- a/linux/assets/file-icons/icons/files/julia.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - JL - diff --git a/linux/assets/file-icons/icons/files/key.svg b/linux/assets/file-icons/icons/files/key.svg deleted file mode 100644 index 9348cc4ce..000000000 --- a/linux/assets/file-icons/icons/files/key.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/kotlin.svg b/linux/assets/file-icons/icons/files/kotlin.svg deleted file mode 100644 index 353a11ce8..000000000 --- a/linux/assets/file-icons/icons/files/kotlin.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - K - diff --git a/linux/assets/file-icons/icons/files/kubernetes.svg b/linux/assets/file-icons/icons/files/kubernetes.svg deleted file mode 100644 index 9b38b313b..000000000 --- a/linux/assets/file-icons/icons/files/kubernetes.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - K8 - diff --git a/linux/assets/file-icons/icons/files/laravel.svg b/linux/assets/file-icons/icons/files/laravel.svg deleted file mode 100644 index 7be1f3df4..000000000 --- a/linux/assets/file-icons/icons/files/laravel.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/layout.svg b/linux/assets/file-icons/icons/files/layout.svg deleted file mode 100644 index ccdd1d73f..000000000 --- a/linux/assets/file-icons/icons/files/layout.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - L - diff --git a/linux/assets/file-icons/icons/files/license.svg b/linux/assets/file-icons/icons/files/license.svg deleted file mode 100644 index 053cf8990..000000000 --- a/linux/assets/file-icons/icons/files/license.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - LIC - diff --git a/linux/assets/file-icons/icons/files/lintstaged.svg b/linux/assets/file-icons/icons/files/lintstaged.svg deleted file mode 100644 index dcbb7fbce..000000000 --- a/linux/assets/file-icons/icons/files/lintstaged.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - LS - diff --git a/linux/assets/file-icons/icons/files/linux.svg b/linux/assets/file-icons/icons/files/linux.svg deleted file mode 100644 index b7baaefb1..000000000 --- a/linux/assets/file-icons/icons/files/linux.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/lit.svg b/linux/assets/file-icons/icons/files/lit.svg deleted file mode 100644 index 41956bae5..000000000 --- a/linux/assets/file-icons/icons/files/lit.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/lithe.svg b/linux/assets/file-icons/icons/files/lithe.svg deleted file mode 100644 index 9dd935c7e..000000000 --- a/linux/assets/file-icons/icons/files/lithe.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - L - diff --git a/linux/assets/file-icons/icons/files/loading.svg b/linux/assets/file-icons/icons/files/loading.svg deleted file mode 100644 index bc3f15ad1..000000000 --- a/linux/assets/file-icons/icons/files/loading.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - ... - diff --git a/linux/assets/file-icons/icons/files/lock.svg b/linux/assets/file-icons/icons/files/lock.svg deleted file mode 100644 index 278b20f1c..000000000 --- a/linux/assets/file-icons/icons/files/lock.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/log.svg b/linux/assets/file-icons/icons/files/log.svg deleted file mode 100644 index 936d5e2a3..000000000 --- a/linux/assets/file-icons/icons/files/log.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - LOG - diff --git a/linux/assets/file-icons/icons/files/lua.svg b/linux/assets/file-icons/icons/files/lua.svg deleted file mode 100644 index 25ea107b8..000000000 --- a/linux/assets/file-icons/icons/files/lua.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - L - diff --git a/linux/assets/file-icons/icons/files/makefile.svg b/linux/assets/file-icons/icons/files/makefile.svg deleted file mode 100644 index a927f9c41..000000000 --- a/linux/assets/file-icons/icons/files/makefile.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/markdown.svg b/linux/assets/file-icons/icons/files/markdown.svg deleted file mode 100644 index 027d5207f..000000000 --- a/linux/assets/file-icons/icons/files/markdown.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/markdownlint.svg b/linux/assets/file-icons/icons/files/markdownlint.svg deleted file mode 100644 index 22e601114..000000000 --- a/linux/assets/file-icons/icons/files/markdownlint.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/maven.svg b/linux/assets/file-icons/icons/files/maven.svg deleted file mode 100644 index dd935da72..000000000 --- a/linux/assets/file-icons/icons/files/maven.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - M - diff --git a/linux/assets/file-icons/icons/files/mcp.svg b/linux/assets/file-icons/icons/files/mcp.svg deleted file mode 100644 index 963f893fe..000000000 --- a/linux/assets/file-icons/icons/files/mcp.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - M - diff --git a/linux/assets/file-icons/icons/files/mermaid.svg b/linux/assets/file-icons/icons/files/mermaid.svg deleted file mode 100644 index eb7cab28a..000000000 --- a/linux/assets/file-icons/icons/files/mermaid.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - MM - diff --git a/linux/assets/file-icons/icons/files/mongo.svg b/linux/assets/file-icons/icons/files/mongo.svg deleted file mode 100644 index 96e2a647b..000000000 --- a/linux/assets/file-icons/icons/files/mongo.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/netlify.svg b/linux/assets/file-icons/icons/files/netlify.svg deleted file mode 100644 index 133725c92..000000000 --- a/linux/assets/file-icons/icons/files/netlify.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - N - diff --git a/linux/assets/file-icons/icons/files/next.svg b/linux/assets/file-icons/icons/files/next.svg deleted file mode 100644 index e2d90d83d..000000000 --- a/linux/assets/file-icons/icons/files/next.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - N - diff --git a/linux/assets/file-icons/icons/files/nginx.svg b/linux/assets/file-icons/icons/files/nginx.svg deleted file mode 100644 index 7f1d606bf..000000000 --- a/linux/assets/file-icons/icons/files/nginx.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - N - diff --git a/linux/assets/file-icons/icons/files/nim.svg b/linux/assets/file-icons/icons/files/nim.svg deleted file mode 100644 index ac80cf1a1..000000000 --- a/linux/assets/file-icons/icons/files/nim.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - N - diff --git a/linux/assets/file-icons/icons/files/nix.svg b/linux/assets/file-icons/icons/files/nix.svg deleted file mode 100644 index f8027026f..000000000 --- a/linux/assets/file-icons/icons/files/nix.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - N - diff --git a/linux/assets/file-icons/icons/files/node.svg b/linux/assets/file-icons/icons/files/node.svg deleted file mode 100644 index aaf878edc..000000000 --- a/linux/assets/file-icons/icons/files/node.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - N - diff --git a/linux/assets/file-icons/icons/files/not-found.svg b/linux/assets/file-icons/icons/files/not-found.svg deleted file mode 100644 index b0ef19fe1..000000000 --- a/linux/assets/file-icons/icons/files/not-found.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/notebook.svg b/linux/assets/file-icons/icons/files/notebook.svg deleted file mode 100644 index 3c841c9e8..000000000 --- a/linux/assets/file-icons/icons/files/notebook.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/npm.svg b/linux/assets/file-icons/icons/files/npm.svg deleted file mode 100644 index 26c322283..000000000 --- a/linux/assets/file-icons/icons/files/npm.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - N - diff --git a/linux/assets/file-icons/icons/files/nuxt.svg b/linux/assets/file-icons/icons/files/nuxt.svg deleted file mode 100644 index fb699c2a2..000000000 --- a/linux/assets/file-icons/icons/files/nuxt.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - N - diff --git a/linux/assets/file-icons/icons/files/nx.svg b/linux/assets/file-icons/icons/files/nx.svg deleted file mode 100644 index ef0c748d8..000000000 --- a/linux/assets/file-icons/icons/files/nx.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - NX - diff --git a/linux/assets/file-icons/icons/files/ocaml.svg b/linux/assets/file-icons/icons/files/ocaml.svg deleted file mode 100644 index 02d552768..000000000 --- a/linux/assets/file-icons/icons/files/ocaml.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - ML - diff --git a/linux/assets/file-icons/icons/files/package.svg b/linux/assets/file-icons/icons/files/package.svg deleted file mode 100644 index 51f25f450..000000000 --- a/linux/assets/file-icons/icons/files/package.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/page.svg b/linux/assets/file-icons/icons/files/page.svg deleted file mode 100644 index 860d4e6b4..000000000 --- a/linux/assets/file-icons/icons/files/page.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - P - diff --git a/linux/assets/file-icons/icons/files/patch.svg b/linux/assets/file-icons/icons/files/patch.svg deleted file mode 100644 index ddaa0c327..000000000 --- a/linux/assets/file-icons/icons/files/patch.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - +- - diff --git a/linux/assets/file-icons/icons/files/pdf.svg b/linux/assets/file-icons/icons/files/pdf.svg deleted file mode 100644 index 15236fe62..000000000 --- a/linux/assets/file-icons/icons/files/pdf.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - PDF - diff --git a/linux/assets/file-icons/icons/files/perl.svg b/linux/assets/file-icons/icons/files/perl.svg deleted file mode 100644 index 6e5a983ae..000000000 --- a/linux/assets/file-icons/icons/files/perl.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - PL - diff --git a/linux/assets/file-icons/icons/files/php.svg b/linux/assets/file-icons/icons/files/php.svg deleted file mode 100644 index a40c8ff3c..000000000 --- a/linux/assets/file-icons/icons/files/php.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - P - diff --git a/linux/assets/file-icons/icons/files/playwright.svg b/linux/assets/file-icons/icons/files/playwright.svg deleted file mode 100644 index 68522e996..000000000 --- a/linux/assets/file-icons/icons/files/playwright.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/pnpm.svg b/linux/assets/file-icons/icons/files/pnpm.svg deleted file mode 100644 index fcc30d19e..000000000 --- a/linux/assets/file-icons/icons/files/pnpm.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - P - diff --git a/linux/assets/file-icons/icons/files/postgres.svg b/linux/assets/file-icons/icons/files/postgres.svg deleted file mode 100644 index 0f5a3a35f..000000000 --- a/linux/assets/file-icons/icons/files/postgres.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/powerpoint.svg b/linux/assets/file-icons/icons/files/powerpoint.svg deleted file mode 100644 index 28ff619e1..000000000 --- a/linux/assets/file-icons/icons/files/powerpoint.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - PPT - diff --git a/linux/assets/file-icons/icons/files/prettier.svg b/linux/assets/file-icons/icons/files/prettier.svg deleted file mode 100644 index fc6d6e16a..000000000 --- a/linux/assets/file-icons/icons/files/prettier.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/prisma.svg b/linux/assets/file-icons/icons/files/prisma.svg deleted file mode 100644 index 2d6f445e2..000000000 --- a/linux/assets/file-icons/icons/files/prisma.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/proto.svg b/linux/assets/file-icons/icons/files/proto.svg deleted file mode 100644 index e116a7fdd..000000000 --- a/linux/assets/file-icons/icons/files/proto.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - P - diff --git a/linux/assets/file-icons/icons/files/python.svg b/linux/assets/file-icons/icons/files/python.svg deleted file mode 100644 index 2a3ffb94c..000000000 --- a/linux/assets/file-icons/icons/files/python.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/qwik.svg b/linux/assets/file-icons/icons/files/qwik.svg deleted file mode 100644 index fa7b9a5c9..000000000 --- a/linux/assets/file-icons/icons/files/qwik.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - Q - diff --git a/linux/assets/file-icons/icons/files/r.svg b/linux/assets/file-icons/icons/files/r.svg deleted file mode 100644 index abf4b93c1..000000000 --- a/linux/assets/file-icons/icons/files/r.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - R - diff --git a/linux/assets/file-icons/icons/files/react.svg b/linux/assets/file-icons/icons/files/react.svg deleted file mode 100644 index 88525c8e3..000000000 --- a/linux/assets/file-icons/icons/files/react.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/redis.svg b/linux/assets/file-icons/icons/files/redis.svg deleted file mode 100644 index ef6c2033a..000000000 --- a/linux/assets/file-icons/icons/files/redis.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/remix.svg b/linux/assets/file-icons/icons/files/remix.svg deleted file mode 100644 index e8dbe13ce..000000000 --- a/linux/assets/file-icons/icons/files/remix.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - R - diff --git a/linux/assets/file-icons/icons/files/renovate.svg b/linux/assets/file-icons/icons/files/renovate.svg deleted file mode 100644 index 4690deb44..000000000 --- a/linux/assets/file-icons/icons/files/renovate.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/rescript.svg b/linux/assets/file-icons/icons/files/rescript.svg deleted file mode 100644 index f2d3b7c61..000000000 --- a/linux/assets/file-icons/icons/files/rescript.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - RE - diff --git a/linux/assets/file-icons/icons/files/rollup.svg b/linux/assets/file-icons/icons/files/rollup.svg deleted file mode 100644 index 869e62dde..000000000 --- a/linux/assets/file-icons/icons/files/rollup.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - R - diff --git a/linux/assets/file-icons/icons/files/route.svg b/linux/assets/file-icons/icons/files/route.svg deleted file mode 100644 index 5e2a4c710..000000000 --- a/linux/assets/file-icons/icons/files/route.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - RT - diff --git a/linux/assets/file-icons/icons/files/rspack.svg b/linux/assets/file-icons/icons/files/rspack.svg deleted file mode 100644 index 302fab094..000000000 --- a/linux/assets/file-icons/icons/files/rspack.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - R - diff --git a/linux/assets/file-icons/icons/files/ruby.svg b/linux/assets/file-icons/icons/files/ruby.svg deleted file mode 100644 index 818e46b2f..000000000 --- a/linux/assets/file-icons/icons/files/ruby.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - RB - diff --git a/linux/assets/file-icons/icons/files/rust.svg b/linux/assets/file-icons/icons/files/rust.svg deleted file mode 100644 index 3b109303d..000000000 --- a/linux/assets/file-icons/icons/files/rust.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/sass.svg b/linux/assets/file-icons/icons/files/sass.svg deleted file mode 100644 index 93d0d5417..000000000 --- a/linux/assets/file-icons/icons/files/sass.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - S - diff --git a/linux/assets/file-icons/icons/files/scala.svg b/linux/assets/file-icons/icons/files/scala.svg deleted file mode 100644 index 2899b8ef7..000000000 --- a/linux/assets/file-icons/icons/files/scala.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - S - diff --git a/linux/assets/file-icons/icons/files/security.svg b/linux/assets/file-icons/icons/files/security.svg deleted file mode 100644 index e8cfb3b5d..000000000 --- a/linux/assets/file-icons/icons/files/security.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - SEC - diff --git a/linux/assets/file-icons/icons/files/shell.svg b/linux/assets/file-icons/icons/files/shell.svg deleted file mode 100644 index 95aecf385..000000000 --- a/linux/assets/file-icons/icons/files/shell.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/sketch.svg b/linux/assets/file-icons/icons/files/sketch.svg deleted file mode 100644 index 1fcb9ba0b..000000000 --- a/linux/assets/file-icons/icons/files/sketch.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - S - diff --git a/linux/assets/file-icons/icons/files/solid.svg b/linux/assets/file-icons/icons/files/solid.svg deleted file mode 100644 index 1eb34aeef..000000000 --- a/linux/assets/file-icons/icons/files/solid.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - S - diff --git a/linux/assets/file-icons/icons/files/solidity.svg b/linux/assets/file-icons/icons/files/solidity.svg deleted file mode 100644 index 1a9a884d8..000000000 --- a/linux/assets/file-icons/icons/files/solidity.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - S - diff --git a/linux/assets/file-icons/icons/files/spreadsheet.svg b/linux/assets/file-icons/icons/files/spreadsheet.svg deleted file mode 100644 index 591053b33..000000000 --- a/linux/assets/file-icons/icons/files/spreadsheet.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - XLS - diff --git a/linux/assets/file-icons/icons/files/sql.svg b/linux/assets/file-icons/icons/files/sql.svg deleted file mode 100644 index 886f5f0e8..000000000 --- a/linux/assets/file-icons/icons/files/sql.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/storybook.svg b/linux/assets/file-icons/icons/files/storybook.svg deleted file mode 100644 index 508b0d4b5..000000000 --- a/linux/assets/file-icons/icons/files/storybook.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/stylelint.svg b/linux/assets/file-icons/icons/files/stylelint.svg deleted file mode 100644 index ddffe630a..000000000 --- a/linux/assets/file-icons/icons/files/stylelint.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - SL - diff --git a/linux/assets/file-icons/icons/files/supabase.svg b/linux/assets/file-icons/icons/files/supabase.svg deleted file mode 100644 index 7fa1d07fb..000000000 --- a/linux/assets/file-icons/icons/files/supabase.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/svelte.svg b/linux/assets/file-icons/icons/files/svelte.svg deleted file mode 100644 index fd08f3199..000000000 --- a/linux/assets/file-icons/icons/files/svelte.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - S - diff --git a/linux/assets/file-icons/icons/files/svg.svg b/linux/assets/file-icons/icons/files/svg.svg deleted file mode 100644 index 82fe92ab9..000000000 --- a/linux/assets/file-icons/icons/files/svg.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - S - diff --git a/linux/assets/file-icons/icons/files/swc.svg b/linux/assets/file-icons/icons/files/swc.svg deleted file mode 100644 index 1a45cc869..000000000 --- a/linux/assets/file-icons/icons/files/swc.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - S - diff --git a/linux/assets/file-icons/icons/files/swift.svg b/linux/assets/file-icons/icons/files/swift.svg deleted file mode 100644 index 0821c2986..000000000 --- a/linux/assets/file-icons/icons/files/swift.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/tailwind.svg b/linux/assets/file-icons/icons/files/tailwind.svg deleted file mode 100644 index 6142d3e56..000000000 --- a/linux/assets/file-icons/icons/files/tailwind.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - T - diff --git a/linux/assets/file-icons/icons/files/tauri.svg b/linux/assets/file-icons/icons/files/tauri.svg deleted file mode 100644 index d98590ba6..000000000 --- a/linux/assets/file-icons/icons/files/tauri.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/terraform.svg b/linux/assets/file-icons/icons/files/terraform.svg deleted file mode 100644 index f02174afb..000000000 --- a/linux/assets/file-icons/icons/files/terraform.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - TF - diff --git a/linux/assets/file-icons/icons/files/test.svg b/linux/assets/file-icons/icons/files/test.svg deleted file mode 100644 index 640d48ccb..000000000 --- a/linux/assets/file-icons/icons/files/test.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/text.svg b/linux/assets/file-icons/icons/files/text.svg deleted file mode 100644 index ce1557bcb..000000000 --- a/linux/assets/file-icons/icons/files/text.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - TXT - diff --git a/linux/assets/file-icons/icons/files/toml.svg b/linux/assets/file-icons/icons/files/toml.svg deleted file mode 100644 index 690cf1e3b..000000000 --- a/linux/assets/file-icons/icons/files/toml.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/turborepo.svg b/linux/assets/file-icons/icons/files/turborepo.svg deleted file mode 100644 index 01c8c146d..000000000 --- a/linux/assets/file-icons/icons/files/turborepo.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - T - diff --git a/linux/assets/file-icons/icons/files/typescript.svg b/linux/assets/file-icons/icons/files/typescript.svg deleted file mode 100644 index 6bb44cce8..000000000 --- a/linux/assets/file-icons/icons/files/typescript.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - TS - diff --git a/linux/assets/file-icons/icons/files/vercel-config.svg b/linux/assets/file-icons/icons/files/vercel-config.svg deleted file mode 100644 index 520eb3b64..000000000 --- a/linux/assets/file-icons/icons/files/vercel-config.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - VC - diff --git a/linux/assets/file-icons/icons/files/vercel.svg b/linux/assets/file-icons/icons/files/vercel.svg deleted file mode 100644 index 045058ceb..000000000 --- a/linux/assets/file-icons/icons/files/vercel.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - V - diff --git a/linux/assets/file-icons/icons/files/video.svg b/linux/assets/file-icons/icons/files/video.svg deleted file mode 100644 index dad213208..000000000 --- a/linux/assets/file-icons/icons/files/video.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/vite.svg b/linux/assets/file-icons/icons/files/vite.svg deleted file mode 100644 index 7101b8e53..000000000 --- a/linux/assets/file-icons/icons/files/vite.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - V - diff --git a/linux/assets/file-icons/icons/files/vitest.svg b/linux/assets/file-icons/icons/files/vitest.svg deleted file mode 100644 index 3a4824a93..000000000 --- a/linux/assets/file-icons/icons/files/vitest.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/vue.svg b/linux/assets/file-icons/icons/files/vue.svg deleted file mode 100644 index afddcd1dd..000000000 --- a/linux/assets/file-icons/icons/files/vue.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - V - diff --git a/linux/assets/file-icons/icons/files/warning.svg b/linux/assets/file-icons/icons/files/warning.svg deleted file mode 100644 index 576f04b1d..000000000 --- a/linux/assets/file-icons/icons/files/warning.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/wasm.svg b/linux/assets/file-icons/icons/files/wasm.svg deleted file mode 100644 index ed16980f5..000000000 --- a/linux/assets/file-icons/icons/files/wasm.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - W - diff --git a/linux/assets/file-icons/icons/files/webpack.svg b/linux/assets/file-icons/icons/files/webpack.svg deleted file mode 100644 index 512e1c057..000000000 --- a/linux/assets/file-icons/icons/files/webpack.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - W - diff --git a/linux/assets/file-icons/icons/files/windows.svg b/linux/assets/file-icons/icons/files/windows.svg deleted file mode 100644 index dccc1f9bf..000000000 --- a/linux/assets/file-icons/icons/files/windows.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - W - diff --git a/linux/assets/file-icons/icons/files/word.svg b/linux/assets/file-icons/icons/files/word.svg deleted file mode 100644 index 850083eff..000000000 --- a/linux/assets/file-icons/icons/files/word.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - DOC - diff --git a/linux/assets/file-icons/icons/files/xcode.svg b/linux/assets/file-icons/icons/files/xcode.svg deleted file mode 100644 index 9b63fe1cd..000000000 --- a/linux/assets/file-icons/icons/files/xcode.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/files/xml.svg b/linux/assets/file-icons/icons/files/xml.svg deleted file mode 100644 index 7905a6bdf..000000000 --- a/linux/assets/file-icons/icons/files/xml.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - <> - diff --git a/linux/assets/file-icons/icons/files/yaml.svg b/linux/assets/file-icons/icons/files/yaml.svg deleted file mode 100644 index 262b00903..000000000 --- a/linux/assets/file-icons/icons/files/yaml.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - Y - diff --git a/linux/assets/file-icons/icons/files/yarn.svg b/linux/assets/file-icons/icons/files/yarn.svg deleted file mode 100644 index 9ee784fc6..000000000 --- a/linux/assets/file-icons/icons/files/yarn.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - Y - diff --git a/linux/assets/file-icons/icons/files/zig.svg b/linux/assets/file-icons/icons/files/zig.svg deleted file mode 100644 index 613c135a4..000000000 --- a/linux/assets/file-icons/icons/files/zig.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-ai-open.svg b/linux/assets/file-icons/icons/folders/folder-ai-open.svg deleted file mode 100644 index c002af7f1..000000000 --- a/linux/assets/file-icons/icons/folders/folder-ai-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-ai.svg b/linux/assets/file-icons/icons/folders/folder-ai.svg deleted file mode 100644 index c2a7d27f1..000000000 --- a/linux/assets/file-icons/icons/folders/folder-ai.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-assets-open.svg b/linux/assets/file-icons/icons/folders/folder-assets-open.svg deleted file mode 100644 index 07c3cc1c8..000000000 --- a/linux/assets/file-icons/icons/folders/folder-assets-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-assets.svg b/linux/assets/file-icons/icons/folders/folder-assets.svg deleted file mode 100644 index a4505fc3f..000000000 --- a/linux/assets/file-icons/icons/folders/folder-assets.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-build-open.svg b/linux/assets/file-icons/icons/folders/folder-build-open.svg deleted file mode 100644 index 677645f72..000000000 --- a/linux/assets/file-icons/icons/folders/folder-build-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-build.svg b/linux/assets/file-icons/icons/folders/folder-build.svg deleted file mode 100644 index f8ae479c8..000000000 --- a/linux/assets/file-icons/icons/folders/folder-build.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-cloud-open.svg b/linux/assets/file-icons/icons/folders/folder-cloud-open.svg deleted file mode 100644 index 93e712555..000000000 --- a/linux/assets/file-icons/icons/folders/folder-cloud-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-cloud.svg b/linux/assets/file-icons/icons/folders/folder-cloud.svg deleted file mode 100644 index fd76ff9e7..000000000 --- a/linux/assets/file-icons/icons/folders/folder-cloud.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-components-open.svg b/linux/assets/file-icons/icons/folders/folder-components-open.svg deleted file mode 100644 index eaf07554e..000000000 --- a/linux/assets/file-icons/icons/folders/folder-components-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-components.svg b/linux/assets/file-icons/icons/folders/folder-components.svg deleted file mode 100644 index 9375049e5..000000000 --- a/linux/assets/file-icons/icons/folders/folder-components.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-config-open.svg b/linux/assets/file-icons/icons/folders/folder-config-open.svg deleted file mode 100644 index 433c9e0e9..000000000 --- a/linux/assets/file-icons/icons/folders/folder-config-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-config.svg b/linux/assets/file-icons/icons/folders/folder-config.svg deleted file mode 100644 index 68e7304c2..000000000 --- a/linux/assets/file-icons/icons/folders/folder-config.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-database-open.svg b/linux/assets/file-icons/icons/folders/folder-database-open.svg deleted file mode 100644 index dcd2fd81a..000000000 --- a/linux/assets/file-icons/icons/folders/folder-database-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-database.svg b/linux/assets/file-icons/icons/folders/folder-database.svg deleted file mode 100644 index 0ed82fe06..000000000 --- a/linux/assets/file-icons/icons/folders/folder-database.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-docs-open.svg b/linux/assets/file-icons/icons/folders/folder-docs-open.svg deleted file mode 100644 index 15024b33e..000000000 --- a/linux/assets/file-icons/icons/folders/folder-docs-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-docs.svg b/linux/assets/file-icons/icons/folders/folder-docs.svg deleted file mode 100644 index 65acb0762..000000000 --- a/linux/assets/file-icons/icons/folders/folder-docs.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-extensions-open.svg b/linux/assets/file-icons/icons/folders/folder-extensions-open.svg deleted file mode 100644 index c07f6810c..000000000 --- a/linux/assets/file-icons/icons/folders/folder-extensions-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-extensions.svg b/linux/assets/file-icons/icons/folders/folder-extensions.svg deleted file mode 100644 index ebcfc4aa0..000000000 --- a/linux/assets/file-icons/icons/folders/folder-extensions.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-git-open.svg b/linux/assets/file-icons/icons/folders/folder-git-open.svg deleted file mode 100644 index df8eca0c3..000000000 --- a/linux/assets/file-icons/icons/folders/folder-git-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-git.svg b/linux/assets/file-icons/icons/folders/folder-git.svg deleted file mode 100644 index 2e6f82e35..000000000 --- a/linux/assets/file-icons/icons/folders/folder-git.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-locales-open.svg b/linux/assets/file-icons/icons/folders/folder-locales-open.svg deleted file mode 100644 index ea2f034f8..000000000 --- a/linux/assets/file-icons/icons/folders/folder-locales-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-locales.svg b/linux/assets/file-icons/icons/folders/folder-locales.svg deleted file mode 100644 index 785df402a..000000000 --- a/linux/assets/file-icons/icons/folders/folder-locales.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-mobile-open.svg b/linux/assets/file-icons/icons/folders/folder-mobile-open.svg deleted file mode 100644 index f1e6fc141..000000000 --- a/linux/assets/file-icons/icons/folders/folder-mobile-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-mobile.svg b/linux/assets/file-icons/icons/folders/folder-mobile.svg deleted file mode 100644 index 6f5dc8122..000000000 --- a/linux/assets/file-icons/icons/folders/folder-mobile.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-open.svg b/linux/assets/file-icons/icons/folders/folder-open.svg deleted file mode 100644 index 44cc2562f..000000000 --- a/linux/assets/file-icons/icons/folders/folder-open.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-packages-open.svg b/linux/assets/file-icons/icons/folders/folder-packages-open.svg deleted file mode 100644 index 372bb7bea..000000000 --- a/linux/assets/file-icons/icons/folders/folder-packages-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-packages.svg b/linux/assets/file-icons/icons/folders/folder-packages.svg deleted file mode 100644 index c60c0c158..000000000 --- a/linux/assets/file-icons/icons/folders/folder-packages.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-routes-open.svg b/linux/assets/file-icons/icons/folders/folder-routes-open.svg deleted file mode 100644 index e11a12e42..000000000 --- a/linux/assets/file-icons/icons/folders/folder-routes-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-routes.svg b/linux/assets/file-icons/icons/folders/folder-routes.svg deleted file mode 100644 index ca413527a..000000000 --- a/linux/assets/file-icons/icons/folders/folder-routes.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-rust-open.svg b/linux/assets/file-icons/icons/folders/folder-rust-open.svg deleted file mode 100644 index a58f44340..000000000 --- a/linux/assets/file-icons/icons/folders/folder-rust-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-rust.svg b/linux/assets/file-icons/icons/folders/folder-rust.svg deleted file mode 100644 index b4d7ad475..000000000 --- a/linux/assets/file-icons/icons/folders/folder-rust.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-scripts-open.svg b/linux/assets/file-icons/icons/folders/folder-scripts-open.svg deleted file mode 100644 index 48be14c05..000000000 --- a/linux/assets/file-icons/icons/folders/folder-scripts-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-scripts.svg b/linux/assets/file-icons/icons/folders/folder-scripts.svg deleted file mode 100644 index af0eae3b8..000000000 --- a/linux/assets/file-icons/icons/folders/folder-scripts.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-security-open.svg b/linux/assets/file-icons/icons/folders/folder-security-open.svg deleted file mode 100644 index f440c6c94..000000000 --- a/linux/assets/file-icons/icons/folders/folder-security-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-security.svg b/linux/assets/file-icons/icons/folders/folder-security.svg deleted file mode 100644 index 7f48358f2..000000000 --- a/linux/assets/file-icons/icons/folders/folder-security.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-source-open.svg b/linux/assets/file-icons/icons/folders/folder-source-open.svg deleted file mode 100644 index 4a3ed1046..000000000 --- a/linux/assets/file-icons/icons/folders/folder-source-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-source.svg b/linux/assets/file-icons/icons/folders/folder-source.svg deleted file mode 100644 index 88bae64e8..000000000 --- a/linux/assets/file-icons/icons/folders/folder-source.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-styles-open.svg b/linux/assets/file-icons/icons/folders/folder-styles-open.svg deleted file mode 100644 index 54fb0735a..000000000 --- a/linux/assets/file-icons/icons/folders/folder-styles-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-styles.svg b/linux/assets/file-icons/icons/folders/folder-styles.svg deleted file mode 100644 index 3bc647e83..000000000 --- a/linux/assets/file-icons/icons/folders/folder-styles.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-test-open.svg b/linux/assets/file-icons/icons/folders/folder-test-open.svg deleted file mode 100644 index 590550958..000000000 --- a/linux/assets/file-icons/icons/folders/folder-test-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder-test.svg b/linux/assets/file-icons/icons/folders/folder-test.svg deleted file mode 100644 index 1de3ba775..000000000 --- a/linux/assets/file-icons/icons/folders/folder-test.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/folders/folder.svg b/linux/assets/file-icons/icons/folders/folder.svg deleted file mode 100644 index 6e0864bb3..000000000 --- a/linux/assets/file-icons/icons/folders/folder.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/linux/assets/file-icons/icons/light/files/adobe.svg b/linux/assets/file-icons/icons/light/files/adobe.svg deleted file mode 100644 index ba716eb30..000000000 --- a/linux/assets/file-icons/icons/light/files/adobe.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - A - diff --git a/linux/assets/file-icons/icons/light/files/agents.svg b/linux/assets/file-icons/icons/light/files/agents.svg deleted file mode 100644 index e787c0597..000000000 --- a/linux/assets/file-icons/icons/light/files/agents.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - AI - diff --git a/linux/assets/file-icons/icons/light/files/android.svg b/linux/assets/file-icons/icons/light/files/android.svg deleted file mode 100644 index f30f85a54..000000000 --- a/linux/assets/file-icons/icons/light/files/android.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/angular.svg b/linux/assets/file-icons/icons/light/files/angular.svg deleted file mode 100644 index 8d0550c61..000000000 --- a/linux/assets/file-icons/icons/light/files/angular.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/ansible.svg b/linux/assets/file-icons/icons/light/files/ansible.svg deleted file mode 100644 index 5b3892010..000000000 --- a/linux/assets/file-icons/icons/light/files/ansible.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - A - diff --git a/linux/assets/file-icons/icons/light/files/apple.svg b/linux/assets/file-icons/icons/light/files/apple.svg deleted file mode 100644 index acba52121..000000000 --- a/linux/assets/file-icons/icons/light/files/apple.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/archive.svg b/linux/assets/file-icons/icons/light/files/archive.svg deleted file mode 100644 index e952d5813..000000000 --- a/linux/assets/file-icons/icons/light/files/archive.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/arduino.svg b/linux/assets/file-icons/icons/light/files/arduino.svg deleted file mode 100644 index 08875c147..000000000 --- a/linux/assets/file-icons/icons/light/files/arduino.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - A - diff --git a/linux/assets/file-icons/icons/light/files/astro.svg b/linux/assets/file-icons/icons/light/files/astro.svg deleted file mode 100644 index 19fd05318..000000000 --- a/linux/assets/file-icons/icons/light/files/astro.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - A - diff --git a/linux/assets/file-icons/icons/light/files/audio.svg b/linux/assets/file-icons/icons/light/files/audio.svg deleted file mode 100644 index 96d9360fd..000000000 --- a/linux/assets/file-icons/icons/light/files/audio.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/authors.svg b/linux/assets/file-icons/icons/light/files/authors.svg deleted file mode 100644 index b408e4921..000000000 --- a/linux/assets/file-icons/icons/light/files/authors.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - BY - diff --git a/linux/assets/file-icons/icons/light/files/babel.svg b/linux/assets/file-icons/icons/light/files/babel.svg deleted file mode 100644 index c3ce53910..000000000 --- a/linux/assets/file-icons/icons/light/files/babel.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - B - diff --git a/linux/assets/file-icons/icons/light/files/biome.svg b/linux/assets/file-icons/icons/light/files/biome.svg deleted file mode 100644 index 7c80ba33b..000000000 --- a/linux/assets/file-icons/icons/light/files/biome.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/bitbucket.svg b/linux/assets/file-icons/icons/light/files/bitbucket.svg deleted file mode 100644 index 7c32ee162..000000000 --- a/linux/assets/file-icons/icons/light/files/bitbucket.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/blender.svg b/linux/assets/file-icons/icons/light/files/blender.svg deleted file mode 100644 index 4153cd689..000000000 --- a/linux/assets/file-icons/icons/light/files/blender.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - B - diff --git a/linux/assets/file-icons/icons/light/files/bun.svg b/linux/assets/file-icons/icons/light/files/bun.svg deleted file mode 100644 index 82af13464..000000000 --- a/linux/assets/file-icons/icons/light/files/bun.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - B - diff --git a/linux/assets/file-icons/icons/light/files/c.svg b/linux/assets/file-icons/icons/light/files/c.svg deleted file mode 100644 index 996ae83d7..000000000 --- a/linux/assets/file-icons/icons/light/files/c.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - C - diff --git a/linux/assets/file-icons/icons/light/files/certificate.svg b/linux/assets/file-icons/icons/light/files/certificate.svg deleted file mode 100644 index 1e5abe60f..000000000 --- a/linux/assets/file-icons/icons/light/files/certificate.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - CRT - diff --git a/linux/assets/file-icons/icons/light/files/changelog.svg b/linux/assets/file-icons/icons/light/files/changelog.svg deleted file mode 100644 index ef01e6c7d..000000000 --- a/linux/assets/file-icons/icons/light/files/changelog.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - LOG - diff --git a/linux/assets/file-icons/icons/light/files/claude.svg b/linux/assets/file-icons/icons/light/files/claude.svg deleted file mode 100644 index 378c9e243..000000000 --- a/linux/assets/file-icons/icons/light/files/claude.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - AI - diff --git a/linux/assets/file-icons/icons/light/files/cline.svg b/linux/assets/file-icons/icons/light/files/cline.svg deleted file mode 100644 index 552832ef4..000000000 --- a/linux/assets/file-icons/icons/light/files/cline.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/clojure.svg b/linux/assets/file-icons/icons/light/files/clojure.svg deleted file mode 100644 index d9521265b..000000000 --- a/linux/assets/file-icons/icons/light/files/clojure.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/cloudflare.svg b/linux/assets/file-icons/icons/light/files/cloudflare.svg deleted file mode 100644 index 97d63b54c..000000000 --- a/linux/assets/file-icons/icons/light/files/cloudflare.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - CF - diff --git a/linux/assets/file-icons/icons/light/files/cmake.svg b/linux/assets/file-icons/icons/light/files/cmake.svg deleted file mode 100644 index cc06991ae..000000000 --- a/linux/assets/file-icons/icons/light/files/cmake.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/codex.svg b/linux/assets/file-icons/icons/light/files/codex.svg deleted file mode 100644 index fd1a89358..000000000 --- a/linux/assets/file-icons/icons/light/files/codex.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/commitlint.svg b/linux/assets/file-icons/icons/light/files/commitlint.svg deleted file mode 100644 index 9566b78f6..000000000 --- a/linux/assets/file-icons/icons/light/files/commitlint.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/config.svg b/linux/assets/file-icons/icons/light/files/config.svg deleted file mode 100644 index ab0ecc327..000000000 --- a/linux/assets/file-icons/icons/light/files/config.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/copilot.svg b/linux/assets/file-icons/icons/light/files/copilot.svg deleted file mode 100644 index a7ef00343..000000000 --- a/linux/assets/file-icons/icons/light/files/copilot.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - AI - diff --git a/linux/assets/file-icons/icons/light/files/cpp.svg b/linux/assets/file-icons/icons/light/files/cpp.svg deleted file mode 100644 index b71ea0cad..000000000 --- a/linux/assets/file-icons/icons/light/files/cpp.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - C+ - diff --git a/linux/assets/file-icons/icons/light/files/csharp.svg b/linux/assets/file-icons/icons/light/files/csharp.svg deleted file mode 100644 index 2f49b3ea6..000000000 --- a/linux/assets/file-icons/icons/light/files/csharp.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - C# - diff --git a/linux/assets/file-icons/icons/light/files/cspell.svg b/linux/assets/file-icons/icons/light/files/cspell.svg deleted file mode 100644 index 1d7e55469..000000000 --- a/linux/assets/file-icons/icons/light/files/cspell.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/css.svg b/linux/assets/file-icons/icons/light/files/css.svg deleted file mode 100644 index 375de6653..000000000 --- a/linux/assets/file-icons/icons/light/files/css.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - # - diff --git a/linux/assets/file-icons/icons/light/files/csv.svg b/linux/assets/file-icons/icons/light/files/csv.svg deleted file mode 100644 index 7ed227c51..000000000 --- a/linux/assets/file-icons/icons/light/files/csv.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - CSV - diff --git a/linux/assets/file-icons/icons/light/files/cursor.svg b/linux/assets/file-icons/icons/light/files/cursor.svg deleted file mode 100644 index df8003f2e..000000000 --- a/linux/assets/file-icons/icons/light/files/cursor.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/cypress.svg b/linux/assets/file-icons/icons/light/files/cypress.svg deleted file mode 100644 index 34e832521..000000000 --- a/linux/assets/file-icons/icons/light/files/cypress.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/dart.svg b/linux/assets/file-icons/icons/light/files/dart.svg deleted file mode 100644 index 941437104..000000000 --- a/linux/assets/file-icons/icons/light/files/dart.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - D - diff --git a/linux/assets/file-icons/icons/light/files/database.svg b/linux/assets/file-icons/icons/light/files/database.svg deleted file mode 100644 index 2040e77f0..000000000 --- a/linux/assets/file-icons/icons/light/files/database.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/deno.svg b/linux/assets/file-icons/icons/light/files/deno.svg deleted file mode 100644 index 301830fce..000000000 --- a/linux/assets/file-icons/icons/light/files/deno.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - D - diff --git a/linux/assets/file-icons/icons/light/files/dependabot.svg b/linux/assets/file-icons/icons/light/files/dependabot.svg deleted file mode 100644 index 38a401d0e..000000000 --- a/linux/assets/file-icons/icons/light/files/dependabot.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - D - diff --git a/linux/assets/file-icons/icons/light/files/devcontainer.svg b/linux/assets/file-icons/icons/light/files/devcontainer.svg deleted file mode 100644 index 9f5dd2277..000000000 --- a/linux/assets/file-icons/icons/light/files/devcontainer.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - DC - diff --git a/linux/assets/file-icons/icons/light/files/diff.svg b/linux/assets/file-icons/icons/light/files/diff.svg deleted file mode 100644 index 6f52d90e4..000000000 --- a/linux/assets/file-icons/icons/light/files/diff.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - +- - diff --git a/linux/assets/file-icons/icons/light/files/django.svg b/linux/assets/file-icons/icons/light/files/django.svg deleted file mode 100644 index 4ec56bcfb..000000000 --- a/linux/assets/file-icons/icons/light/files/django.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/docker-compose.svg b/linux/assets/file-icons/icons/light/files/docker-compose.svg deleted file mode 100644 index 2d4c58925..000000000 --- a/linux/assets/file-icons/icons/light/files/docker-compose.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/docker.svg b/linux/assets/file-icons/icons/light/files/docker.svg deleted file mode 100644 index 867766922..000000000 --- a/linux/assets/file-icons/icons/light/files/docker.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/document.svg b/linux/assets/file-icons/icons/light/files/document.svg deleted file mode 100644 index f5ad58865..000000000 --- a/linux/assets/file-icons/icons/light/files/document.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - DOC - diff --git a/linux/assets/file-icons/icons/light/files/docusaurus.svg b/linux/assets/file-icons/icons/light/files/docusaurus.svg deleted file mode 100644 index ed0fe2214..000000000 --- a/linux/assets/file-icons/icons/light/files/docusaurus.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/drawio.svg b/linux/assets/file-icons/icons/light/files/drawio.svg deleted file mode 100644 index 807b2aa58..000000000 --- a/linux/assets/file-icons/icons/light/files/drawio.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - D - diff --git a/linux/assets/file-icons/icons/light/files/drizzle.svg b/linux/assets/file-icons/icons/light/files/drizzle.svg deleted file mode 100644 index 29a554e29..000000000 --- a/linux/assets/file-icons/icons/light/files/drizzle.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/editorconfig.svg b/linux/assets/file-icons/icons/light/files/editorconfig.svg deleted file mode 100644 index 2532cefd4..000000000 --- a/linux/assets/file-icons/icons/light/files/editorconfig.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/electron.svg b/linux/assets/file-icons/icons/light/files/electron.svg deleted file mode 100644 index 066bddd15..000000000 --- a/linux/assets/file-icons/icons/light/files/electron.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - E - diff --git a/linux/assets/file-icons/icons/light/files/elixir.svg b/linux/assets/file-icons/icons/light/files/elixir.svg deleted file mode 100644 index 70e88edc3..000000000 --- a/linux/assets/file-icons/icons/light/files/elixir.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - EX - diff --git a/linux/assets/file-icons/icons/light/files/env.svg b/linux/assets/file-icons/icons/light/files/env.svg deleted file mode 100644 index 739868f76..000000000 --- a/linux/assets/file-icons/icons/light/files/env.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/erlang.svg b/linux/assets/file-icons/icons/light/files/erlang.svg deleted file mode 100644 index 6dd8f7210..000000000 --- a/linux/assets/file-icons/icons/light/files/erlang.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - ER - diff --git a/linux/assets/file-icons/icons/light/files/error.svg b/linux/assets/file-icons/icons/light/files/error.svg deleted file mode 100644 index 6777185ab..000000000 --- a/linux/assets/file-icons/icons/light/files/error.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/eslint.svg b/linux/assets/file-icons/icons/light/files/eslint.svg deleted file mode 100644 index 31cdc71f1..000000000 --- a/linux/assets/file-icons/icons/light/files/eslint.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - E - diff --git a/linux/assets/file-icons/icons/light/files/excalidraw.svg b/linux/assets/file-icons/icons/light/files/excalidraw.svg deleted file mode 100644 index b3ab80975..000000000 --- a/linux/assets/file-icons/icons/light/files/excalidraw.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/fastapi.svg b/linux/assets/file-icons/icons/light/files/fastapi.svg deleted file mode 100644 index cf4cb4448..000000000 --- a/linux/assets/file-icons/icons/light/files/fastapi.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - FA - diff --git a/linux/assets/file-icons/icons/light/files/figma.svg b/linux/assets/file-icons/icons/light/files/figma.svg deleted file mode 100644 index 9d3f20dfc..000000000 --- a/linux/assets/file-icons/icons/light/files/figma.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - F - diff --git a/linux/assets/file-icons/icons/light/files/file.svg b/linux/assets/file-icons/icons/light/files/file.svg deleted file mode 100644 index ecd9c5967..000000000 --- a/linux/assets/file-icons/icons/light/files/file.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/firebase.svg b/linux/assets/file-icons/icons/light/files/firebase.svg deleted file mode 100644 index 160cd7a47..000000000 --- a/linux/assets/file-icons/icons/light/files/firebase.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/flask.svg b/linux/assets/file-icons/icons/light/files/flask.svg deleted file mode 100644 index f91b6630f..000000000 --- a/linux/assets/file-icons/icons/light/files/flask.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/font.svg b/linux/assets/file-icons/icons/light/files/font.svg deleted file mode 100644 index 232442e9e..000000000 --- a/linux/assets/file-icons/icons/light/files/font.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/gatsby.svg b/linux/assets/file-icons/icons/light/files/gatsby.svg deleted file mode 100644 index f0ccaaf81..000000000 --- a/linux/assets/file-icons/icons/light/files/gatsby.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - G - diff --git a/linux/assets/file-icons/icons/light/files/gemini.svg b/linux/assets/file-icons/icons/light/files/gemini.svg deleted file mode 100644 index aa1b35013..000000000 --- a/linux/assets/file-icons/icons/light/files/gemini.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - G - diff --git a/linux/assets/file-icons/icons/light/files/git.svg b/linux/assets/file-icons/icons/light/files/git.svg deleted file mode 100644 index 60e5c97fe..000000000 --- a/linux/assets/file-icons/icons/light/files/git.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/github-actions.svg b/linux/assets/file-icons/icons/light/files/github-actions.svg deleted file mode 100644 index 1a8595f25..000000000 --- a/linux/assets/file-icons/icons/light/files/github-actions.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - GH - diff --git a/linux/assets/file-icons/icons/light/files/github.svg b/linux/assets/file-icons/icons/light/files/github.svg deleted file mode 100644 index ac94fb622..000000000 --- a/linux/assets/file-icons/icons/light/files/github.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/gitlab.svg b/linux/assets/file-icons/icons/light/files/gitlab.svg deleted file mode 100644 index eb6eb35ed..000000000 --- a/linux/assets/file-icons/icons/light/files/gitlab.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/go.svg b/linux/assets/file-icons/icons/light/files/go.svg deleted file mode 100644 index 73f3b11a9..000000000 --- a/linux/assets/file-icons/icons/light/files/go.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/gradle.svg b/linux/assets/file-icons/icons/light/files/gradle.svg deleted file mode 100644 index 456555037..000000000 --- a/linux/assets/file-icons/icons/light/files/gradle.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - G - diff --git a/linux/assets/file-icons/icons/light/files/graphql-schema.svg b/linux/assets/file-icons/icons/light/files/graphql-schema.svg deleted file mode 100644 index d50cccb17..000000000 --- a/linux/assets/file-icons/icons/light/files/graphql-schema.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - GS - diff --git a/linux/assets/file-icons/icons/light/files/graphql.svg b/linux/assets/file-icons/icons/light/files/graphql.svg deleted file mode 100644 index c63624564..000000000 --- a/linux/assets/file-icons/icons/light/files/graphql.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - G - diff --git a/linux/assets/file-icons/icons/light/files/haskell.svg b/linux/assets/file-icons/icons/light/files/haskell.svg deleted file mode 100644 index 24db0ded5..000000000 --- a/linux/assets/file-icons/icons/light/files/haskell.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - HS - diff --git a/linux/assets/file-icons/icons/light/files/helm.svg b/linux/assets/file-icons/icons/light/files/helm.svg deleted file mode 100644 index f1c3bb82a..000000000 --- a/linux/assets/file-icons/icons/light/files/helm.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - H - diff --git a/linux/assets/file-icons/icons/light/files/html.svg b/linux/assets/file-icons/icons/light/files/html.svg deleted file mode 100644 index d1e8d808b..000000000 --- a/linux/assets/file-icons/icons/light/files/html.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - H - diff --git a/linux/assets/file-icons/icons/light/files/http.svg b/linux/assets/file-icons/icons/light/files/http.svg deleted file mode 100644 index 977a063c4..000000000 --- a/linux/assets/file-icons/icons/light/files/http.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - HT - diff --git a/linux/assets/file-icons/icons/light/files/hurl.svg b/linux/assets/file-icons/icons/light/files/hurl.svg deleted file mode 100644 index 1ce145c73..000000000 --- a/linux/assets/file-icons/icons/light/files/hurl.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - HU - diff --git a/linux/assets/file-icons/icons/light/files/image.svg b/linux/assets/file-icons/icons/light/files/image.svg deleted file mode 100644 index 2bfa5eb89..000000000 --- a/linux/assets/file-icons/icons/light/files/image.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/index-js.svg b/linux/assets/file-icons/icons/light/files/index-js.svg deleted file mode 100644 index e676bf67e..000000000 --- a/linux/assets/file-icons/icons/light/files/index-js.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - IDX - diff --git a/linux/assets/file-icons/icons/light/files/index-ts.svg b/linux/assets/file-icons/icons/light/files/index-ts.svg deleted file mode 100644 index 9c62ff182..000000000 --- a/linux/assets/file-icons/icons/light/files/index-ts.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - IDX - diff --git a/linux/assets/file-icons/icons/light/files/java.svg b/linux/assets/file-icons/icons/light/files/java.svg deleted file mode 100644 index 948e01ddd..000000000 --- a/linux/assets/file-icons/icons/light/files/java.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/javascript.svg b/linux/assets/file-icons/icons/light/files/javascript.svg deleted file mode 100644 index 0e36f8cfe..000000000 --- a/linux/assets/file-icons/icons/light/files/javascript.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - JS - diff --git a/linux/assets/file-icons/icons/light/files/jenkins.svg b/linux/assets/file-icons/icons/light/files/jenkins.svg deleted file mode 100644 index cc549fc11..000000000 --- a/linux/assets/file-icons/icons/light/files/jenkins.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/jest.svg b/linux/assets/file-icons/icons/light/files/jest.svg deleted file mode 100644 index 97aef2580..000000000 --- a/linux/assets/file-icons/icons/light/files/jest.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/jsconfig.svg b/linux/assets/file-icons/icons/light/files/jsconfig.svg deleted file mode 100644 index 2af749c4d..000000000 --- a/linux/assets/file-icons/icons/light/files/jsconfig.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/json.svg b/linux/assets/file-icons/icons/light/files/json.svg deleted file mode 100644 index 9bd3910d1..000000000 --- a/linux/assets/file-icons/icons/light/files/json.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - {} - diff --git a/linux/assets/file-icons/icons/light/files/julia.svg b/linux/assets/file-icons/icons/light/files/julia.svg deleted file mode 100644 index 1400b22df..000000000 --- a/linux/assets/file-icons/icons/light/files/julia.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - JL - diff --git a/linux/assets/file-icons/icons/light/files/key.svg b/linux/assets/file-icons/icons/light/files/key.svg deleted file mode 100644 index 48a792e87..000000000 --- a/linux/assets/file-icons/icons/light/files/key.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/kotlin.svg b/linux/assets/file-icons/icons/light/files/kotlin.svg deleted file mode 100644 index 8d0fd4dd0..000000000 --- a/linux/assets/file-icons/icons/light/files/kotlin.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - K - diff --git a/linux/assets/file-icons/icons/light/files/kubernetes.svg b/linux/assets/file-icons/icons/light/files/kubernetes.svg deleted file mode 100644 index 6837d0a06..000000000 --- a/linux/assets/file-icons/icons/light/files/kubernetes.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - K8 - diff --git a/linux/assets/file-icons/icons/light/files/laravel.svg b/linux/assets/file-icons/icons/light/files/laravel.svg deleted file mode 100644 index 8c2f3393e..000000000 --- a/linux/assets/file-icons/icons/light/files/laravel.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/layout.svg b/linux/assets/file-icons/icons/light/files/layout.svg deleted file mode 100644 index 70c99e58b..000000000 --- a/linux/assets/file-icons/icons/light/files/layout.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - L - diff --git a/linux/assets/file-icons/icons/light/files/license.svg b/linux/assets/file-icons/icons/light/files/license.svg deleted file mode 100644 index 0aa5fbce7..000000000 --- a/linux/assets/file-icons/icons/light/files/license.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - LIC - diff --git a/linux/assets/file-icons/icons/light/files/lintstaged.svg b/linux/assets/file-icons/icons/light/files/lintstaged.svg deleted file mode 100644 index 0caeb0f9f..000000000 --- a/linux/assets/file-icons/icons/light/files/lintstaged.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - LS - diff --git a/linux/assets/file-icons/icons/light/files/linux.svg b/linux/assets/file-icons/icons/light/files/linux.svg deleted file mode 100644 index b43691eb4..000000000 --- a/linux/assets/file-icons/icons/light/files/linux.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/lit.svg b/linux/assets/file-icons/icons/light/files/lit.svg deleted file mode 100644 index 76e58c410..000000000 --- a/linux/assets/file-icons/icons/light/files/lit.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/lithe.svg b/linux/assets/file-icons/icons/light/files/lithe.svg deleted file mode 100644 index dc5523ab4..000000000 --- a/linux/assets/file-icons/icons/light/files/lithe.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - L - diff --git a/linux/assets/file-icons/icons/light/files/loading.svg b/linux/assets/file-icons/icons/light/files/loading.svg deleted file mode 100644 index cb632d555..000000000 --- a/linux/assets/file-icons/icons/light/files/loading.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - ... - diff --git a/linux/assets/file-icons/icons/light/files/lock.svg b/linux/assets/file-icons/icons/light/files/lock.svg deleted file mode 100644 index 8abab2a1d..000000000 --- a/linux/assets/file-icons/icons/light/files/lock.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/log.svg b/linux/assets/file-icons/icons/light/files/log.svg deleted file mode 100644 index f06880824..000000000 --- a/linux/assets/file-icons/icons/light/files/log.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - LOG - diff --git a/linux/assets/file-icons/icons/light/files/lua.svg b/linux/assets/file-icons/icons/light/files/lua.svg deleted file mode 100644 index 3fc5f6d9b..000000000 --- a/linux/assets/file-icons/icons/light/files/lua.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - L - diff --git a/linux/assets/file-icons/icons/light/files/makefile.svg b/linux/assets/file-icons/icons/light/files/makefile.svg deleted file mode 100644 index e6ca2fb8b..000000000 --- a/linux/assets/file-icons/icons/light/files/makefile.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/markdown.svg b/linux/assets/file-icons/icons/light/files/markdown.svg deleted file mode 100644 index 0a57e1538..000000000 --- a/linux/assets/file-icons/icons/light/files/markdown.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/markdownlint.svg b/linux/assets/file-icons/icons/light/files/markdownlint.svg deleted file mode 100644 index 3aabe5da5..000000000 --- a/linux/assets/file-icons/icons/light/files/markdownlint.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/maven.svg b/linux/assets/file-icons/icons/light/files/maven.svg deleted file mode 100644 index b5d240b51..000000000 --- a/linux/assets/file-icons/icons/light/files/maven.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - M - diff --git a/linux/assets/file-icons/icons/light/files/mcp.svg b/linux/assets/file-icons/icons/light/files/mcp.svg deleted file mode 100644 index 9480e0499..000000000 --- a/linux/assets/file-icons/icons/light/files/mcp.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - M - diff --git a/linux/assets/file-icons/icons/light/files/mermaid.svg b/linux/assets/file-icons/icons/light/files/mermaid.svg deleted file mode 100644 index 4d45d6f3c..000000000 --- a/linux/assets/file-icons/icons/light/files/mermaid.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - MM - diff --git a/linux/assets/file-icons/icons/light/files/mongo.svg b/linux/assets/file-icons/icons/light/files/mongo.svg deleted file mode 100644 index 18986c84c..000000000 --- a/linux/assets/file-icons/icons/light/files/mongo.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/netlify.svg b/linux/assets/file-icons/icons/light/files/netlify.svg deleted file mode 100644 index 941d09eb5..000000000 --- a/linux/assets/file-icons/icons/light/files/netlify.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - N - diff --git a/linux/assets/file-icons/icons/light/files/next.svg b/linux/assets/file-icons/icons/light/files/next.svg deleted file mode 100644 index 6b62626f4..000000000 --- a/linux/assets/file-icons/icons/light/files/next.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - N - diff --git a/linux/assets/file-icons/icons/light/files/nginx.svg b/linux/assets/file-icons/icons/light/files/nginx.svg deleted file mode 100644 index e56efaeea..000000000 --- a/linux/assets/file-icons/icons/light/files/nginx.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - N - diff --git a/linux/assets/file-icons/icons/light/files/nim.svg b/linux/assets/file-icons/icons/light/files/nim.svg deleted file mode 100644 index 8fb2d6ee4..000000000 --- a/linux/assets/file-icons/icons/light/files/nim.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - N - diff --git a/linux/assets/file-icons/icons/light/files/nix.svg b/linux/assets/file-icons/icons/light/files/nix.svg deleted file mode 100644 index 5e4119b95..000000000 --- a/linux/assets/file-icons/icons/light/files/nix.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - N - diff --git a/linux/assets/file-icons/icons/light/files/node.svg b/linux/assets/file-icons/icons/light/files/node.svg deleted file mode 100644 index 6bc1f64b2..000000000 --- a/linux/assets/file-icons/icons/light/files/node.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - N - diff --git a/linux/assets/file-icons/icons/light/files/not-found.svg b/linux/assets/file-icons/icons/light/files/not-found.svg deleted file mode 100644 index 0062bda71..000000000 --- a/linux/assets/file-icons/icons/light/files/not-found.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/notebook.svg b/linux/assets/file-icons/icons/light/files/notebook.svg deleted file mode 100644 index a9c69987e..000000000 --- a/linux/assets/file-icons/icons/light/files/notebook.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/npm.svg b/linux/assets/file-icons/icons/light/files/npm.svg deleted file mode 100644 index bfb1828ca..000000000 --- a/linux/assets/file-icons/icons/light/files/npm.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - N - diff --git a/linux/assets/file-icons/icons/light/files/nuxt.svg b/linux/assets/file-icons/icons/light/files/nuxt.svg deleted file mode 100644 index d169e54d3..000000000 --- a/linux/assets/file-icons/icons/light/files/nuxt.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - N - diff --git a/linux/assets/file-icons/icons/light/files/nx.svg b/linux/assets/file-icons/icons/light/files/nx.svg deleted file mode 100644 index 69e48c5c8..000000000 --- a/linux/assets/file-icons/icons/light/files/nx.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - NX - diff --git a/linux/assets/file-icons/icons/light/files/ocaml.svg b/linux/assets/file-icons/icons/light/files/ocaml.svg deleted file mode 100644 index 57d304597..000000000 --- a/linux/assets/file-icons/icons/light/files/ocaml.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - ML - diff --git a/linux/assets/file-icons/icons/light/files/package.svg b/linux/assets/file-icons/icons/light/files/package.svg deleted file mode 100644 index 21909634f..000000000 --- a/linux/assets/file-icons/icons/light/files/package.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/page.svg b/linux/assets/file-icons/icons/light/files/page.svg deleted file mode 100644 index 6832c7701..000000000 --- a/linux/assets/file-icons/icons/light/files/page.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - P - diff --git a/linux/assets/file-icons/icons/light/files/patch.svg b/linux/assets/file-icons/icons/light/files/patch.svg deleted file mode 100644 index 5c1c448a2..000000000 --- a/linux/assets/file-icons/icons/light/files/patch.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - +- - diff --git a/linux/assets/file-icons/icons/light/files/pdf.svg b/linux/assets/file-icons/icons/light/files/pdf.svg deleted file mode 100644 index e79c22e9b..000000000 --- a/linux/assets/file-icons/icons/light/files/pdf.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - PDF - diff --git a/linux/assets/file-icons/icons/light/files/perl.svg b/linux/assets/file-icons/icons/light/files/perl.svg deleted file mode 100644 index a41c0cacd..000000000 --- a/linux/assets/file-icons/icons/light/files/perl.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - PL - diff --git a/linux/assets/file-icons/icons/light/files/php.svg b/linux/assets/file-icons/icons/light/files/php.svg deleted file mode 100644 index d4c2edfe9..000000000 --- a/linux/assets/file-icons/icons/light/files/php.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - P - diff --git a/linux/assets/file-icons/icons/light/files/playwright.svg b/linux/assets/file-icons/icons/light/files/playwright.svg deleted file mode 100644 index 0317c2886..000000000 --- a/linux/assets/file-icons/icons/light/files/playwright.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/pnpm.svg b/linux/assets/file-icons/icons/light/files/pnpm.svg deleted file mode 100644 index 03d12c964..000000000 --- a/linux/assets/file-icons/icons/light/files/pnpm.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - P - diff --git a/linux/assets/file-icons/icons/light/files/postgres.svg b/linux/assets/file-icons/icons/light/files/postgres.svg deleted file mode 100644 index 368daa77f..000000000 --- a/linux/assets/file-icons/icons/light/files/postgres.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/powerpoint.svg b/linux/assets/file-icons/icons/light/files/powerpoint.svg deleted file mode 100644 index 2e8a55782..000000000 --- a/linux/assets/file-icons/icons/light/files/powerpoint.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - PPT - diff --git a/linux/assets/file-icons/icons/light/files/prettier.svg b/linux/assets/file-icons/icons/light/files/prettier.svg deleted file mode 100644 index 8f24fa2de..000000000 --- a/linux/assets/file-icons/icons/light/files/prettier.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/prisma.svg b/linux/assets/file-icons/icons/light/files/prisma.svg deleted file mode 100644 index 3a575c6c6..000000000 --- a/linux/assets/file-icons/icons/light/files/prisma.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/proto.svg b/linux/assets/file-icons/icons/light/files/proto.svg deleted file mode 100644 index 6551ba995..000000000 --- a/linux/assets/file-icons/icons/light/files/proto.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - P - diff --git a/linux/assets/file-icons/icons/light/files/python.svg b/linux/assets/file-icons/icons/light/files/python.svg deleted file mode 100644 index 72f09abe9..000000000 --- a/linux/assets/file-icons/icons/light/files/python.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/qwik.svg b/linux/assets/file-icons/icons/light/files/qwik.svg deleted file mode 100644 index 5e09fe0eb..000000000 --- a/linux/assets/file-icons/icons/light/files/qwik.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - Q - diff --git a/linux/assets/file-icons/icons/light/files/r.svg b/linux/assets/file-icons/icons/light/files/r.svg deleted file mode 100644 index b496fd493..000000000 --- a/linux/assets/file-icons/icons/light/files/r.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - R - diff --git a/linux/assets/file-icons/icons/light/files/react.svg b/linux/assets/file-icons/icons/light/files/react.svg deleted file mode 100644 index aac59a6c4..000000000 --- a/linux/assets/file-icons/icons/light/files/react.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/redis.svg b/linux/assets/file-icons/icons/light/files/redis.svg deleted file mode 100644 index 7cd4c2f24..000000000 --- a/linux/assets/file-icons/icons/light/files/redis.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/remix.svg b/linux/assets/file-icons/icons/light/files/remix.svg deleted file mode 100644 index 975fda0a2..000000000 --- a/linux/assets/file-icons/icons/light/files/remix.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - R - diff --git a/linux/assets/file-icons/icons/light/files/renovate.svg b/linux/assets/file-icons/icons/light/files/renovate.svg deleted file mode 100644 index a95016811..000000000 --- a/linux/assets/file-icons/icons/light/files/renovate.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/rescript.svg b/linux/assets/file-icons/icons/light/files/rescript.svg deleted file mode 100644 index eb898c1d1..000000000 --- a/linux/assets/file-icons/icons/light/files/rescript.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - RE - diff --git a/linux/assets/file-icons/icons/light/files/rollup.svg b/linux/assets/file-icons/icons/light/files/rollup.svg deleted file mode 100644 index 8c846b294..000000000 --- a/linux/assets/file-icons/icons/light/files/rollup.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - R - diff --git a/linux/assets/file-icons/icons/light/files/route.svg b/linux/assets/file-icons/icons/light/files/route.svg deleted file mode 100644 index a1db3d411..000000000 --- a/linux/assets/file-icons/icons/light/files/route.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - RT - diff --git a/linux/assets/file-icons/icons/light/files/rspack.svg b/linux/assets/file-icons/icons/light/files/rspack.svg deleted file mode 100644 index a33654646..000000000 --- a/linux/assets/file-icons/icons/light/files/rspack.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - R - diff --git a/linux/assets/file-icons/icons/light/files/ruby.svg b/linux/assets/file-icons/icons/light/files/ruby.svg deleted file mode 100644 index d245a2255..000000000 --- a/linux/assets/file-icons/icons/light/files/ruby.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - RB - diff --git a/linux/assets/file-icons/icons/light/files/rust.svg b/linux/assets/file-icons/icons/light/files/rust.svg deleted file mode 100644 index 519da0c0d..000000000 --- a/linux/assets/file-icons/icons/light/files/rust.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/sass.svg b/linux/assets/file-icons/icons/light/files/sass.svg deleted file mode 100644 index 753e00605..000000000 --- a/linux/assets/file-icons/icons/light/files/sass.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - S - diff --git a/linux/assets/file-icons/icons/light/files/scala.svg b/linux/assets/file-icons/icons/light/files/scala.svg deleted file mode 100644 index f90003510..000000000 --- a/linux/assets/file-icons/icons/light/files/scala.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - S - diff --git a/linux/assets/file-icons/icons/light/files/security.svg b/linux/assets/file-icons/icons/light/files/security.svg deleted file mode 100644 index 349ea6213..000000000 --- a/linux/assets/file-icons/icons/light/files/security.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - SEC - diff --git a/linux/assets/file-icons/icons/light/files/shell.svg b/linux/assets/file-icons/icons/light/files/shell.svg deleted file mode 100644 index 90d4ad418..000000000 --- a/linux/assets/file-icons/icons/light/files/shell.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/sketch.svg b/linux/assets/file-icons/icons/light/files/sketch.svg deleted file mode 100644 index 740150901..000000000 --- a/linux/assets/file-icons/icons/light/files/sketch.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - S - diff --git a/linux/assets/file-icons/icons/light/files/solid.svg b/linux/assets/file-icons/icons/light/files/solid.svg deleted file mode 100644 index e0d41430a..000000000 --- a/linux/assets/file-icons/icons/light/files/solid.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - S - diff --git a/linux/assets/file-icons/icons/light/files/solidity.svg b/linux/assets/file-icons/icons/light/files/solidity.svg deleted file mode 100644 index 646c19201..000000000 --- a/linux/assets/file-icons/icons/light/files/solidity.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - S - diff --git a/linux/assets/file-icons/icons/light/files/spreadsheet.svg b/linux/assets/file-icons/icons/light/files/spreadsheet.svg deleted file mode 100644 index 72a4c1e5e..000000000 --- a/linux/assets/file-icons/icons/light/files/spreadsheet.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - XLS - diff --git a/linux/assets/file-icons/icons/light/files/sql.svg b/linux/assets/file-icons/icons/light/files/sql.svg deleted file mode 100644 index dbaad7bf8..000000000 --- a/linux/assets/file-icons/icons/light/files/sql.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/storybook.svg b/linux/assets/file-icons/icons/light/files/storybook.svg deleted file mode 100644 index 23f0d98b4..000000000 --- a/linux/assets/file-icons/icons/light/files/storybook.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/stylelint.svg b/linux/assets/file-icons/icons/light/files/stylelint.svg deleted file mode 100644 index bfca7276e..000000000 --- a/linux/assets/file-icons/icons/light/files/stylelint.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - SL - diff --git a/linux/assets/file-icons/icons/light/files/supabase.svg b/linux/assets/file-icons/icons/light/files/supabase.svg deleted file mode 100644 index 4d37adde0..000000000 --- a/linux/assets/file-icons/icons/light/files/supabase.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/svelte.svg b/linux/assets/file-icons/icons/light/files/svelte.svg deleted file mode 100644 index 2ca6817c6..000000000 --- a/linux/assets/file-icons/icons/light/files/svelte.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - S - diff --git a/linux/assets/file-icons/icons/light/files/svg.svg b/linux/assets/file-icons/icons/light/files/svg.svg deleted file mode 100644 index 360f68910..000000000 --- a/linux/assets/file-icons/icons/light/files/svg.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - S - diff --git a/linux/assets/file-icons/icons/light/files/swc.svg b/linux/assets/file-icons/icons/light/files/swc.svg deleted file mode 100644 index 79eb8ecfb..000000000 --- a/linux/assets/file-icons/icons/light/files/swc.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - S - diff --git a/linux/assets/file-icons/icons/light/files/swift.svg b/linux/assets/file-icons/icons/light/files/swift.svg deleted file mode 100644 index 80d17c02c..000000000 --- a/linux/assets/file-icons/icons/light/files/swift.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/tailwind.svg b/linux/assets/file-icons/icons/light/files/tailwind.svg deleted file mode 100644 index c6b21b1d7..000000000 --- a/linux/assets/file-icons/icons/light/files/tailwind.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - T - diff --git a/linux/assets/file-icons/icons/light/files/tauri.svg b/linux/assets/file-icons/icons/light/files/tauri.svg deleted file mode 100644 index 9a7394cb7..000000000 --- a/linux/assets/file-icons/icons/light/files/tauri.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/terraform.svg b/linux/assets/file-icons/icons/light/files/terraform.svg deleted file mode 100644 index 6318401f8..000000000 --- a/linux/assets/file-icons/icons/light/files/terraform.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - TF - diff --git a/linux/assets/file-icons/icons/light/files/test.svg b/linux/assets/file-icons/icons/light/files/test.svg deleted file mode 100644 index fb610d1ea..000000000 --- a/linux/assets/file-icons/icons/light/files/test.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/text.svg b/linux/assets/file-icons/icons/light/files/text.svg deleted file mode 100644 index 7755a7710..000000000 --- a/linux/assets/file-icons/icons/light/files/text.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - TXT - diff --git a/linux/assets/file-icons/icons/light/files/toml.svg b/linux/assets/file-icons/icons/light/files/toml.svg deleted file mode 100644 index 96fb34215..000000000 --- a/linux/assets/file-icons/icons/light/files/toml.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/turborepo.svg b/linux/assets/file-icons/icons/light/files/turborepo.svg deleted file mode 100644 index 6ef2337d7..000000000 --- a/linux/assets/file-icons/icons/light/files/turborepo.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - T - diff --git a/linux/assets/file-icons/icons/light/files/typescript.svg b/linux/assets/file-icons/icons/light/files/typescript.svg deleted file mode 100644 index d52a028e3..000000000 --- a/linux/assets/file-icons/icons/light/files/typescript.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - TS - diff --git a/linux/assets/file-icons/icons/light/files/vercel-config.svg b/linux/assets/file-icons/icons/light/files/vercel-config.svg deleted file mode 100644 index 6c5eb2b1f..000000000 --- a/linux/assets/file-icons/icons/light/files/vercel-config.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - VC - diff --git a/linux/assets/file-icons/icons/light/files/vercel.svg b/linux/assets/file-icons/icons/light/files/vercel.svg deleted file mode 100644 index 64752bab9..000000000 --- a/linux/assets/file-icons/icons/light/files/vercel.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - V - diff --git a/linux/assets/file-icons/icons/light/files/video.svg b/linux/assets/file-icons/icons/light/files/video.svg deleted file mode 100644 index 86cd60bb3..000000000 --- a/linux/assets/file-icons/icons/light/files/video.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/vite.svg b/linux/assets/file-icons/icons/light/files/vite.svg deleted file mode 100644 index 3a4e803c6..000000000 --- a/linux/assets/file-icons/icons/light/files/vite.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - V - diff --git a/linux/assets/file-icons/icons/light/files/vitest.svg b/linux/assets/file-icons/icons/light/files/vitest.svg deleted file mode 100644 index cc4872157..000000000 --- a/linux/assets/file-icons/icons/light/files/vitest.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/vue.svg b/linux/assets/file-icons/icons/light/files/vue.svg deleted file mode 100644 index 644da25b9..000000000 --- a/linux/assets/file-icons/icons/light/files/vue.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - V - diff --git a/linux/assets/file-icons/icons/light/files/warning.svg b/linux/assets/file-icons/icons/light/files/warning.svg deleted file mode 100644 index 9e9cb6330..000000000 --- a/linux/assets/file-icons/icons/light/files/warning.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/wasm.svg b/linux/assets/file-icons/icons/light/files/wasm.svg deleted file mode 100644 index 3a63d47a9..000000000 --- a/linux/assets/file-icons/icons/light/files/wasm.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - W - diff --git a/linux/assets/file-icons/icons/light/files/webpack.svg b/linux/assets/file-icons/icons/light/files/webpack.svg deleted file mode 100644 index 14cb684b2..000000000 --- a/linux/assets/file-icons/icons/light/files/webpack.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - W - diff --git a/linux/assets/file-icons/icons/light/files/windows.svg b/linux/assets/file-icons/icons/light/files/windows.svg deleted file mode 100644 index 92afb257d..000000000 --- a/linux/assets/file-icons/icons/light/files/windows.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - W - diff --git a/linux/assets/file-icons/icons/light/files/word.svg b/linux/assets/file-icons/icons/light/files/word.svg deleted file mode 100644 index 7f54bb1fc..000000000 --- a/linux/assets/file-icons/icons/light/files/word.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - DOC - diff --git a/linux/assets/file-icons/icons/light/files/xcode.svg b/linux/assets/file-icons/icons/light/files/xcode.svg deleted file mode 100644 index 51a37e666..000000000 --- a/linux/assets/file-icons/icons/light/files/xcode.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/files/xml.svg b/linux/assets/file-icons/icons/light/files/xml.svg deleted file mode 100644 index f1c38169f..000000000 --- a/linux/assets/file-icons/icons/light/files/xml.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - <> - diff --git a/linux/assets/file-icons/icons/light/files/yaml.svg b/linux/assets/file-icons/icons/light/files/yaml.svg deleted file mode 100644 index 8465e7e70..000000000 --- a/linux/assets/file-icons/icons/light/files/yaml.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - Y - diff --git a/linux/assets/file-icons/icons/light/files/yarn.svg b/linux/assets/file-icons/icons/light/files/yarn.svg deleted file mode 100644 index 05219d53a..000000000 --- a/linux/assets/file-icons/icons/light/files/yarn.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - Y - diff --git a/linux/assets/file-icons/icons/light/files/zig.svg b/linux/assets/file-icons/icons/light/files/zig.svg deleted file mode 100644 index c529bd03a..000000000 --- a/linux/assets/file-icons/icons/light/files/zig.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-ai-open.svg b/linux/assets/file-icons/icons/light/folders/folder-ai-open.svg deleted file mode 100644 index 5e951884f..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-ai-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-ai.svg b/linux/assets/file-icons/icons/light/folders/folder-ai.svg deleted file mode 100644 index 44f8c72aa..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-ai.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-assets-open.svg b/linux/assets/file-icons/icons/light/folders/folder-assets-open.svg deleted file mode 100644 index 88538645e..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-assets-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-assets.svg b/linux/assets/file-icons/icons/light/folders/folder-assets.svg deleted file mode 100644 index 94d68d0b7..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-assets.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-build-open.svg b/linux/assets/file-icons/icons/light/folders/folder-build-open.svg deleted file mode 100644 index 690053176..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-build-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-build.svg b/linux/assets/file-icons/icons/light/folders/folder-build.svg deleted file mode 100644 index 8e17e72f2..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-build.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-cloud-open.svg b/linux/assets/file-icons/icons/light/folders/folder-cloud-open.svg deleted file mode 100644 index 52f4a926a..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-cloud-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-cloud.svg b/linux/assets/file-icons/icons/light/folders/folder-cloud.svg deleted file mode 100644 index c0bce4b87..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-cloud.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-components-open.svg b/linux/assets/file-icons/icons/light/folders/folder-components-open.svg deleted file mode 100644 index 7ddb8b0d3..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-components-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-components.svg b/linux/assets/file-icons/icons/light/folders/folder-components.svg deleted file mode 100644 index 95b2529da..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-components.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-config-open.svg b/linux/assets/file-icons/icons/light/folders/folder-config-open.svg deleted file mode 100644 index b2bd27620..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-config-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-config.svg b/linux/assets/file-icons/icons/light/folders/folder-config.svg deleted file mode 100644 index 2a9817835..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-config.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-database-open.svg b/linux/assets/file-icons/icons/light/folders/folder-database-open.svg deleted file mode 100644 index 6755b5694..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-database-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-database.svg b/linux/assets/file-icons/icons/light/folders/folder-database.svg deleted file mode 100644 index 8ac9462ab..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-database.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-docs-open.svg b/linux/assets/file-icons/icons/light/folders/folder-docs-open.svg deleted file mode 100644 index f244dc895..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-docs-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-docs.svg b/linux/assets/file-icons/icons/light/folders/folder-docs.svg deleted file mode 100644 index 88f62bd59..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-docs.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-extensions-open.svg b/linux/assets/file-icons/icons/light/folders/folder-extensions-open.svg deleted file mode 100644 index 3119706f1..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-extensions-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-extensions.svg b/linux/assets/file-icons/icons/light/folders/folder-extensions.svg deleted file mode 100644 index 2e24257d9..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-extensions.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-git-open.svg b/linux/assets/file-icons/icons/light/folders/folder-git-open.svg deleted file mode 100644 index 946e9cdeb..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-git-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-git.svg b/linux/assets/file-icons/icons/light/folders/folder-git.svg deleted file mode 100644 index f0b06065a..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-git.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-locales-open.svg b/linux/assets/file-icons/icons/light/folders/folder-locales-open.svg deleted file mode 100644 index 73c3d1386..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-locales-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-locales.svg b/linux/assets/file-icons/icons/light/folders/folder-locales.svg deleted file mode 100644 index 6fc668015..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-locales.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-mobile-open.svg b/linux/assets/file-icons/icons/light/folders/folder-mobile-open.svg deleted file mode 100644 index 243086c22..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-mobile-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-mobile.svg b/linux/assets/file-icons/icons/light/folders/folder-mobile.svg deleted file mode 100644 index 7fa4504b5..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-mobile.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-open.svg b/linux/assets/file-icons/icons/light/folders/folder-open.svg deleted file mode 100644 index 2dc12ef87..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-open.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-packages-open.svg b/linux/assets/file-icons/icons/light/folders/folder-packages-open.svg deleted file mode 100644 index 1c7e10af4..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-packages-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-packages.svg b/linux/assets/file-icons/icons/light/folders/folder-packages.svg deleted file mode 100644 index 0663dd214..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-packages.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-routes-open.svg b/linux/assets/file-icons/icons/light/folders/folder-routes-open.svg deleted file mode 100644 index 2e6036a73..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-routes-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-routes.svg b/linux/assets/file-icons/icons/light/folders/folder-routes.svg deleted file mode 100644 index c88e28f75..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-routes.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-rust-open.svg b/linux/assets/file-icons/icons/light/folders/folder-rust-open.svg deleted file mode 100644 index 297e2377c..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-rust-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-rust.svg b/linux/assets/file-icons/icons/light/folders/folder-rust.svg deleted file mode 100644 index 35e5e2327..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-rust.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-scripts-open.svg b/linux/assets/file-icons/icons/light/folders/folder-scripts-open.svg deleted file mode 100644 index 519b6138c..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-scripts-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-scripts.svg b/linux/assets/file-icons/icons/light/folders/folder-scripts.svg deleted file mode 100644 index 78fa6406f..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-scripts.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-security-open.svg b/linux/assets/file-icons/icons/light/folders/folder-security-open.svg deleted file mode 100644 index 4888dcd72..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-security-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-security.svg b/linux/assets/file-icons/icons/light/folders/folder-security.svg deleted file mode 100644 index 1aa7f1769..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-security.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-source-open.svg b/linux/assets/file-icons/icons/light/folders/folder-source-open.svg deleted file mode 100644 index 1da3d2d4b..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-source-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-source.svg b/linux/assets/file-icons/icons/light/folders/folder-source.svg deleted file mode 100644 index dd71d5eb7..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-source.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-styles-open.svg b/linux/assets/file-icons/icons/light/folders/folder-styles-open.svg deleted file mode 100644 index b3bf8b742..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-styles-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-styles.svg b/linux/assets/file-icons/icons/light/folders/folder-styles.svg deleted file mode 100644 index 060ce9d00..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-styles.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-test-open.svg b/linux/assets/file-icons/icons/light/folders/folder-test-open.svg deleted file mode 100644 index 7adcf0d59..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-test-open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder-test.svg b/linux/assets/file-icons/icons/light/folders/folder-test.svg deleted file mode 100644 index 2fd97785a..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder-test.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/linux/assets/file-icons/icons/light/folders/folder.svg b/linux/assets/file-icons/icons/light/folders/folder.svg deleted file mode 100644 index a127080bf..000000000 --- a/linux/assets/file-icons/icons/light/folders/folder.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/linux/assets/file-icons/map.json b/linux/assets/file-icons/map.json index 53f5e9925..53ca02dc3 100644 --- a/linux/assets/file-icons/map.json +++ b/linux/assets/file-icons/map.json @@ -1 +1 @@ -{"defaultFile": "file", "defaultFolder": "folder", "defaultFolderOpen": "folder-open", "iconDefinitions": {"file": "./icons/files/file.svg", "text": "./icons/files/text.svg", "document": "./icons/files/document.svg", "markdown": "./icons/files/markdown.svg", "html": "./icons/files/html.svg", "css": "./icons/files/css.svg", "sass": "./icons/files/sass.svg", "javascript": "./icons/files/javascript.svg", "typescript": "./icons/files/typescript.svg", "react": "./icons/files/react.svg", "vue": "./icons/files/vue.svg", "svelte": "./icons/files/svelte.svg", "astro": "./icons/files/astro.svg", "json": "./icons/files/json.svg", "yaml": "./icons/files/yaml.svg", "toml": "./icons/files/toml.svg", "xml": "./icons/files/xml.svg", "rust": "./icons/files/rust.svg", "python": "./icons/files/python.svg", "go": "./icons/files/go.svg", "java": "./icons/files/java.svg", "c": "./icons/files/c.svg", "cpp": "./icons/files/cpp.svg", "csharp": "./icons/files/csharp.svg", "swift": "./icons/files/swift.svg", "zig": "./icons/files/zig.svg", "ruby": "./icons/files/ruby.svg", "php": "./icons/files/php.svg", "shell": "./icons/files/shell.svg", "sql": "./icons/files/sql.svg", "database": "./icons/files/database.svg", "prisma": "./icons/files/prisma.svg", "graphql": "./icons/files/graphql.svg", "docker": "./icons/files/docker.svg", "git": "./icons/files/git.svg", "github": "./icons/files/github.svg", "package": "./icons/files/package.svg", "node": "./icons/files/node.svg", "bun": "./icons/files/bun.svg", "deno": "./icons/files/deno.svg", "lock": "./icons/files/lock.svg", "config": "./icons/files/config.svg", "env": "./icons/files/env.svg", "test": "./icons/files/test.svg", "vite": "./icons/files/vite.svg", "tailwind": "./icons/files/tailwind.svg", "image": "./icons/files/image.svg", "svg": "./icons/files/svg.svg", "audio": "./icons/files/audio.svg", "video": "./icons/files/video.svg", "font": "./icons/files/font.svg", "pdf": "./icons/files/pdf.svg", "notebook": "./icons/files/notebook.svg", "next": "./icons/files/next.svg", "nuxt": "./icons/files/nuxt.svg", "angular": "./icons/files/angular.svg", "solid": "./icons/files/solid.svg", "remix": "./icons/files/remix.svg", "qwik": "./icons/files/qwik.svg", "lit": "./icons/files/lit.svg", "storybook": "./icons/files/storybook.svg", "jest": "./icons/files/jest.svg", "vitest": "./icons/files/vitest.svg", "playwright": "./icons/files/playwright.svg", "cypress": "./icons/files/cypress.svg", "eslint": "./icons/files/eslint.svg", "prettier": "./icons/files/prettier.svg", "biome": "./icons/files/biome.svg", "babel": "./icons/files/babel.svg", "swc": "./icons/files/swc.svg", "webpack": "./icons/files/webpack.svg", "rollup": "./icons/files/rollup.svg", "rspack": "./icons/files/rspack.svg", "turborepo": "./icons/files/turborepo.svg", "nx": "./icons/files/nx.svg", "npm": "./icons/files/npm.svg", "pnpm": "./icons/files/pnpm.svg", "yarn": "./icons/files/yarn.svg", "maven": "./icons/files/maven.svg", "gradle": "./icons/files/gradle.svg", "kotlin": "./icons/files/kotlin.svg", "dart": "./icons/files/dart.svg", "lua": "./icons/files/lua.svg", "elixir": "./icons/files/elixir.svg", "erlang": "./icons/files/erlang.svg", "haskell": "./icons/files/haskell.svg", "scala": "./icons/files/scala.svg", "clojure": "./icons/files/clojure.svg", "nim": "./icons/files/nim.svg", "nix": "./icons/files/nix.svg", "terraform": "./icons/files/terraform.svg", "kubernetes": "./icons/files/kubernetes.svg", "helm": "./icons/files/helm.svg", "ansible": "./icons/files/ansible.svg", "cloudflare": "./icons/files/cloudflare.svg", "netlify": "./icons/files/netlify.svg", "vercel": "./icons/files/vercel.svg", "firebase": "./icons/files/firebase.svg", "supabase": "./icons/files/supabase.svg", "mongo": "./icons/files/mongo.svg", "redis": "./icons/files/redis.svg", "postgres": "./icons/files/postgres.svg", "drizzle": "./icons/files/drizzle.svg", "figma": "./icons/files/figma.svg", "sketch": "./icons/files/sketch.svg", "adobe": "./icons/files/adobe.svg", "csv": "./icons/files/csv.svg", "spreadsheet": "./icons/files/spreadsheet.svg", "word": "./icons/files/word.svg", "powerpoint": "./icons/files/powerpoint.svg", "archive": "./icons/files/archive.svg", "certificate": "./icons/files/certificate.svg", "key": "./icons/files/key.svg", "log": "./icons/files/log.svg", "diff": "./icons/files/diff.svg", "patch": "./icons/files/patch.svg", "license": "./icons/files/license.svg", "makefile": "./icons/files/makefile.svg", "cmake": "./icons/files/cmake.svg", "proto": "./icons/files/proto.svg", "wasm": "./icons/files/wasm.svg", "rescript": "./icons/files/rescript.svg", "ocaml": "./icons/files/ocaml.svg", "solidity": "./icons/files/solidity.svg", "r": "./icons/files/r.svg", "julia": "./icons/files/julia.svg", "perl": "./icons/files/perl.svg", "lithe": "./icons/files/lithe.svg", "codex": "./icons/files/codex.svg", "claude": "./icons/files/claude.svg", "cursor": "./icons/files/cursor.svg", "tauri": "./icons/files/tauri.svg", "electron": "./icons/files/electron.svg", "xcode": "./icons/files/xcode.svg", "android": "./icons/files/android.svg", "apple": "./icons/files/apple.svg", "windows": "./icons/files/windows.svg", "linux": "./icons/files/linux.svg", "changelog": "./icons/files/changelog.svg", "authors": "./icons/files/authors.svg", "security": "./icons/files/security.svg", "warning": "./icons/files/warning.svg", "agents": "./icons/files/agents.svg", "copilot": "./icons/files/copilot.svg", "gemini": "./icons/files/gemini.svg", "cline": "./icons/files/cline.svg", "mcp": "./icons/files/mcp.svg", "editorconfig": "./icons/files/editorconfig.svg", "stylelint": "./icons/files/stylelint.svg", "markdownlint": "./icons/files/markdownlint.svg", "cspell": "./icons/files/cspell.svg", "commitlint": "./icons/files/commitlint.svg", "lintstaged": "./icons/files/lintstaged.svg", "renovate": "./icons/files/renovate.svg", "dependabot": "./icons/files/dependabot.svg", "docker-compose": "./icons/files/docker-compose.svg", "devcontainer": "./icons/files/devcontainer.svg", "github-actions": "./icons/files/github-actions.svg", "gitlab": "./icons/files/gitlab.svg", "bitbucket": "./icons/files/bitbucket.svg", "jenkins": "./icons/files/jenkins.svg", "vercel-config": "./icons/files/vercel-config.svg", "nginx": "./icons/files/nginx.svg", "http": "./icons/files/http.svg", "hurl": "./icons/files/hurl.svg", "graphql-schema": "./icons/files/graphql-schema.svg", "jsconfig": "./icons/files/jsconfig.svg", "index-js": "./icons/files/index-js.svg", "index-ts": "./icons/files/index-ts.svg", "layout": "./icons/files/layout.svg", "page": "./icons/files/page.svg", "route": "./icons/files/route.svg", "loading": "./icons/files/loading.svg", "not-found": "./icons/files/not-found.svg", "error": "./icons/files/error.svg", "docusaurus": "./icons/files/docusaurus.svg", "gatsby": "./icons/files/gatsby.svg", "laravel": "./icons/files/laravel.svg", "django": "./icons/files/django.svg", "flask": "./icons/files/flask.svg", "fastapi": "./icons/files/fastapi.svg", "arduino": "./icons/files/arduino.svg", "blender": "./icons/files/blender.svg", "drawio": "./icons/files/drawio.svg", "excalidraw": "./icons/files/excalidraw.svg", "mermaid": "./icons/files/mermaid.svg", "folder": "./icons/folders/folder.svg", "folder-open": "./icons/folders/folder-open.svg", "folder-source": "./icons/folders/folder-source.svg", "folder-source-open": "./icons/folders/folder-source-open.svg", "folder-components": "./icons/folders/folder-components.svg", "folder-components-open": "./icons/folders/folder-components-open.svg", "folder-test": "./icons/folders/folder-test.svg", "folder-test-open": "./icons/folders/folder-test-open.svg", "folder-config": "./icons/folders/folder-config.svg", "folder-config-open": "./icons/folders/folder-config-open.svg", "folder-assets": "./icons/folders/folder-assets.svg", "folder-assets-open": "./icons/folders/folder-assets-open.svg", "folder-docs": "./icons/folders/folder-docs.svg", "folder-docs-open": "./icons/folders/folder-docs-open.svg", "folder-scripts": "./icons/folders/folder-scripts.svg", "folder-scripts-open": "./icons/folders/folder-scripts-open.svg", "folder-rust": "./icons/folders/folder-rust.svg", "folder-rust-open": "./icons/folders/folder-rust-open.svg", "folder-packages": "./icons/folders/folder-packages.svg", "folder-packages-open": "./icons/folders/folder-packages-open.svg", "folder-git": "./icons/folders/folder-git.svg", "folder-git-open": "./icons/folders/folder-git-open.svg", "folder-build": "./icons/folders/folder-build.svg", "folder-build-open": "./icons/folders/folder-build-open.svg", "folder-database": "./icons/folders/folder-database.svg", "folder-database-open": "./icons/folders/folder-database-open.svg", "folder-routes": "./icons/folders/folder-routes.svg", "folder-routes-open": "./icons/folders/folder-routes-open.svg", "folder-styles": "./icons/folders/folder-styles.svg", "folder-styles-open": "./icons/folders/folder-styles-open.svg", "folder-locales": "./icons/folders/folder-locales.svg", "folder-locales-open": "./icons/folders/folder-locales-open.svg", "folder-cloud": "./icons/folders/folder-cloud.svg", "folder-cloud-open": "./icons/folders/folder-cloud-open.svg", "folder-mobile": "./icons/folders/folder-mobile.svg", "folder-mobile-open": "./icons/folders/folder-mobile-open.svg", "folder-security": "./icons/folders/folder-security.svg", "folder-security-open": "./icons/folders/folder-security-open.svg", "folder-ai": "./icons/folders/folder-ai.svg", "folder-ai-open": "./icons/folders/folder-ai-open.svg", "folder-extensions": "./icons/folders/folder-extensions.svg", "folder-extensions-open": "./icons/folders/folder-extensions-open.svg"}, "lightIconDefinitions": {"file": "./icons/light/files/file.svg", "text": "./icons/light/files/text.svg", "document": "./icons/light/files/document.svg", "markdown": "./icons/light/files/markdown.svg", "html": "./icons/light/files/html.svg", "css": "./icons/light/files/css.svg", "sass": "./icons/light/files/sass.svg", "javascript": "./icons/light/files/javascript.svg", "typescript": "./icons/light/files/typescript.svg", "react": "./icons/light/files/react.svg", "vue": "./icons/light/files/vue.svg", "svelte": "./icons/light/files/svelte.svg", "astro": "./icons/light/files/astro.svg", "json": "./icons/light/files/json.svg", "yaml": "./icons/light/files/yaml.svg", "toml": "./icons/light/files/toml.svg", "xml": "./icons/light/files/xml.svg", "rust": "./icons/light/files/rust.svg", "python": "./icons/light/files/python.svg", "go": "./icons/light/files/go.svg", "java": "./icons/light/files/java.svg", "c": "./icons/light/files/c.svg", "cpp": "./icons/light/files/cpp.svg", "csharp": "./icons/light/files/csharp.svg", "swift": "./icons/light/files/swift.svg", "zig": "./icons/light/files/zig.svg", "ruby": "./icons/light/files/ruby.svg", "php": "./icons/light/files/php.svg", "shell": "./icons/light/files/shell.svg", "sql": "./icons/light/files/sql.svg", "database": "./icons/light/files/database.svg", "prisma": "./icons/light/files/prisma.svg", "graphql": "./icons/light/files/graphql.svg", "docker": "./icons/light/files/docker.svg", "git": "./icons/light/files/git.svg", "github": "./icons/light/files/github.svg", "package": "./icons/light/files/package.svg", "node": "./icons/light/files/node.svg", "bun": "./icons/light/files/bun.svg", "deno": "./icons/light/files/deno.svg", "lock": "./icons/light/files/lock.svg", "config": "./icons/light/files/config.svg", "env": "./icons/light/files/env.svg", "test": "./icons/light/files/test.svg", "vite": "./icons/light/files/vite.svg", "tailwind": "./icons/light/files/tailwind.svg", "image": "./icons/light/files/image.svg", "svg": "./icons/light/files/svg.svg", "audio": "./icons/light/files/audio.svg", "video": "./icons/light/files/video.svg", "font": "./icons/light/files/font.svg", "pdf": "./icons/light/files/pdf.svg", "notebook": "./icons/light/files/notebook.svg", "next": "./icons/light/files/next.svg", "nuxt": "./icons/light/files/nuxt.svg", "angular": "./icons/light/files/angular.svg", "solid": "./icons/light/files/solid.svg", "remix": "./icons/light/files/remix.svg", "qwik": "./icons/light/files/qwik.svg", "lit": "./icons/light/files/lit.svg", "storybook": "./icons/light/files/storybook.svg", "jest": "./icons/light/files/jest.svg", "vitest": "./icons/light/files/vitest.svg", "playwright": "./icons/light/files/playwright.svg", "cypress": "./icons/light/files/cypress.svg", "eslint": "./icons/light/files/eslint.svg", "prettier": "./icons/light/files/prettier.svg", "biome": "./icons/light/files/biome.svg", "babel": "./icons/light/files/babel.svg", "swc": "./icons/light/files/swc.svg", "webpack": "./icons/light/files/webpack.svg", "rollup": "./icons/light/files/rollup.svg", "rspack": "./icons/light/files/rspack.svg", "turborepo": "./icons/light/files/turborepo.svg", "nx": "./icons/light/files/nx.svg", "npm": "./icons/light/files/npm.svg", "pnpm": "./icons/light/files/pnpm.svg", "yarn": "./icons/light/files/yarn.svg", "maven": "./icons/light/files/maven.svg", "gradle": "./icons/light/files/gradle.svg", "kotlin": "./icons/light/files/kotlin.svg", "dart": "./icons/light/files/dart.svg", "lua": "./icons/light/files/lua.svg", "elixir": "./icons/light/files/elixir.svg", "erlang": "./icons/light/files/erlang.svg", "haskell": "./icons/light/files/haskell.svg", "scala": "./icons/light/files/scala.svg", "clojure": "./icons/light/files/clojure.svg", "nim": "./icons/light/files/nim.svg", "nix": "./icons/light/files/nix.svg", "terraform": "./icons/light/files/terraform.svg", "kubernetes": "./icons/light/files/kubernetes.svg", "helm": "./icons/light/files/helm.svg", "ansible": "./icons/light/files/ansible.svg", "cloudflare": "./icons/light/files/cloudflare.svg", "netlify": "./icons/light/files/netlify.svg", "vercel": "./icons/light/files/vercel.svg", "firebase": "./icons/light/files/firebase.svg", "supabase": "./icons/light/files/supabase.svg", "mongo": "./icons/light/files/mongo.svg", "redis": "./icons/light/files/redis.svg", "postgres": "./icons/light/files/postgres.svg", "drizzle": "./icons/light/files/drizzle.svg", "figma": "./icons/light/files/figma.svg", "sketch": "./icons/light/files/sketch.svg", "adobe": "./icons/light/files/adobe.svg", "csv": "./icons/light/files/csv.svg", "spreadsheet": "./icons/light/files/spreadsheet.svg", "word": "./icons/light/files/word.svg", "powerpoint": "./icons/light/files/powerpoint.svg", "archive": "./icons/light/files/archive.svg", "certificate": "./icons/light/files/certificate.svg", "key": "./icons/light/files/key.svg", "log": "./icons/light/files/log.svg", "diff": "./icons/light/files/diff.svg", "patch": "./icons/light/files/patch.svg", "license": "./icons/light/files/license.svg", "makefile": "./icons/light/files/makefile.svg", "cmake": "./icons/light/files/cmake.svg", "proto": "./icons/light/files/proto.svg", "wasm": "./icons/light/files/wasm.svg", "rescript": "./icons/light/files/rescript.svg", "ocaml": "./icons/light/files/ocaml.svg", "solidity": "./icons/light/files/solidity.svg", "r": "./icons/light/files/r.svg", "julia": "./icons/light/files/julia.svg", "perl": "./icons/light/files/perl.svg", "lithe": "./icons/light/files/lithe.svg", "codex": "./icons/light/files/codex.svg", "claude": "./icons/light/files/claude.svg", "cursor": "./icons/light/files/cursor.svg", "tauri": "./icons/light/files/tauri.svg", "electron": "./icons/light/files/electron.svg", "xcode": "./icons/light/files/xcode.svg", "android": "./icons/light/files/android.svg", "apple": "./icons/light/files/apple.svg", "windows": "./icons/light/files/windows.svg", "linux": "./icons/light/files/linux.svg", "changelog": "./icons/light/files/changelog.svg", "authors": "./icons/light/files/authors.svg", "security": "./icons/light/files/security.svg", "warning": "./icons/light/files/warning.svg", "agents": "./icons/light/files/agents.svg", "copilot": "./icons/light/files/copilot.svg", "gemini": "./icons/light/files/gemini.svg", "cline": "./icons/light/files/cline.svg", "mcp": "./icons/light/files/mcp.svg", "editorconfig": "./icons/light/files/editorconfig.svg", "stylelint": "./icons/light/files/stylelint.svg", "markdownlint": "./icons/light/files/markdownlint.svg", "cspell": "./icons/light/files/cspell.svg", "commitlint": "./icons/light/files/commitlint.svg", "lintstaged": "./icons/light/files/lintstaged.svg", "renovate": "./icons/light/files/renovate.svg", "dependabot": "./icons/light/files/dependabot.svg", "docker-compose": "./icons/light/files/docker-compose.svg", "devcontainer": "./icons/light/files/devcontainer.svg", "github-actions": "./icons/light/files/github-actions.svg", "gitlab": "./icons/light/files/gitlab.svg", "bitbucket": "./icons/light/files/bitbucket.svg", "jenkins": "./icons/light/files/jenkins.svg", "vercel-config": "./icons/light/files/vercel-config.svg", "nginx": "./icons/light/files/nginx.svg", "http": "./icons/light/files/http.svg", "hurl": "./icons/light/files/hurl.svg", "graphql-schema": "./icons/light/files/graphql-schema.svg", "jsconfig": "./icons/light/files/jsconfig.svg", "index-js": "./icons/light/files/index-js.svg", "index-ts": "./icons/light/files/index-ts.svg", "layout": "./icons/light/files/layout.svg", "page": "./icons/light/files/page.svg", "route": "./icons/light/files/route.svg", "loading": "./icons/light/files/loading.svg", "not-found": "./icons/light/files/not-found.svg", "error": "./icons/light/files/error.svg", "docusaurus": "./icons/light/files/docusaurus.svg", "gatsby": "./icons/light/files/gatsby.svg", "laravel": "./icons/light/files/laravel.svg", "django": "./icons/light/files/django.svg", "flask": "./icons/light/files/flask.svg", "fastapi": "./icons/light/files/fastapi.svg", "arduino": "./icons/light/files/arduino.svg", "blender": "./icons/light/files/blender.svg", "drawio": "./icons/light/files/drawio.svg", "excalidraw": "./icons/light/files/excalidraw.svg", "mermaid": "./icons/light/files/mermaid.svg", "folder": "./icons/light/folders/folder.svg", "folder-open": "./icons/light/folders/folder-open.svg", "folder-source": "./icons/light/folders/folder-source.svg", "folder-source-open": "./icons/light/folders/folder-source-open.svg", "folder-components": "./icons/light/folders/folder-components.svg", "folder-components-open": "./icons/light/folders/folder-components-open.svg", "folder-test": "./icons/light/folders/folder-test.svg", "folder-test-open": "./icons/light/folders/folder-test-open.svg", "folder-config": "./icons/light/folders/folder-config.svg", "folder-config-open": "./icons/light/folders/folder-config-open.svg", "folder-assets": "./icons/light/folders/folder-assets.svg", "folder-assets-open": "./icons/light/folders/folder-assets-open.svg", "folder-docs": "./icons/light/folders/folder-docs.svg", "folder-docs-open": "./icons/light/folders/folder-docs-open.svg", "folder-scripts": "./icons/light/folders/folder-scripts.svg", "folder-scripts-open": "./icons/light/folders/folder-scripts-open.svg", "folder-rust": "./icons/light/folders/folder-rust.svg", "folder-rust-open": "./icons/light/folders/folder-rust-open.svg", "folder-packages": "./icons/light/folders/folder-packages.svg", "folder-packages-open": "./icons/light/folders/folder-packages-open.svg", "folder-git": "./icons/light/folders/folder-git.svg", "folder-git-open": "./icons/light/folders/folder-git-open.svg", "folder-build": "./icons/light/folders/folder-build.svg", "folder-build-open": "./icons/light/folders/folder-build-open.svg", "folder-database": "./icons/light/folders/folder-database.svg", "folder-database-open": "./icons/light/folders/folder-database-open.svg", "folder-routes": "./icons/light/folders/folder-routes.svg", "folder-routes-open": "./icons/light/folders/folder-routes-open.svg", "folder-styles": "./icons/light/folders/folder-styles.svg", "folder-styles-open": "./icons/light/folders/folder-styles-open.svg", "folder-locales": "./icons/light/folders/folder-locales.svg", "folder-locales-open": "./icons/light/folders/folder-locales-open.svg", "folder-cloud": "./icons/light/folders/folder-cloud.svg", "folder-cloud-open": "./icons/light/folders/folder-cloud-open.svg", "folder-mobile": "./icons/light/folders/folder-mobile.svg", "folder-mobile-open": "./icons/light/folders/folder-mobile-open.svg", "folder-security": "./icons/light/folders/folder-security.svg", "folder-security-open": "./icons/light/folders/folder-security-open.svg", "folder-ai": "./icons/light/folders/folder-ai.svg", "folder-ai-open": "./icons/light/folders/folder-ai-open.svg", "folder-extensions": "./icons/light/folders/folder-extensions.svg", "folder-extensions-open": "./icons/light/folders/folder-extensions-open.svg"}, "fileExtensions": {".txt": "text", ".md": "markdown", ".mdx": "markdown", ".html": "html", ".htm": "html", ".css": "css", ".scss": "sass", ".sass": "sass", ".js": "javascript", ".mjs": "javascript", ".cjs": "javascript", ".jsx": "react", ".ts": "typescript", ".mts": "typescript", ".cts": "typescript", ".tsx": "react", ".vue": "vue", ".svelte": "svelte", ".astro": "astro", ".json": "json", ".jsonc": "json", ".yaml": "yaml", ".yml": "yaml", ".toml": "toml", ".xml": "xml", ".svg": "svg", ".rs": "rust", ".ron": "rust", ".py": "python", ".go": "go", ".java": "java", ".c": "c", ".h": "c", ".cpp": "cpp", ".cxx": "cpp", ".cc": "cpp", ".hpp": "cpp", ".cs": "csharp", ".swift": "swift", ".zig": "zig", ".rb": "ruby", ".php": "php", ".sh": "shell", ".bash": "shell", ".zsh": "shell", ".fish": "shell", ".sql": "sql", ".sqlite": "database", ".sqlite3": "database", ".db": "database", ".prisma": "prisma", ".graphql": "graphql", ".gql": "graphql", ".dockerfile": "docker", ".lock": "lock", ".env": "env", ".test.js": "test", ".test.ts": "test", ".test.tsx": "test", ".spec.js": "test", ".spec.ts": "test", ".spec.tsx": "test", ".png": "image", ".jpg": "image", ".jpeg": "image", ".webp": "image", ".gif": "image", ".ico": "image", ".mp3": "audio", ".wav": "audio", ".flac": "audio", ".mp4": "video", ".mov": "video", ".webm": "video", ".ttf": "font", ".otf": "font", ".woff": "font", ".woff2": "font", ".pdf": "pdf", ".ipynb": "notebook", ".vue.ts": "vue", ".svelte.ts": "svelte", ".stories.js": "storybook", ".stories.jsx": "storybook", ".stories.ts": "storybook", ".stories.tsx": "storybook", ".story.js": "storybook", ".story.jsx": "storybook", ".story.ts": "storybook", ".story.tsx": "storybook", ".cy.js": "cypress", ".cy.ts": "cypress", ".cy.tsx": "cypress", ".playwright.js": "playwright", ".playwright.ts": "playwright", ".test.mjs": "test", ".spec.mjs": "test", ".kt": "kotlin", ".kts": "kotlin", ".dart": "dart", ".lua": "lua", ".ex": "elixir", ".exs": "elixir", ".erl": "erlang", ".hrl": "erlang", ".hs": "haskell", ".scala": "scala", ".sc": "scala", ".clj": "clojure", ".cljs": "clojure", ".nim": "nim", ".nix": "nix", ".tf": "terraform", ".tfvars": "terraform", ".hcl": "terraform", ".k8s.yaml": "kubernetes", ".helm.yaml": "helm", ".yarnrc": "yarn", ".npmrc": "npm", ".csv": "csv", ".tsv": "csv", ".xlsx": "spreadsheet", ".xls": "spreadsheet", ".doc": "word", ".docx": "word", ".ppt": "powerpoint", ".pptx": "powerpoint", ".zip": "archive", ".tar": "archive", ".gz": "archive", ".tgz": "archive", ".rar": "archive", ".7z": "archive", ".pem": "certificate", ".crt": "certificate", ".cer": "certificate", ".key": "key", ".log": "log", ".diff": "diff", ".patch": "patch", ".proto": "proto", ".wasm": "wasm", ".res": "rescript", ".resi": "rescript", ".ml": "ocaml", ".mli": "ocaml", ".sol": "solidity", ".r": "r", ".rmd": "r", ".jl": "julia", ".pl": "perl", ".app": "apple", ".ipa": "apple", ".apk": "android", ".aab": "android", ".exe": "windows", ".msi": "windows", ".dll": "windows", ".so": "linux", ".http": "http", ".rest": "http", ".hurl": "hurl", ".drawio": "drawio", ".excalidraw": "excalidraw", ".mmd": "mermaid", ".mermaid": "mermaid", ".blend": "blender", ".ino": "arduino"}, "filenames": {"package.json": "node", "package-lock.json": "node", "bun.lock": "bun", "bun.lockb": "bun", "bunfig.toml": "bun", "deno.json": "deno", "deno.jsonc": "deno", "tsconfig.json": "typescript", "vite.config.js": "vite", "vite.config.ts": "vite", "tailwind.config.js": "tailwind", "tailwind.config.ts": "tailwind", "dockerfile": "docker", ".dockerignore": "docker", ".gitignore": "git", ".gitattributes": "git", ".env": "env", ".env.example": "env", "cargo.toml": "rust", "cargo.lock": "rust", "readme.md": "markdown", "license": "license", "license.md": "license", "next.config.js": "next", "next.config.mjs": "next", "next.config.ts": "next", "nuxt.config.js": "nuxt", "nuxt.config.ts": "nuxt", "angular.json": "angular", "remix.config.js": "remix", "remix.config.ts": "remix", "qwik.config.js": "qwik", "qwik.config.ts": "qwik", "lit.config.js": "lit", "storybook.config.js": "storybook", "jest.config.js": "jest", "jest.config.ts": "jest", "vitest.config.js": "vitest", "vitest.config.ts": "vitest", "playwright.config.js": "playwright", "playwright.config.ts": "playwright", "cypress.config.js": "cypress", "cypress.config.ts": "cypress", ".eslintrc": "eslint", ".eslintrc.json": "eslint", "eslint.config.js": "eslint", "eslint.config.mjs": "eslint", "eslint.config.ts": "eslint", ".prettierrc": "prettier", ".prettierrc.json": "prettier", "prettier.config.js": "prettier", "biome.json": "biome", "biome.jsonc": "biome", "babel.config.js": "babel", ".babelrc": "babel", ".swcrc": "swc", "webpack.config.js": "webpack", "webpack.config.ts": "webpack", "rollup.config.js": "rollup", "rollup.config.ts": "rollup", "rspack.config.js": "rspack", "rspack.config.ts": "rspack", "turbo.json": "turborepo", "nx.json": "nx", "pnpm-lock.yaml": "pnpm", "pnpm-workspace.yaml": "pnpm", "yarn.lock": "yarn", "pom.xml": "maven", "build.gradle": "gradle", "build.gradle.kts": "gradle", "gradle.properties": "gradle", "gradlew": "gradle", "flake.nix": "nix", "terraform.tfvars": "terraform", "chart.yaml": "helm", "ansible.cfg": "ansible", "wrangler.toml": "cloudflare", "netlify.toml": "netlify", "vercel.json": "vercel", "firebase.json": "firebase", "supabase.toml": "supabase", "drizzle.config.ts": "drizzle", ".fig": "figma", "makefile": "makefile", "cmakelists.txt": "cmake", "copying": "license", "lithe.json": "lithe", ".codex": "codex", "agents.md": "codex", "claude.md": "claude", ".cursorrules": "cursor", "tauri.conf.json": "tauri", "electron-builder.json": "electron", "xcodeproj": "xcode", "androidmanifest.xml": "android", "changelog.md": "changelog", "authors": "authors", "authors.md": "authors", "security.md": "security", "codeowners": "security", ".agents": "agents", "agents.json": "agents", "agents.toml": "agents", "copilot-instructions.md": "copilot", ".mcp.json": "mcp", "mcp.json": "mcp", ".editorconfig": "editorconfig", ".stylelintrc": "stylelint", ".stylelintrc.json": "stylelint", "stylelint.config.js": "stylelint", "stylelint.config.mjs": "stylelint", ".markdownlint.json": "markdownlint", ".markdownlint.yaml": "markdownlint", ".markdownlintignore": "markdownlint", "cspell.json": "cspell", ".cspell.json": "cspell", "commitlint.config.js": "commitlint", "commitlint.config.ts": "commitlint", ".lintstagedrc": "lintstaged", "lint-staged.config.js": "lintstaged", "renovate.json": "renovate", "dependabot.yml": "dependabot", "docker-compose.yml": "docker-compose", "docker-compose.yaml": "docker-compose", ".devcontainer.json": "devcontainer", "devcontainer.json": "devcontainer", "action.yml": "github-actions", "action.yaml": "github-actions", ".gitlab-ci.yml": "gitlab", "bitbucket-pipelines.yml": "bitbucket", "jenkinsfile": "jenkins", "nginx.conf": "nginx", "index.js": "index-js", "index.ts": "index-ts", "index.tsx": "index-ts", "layout.js": "layout", "layout.jsx": "layout", "layout.ts": "layout", "layout.tsx": "layout", "page.js": "page", "page.jsx": "page", "page.ts": "page", "page.tsx": "page", "route.js": "route", "route.ts": "route", "loading.js": "loading", "loading.tsx": "loading", "not-found.js": "not-found", "not-found.tsx": "not-found", "error.js": "error", "error.tsx": "error", "docusaurus.config.js": "docusaurus", "docusaurus.config.ts": "docusaurus", "gatsby-config.js": "gatsby", "gatsby-node.js": "gatsby", "artisan": "laravel", "manage.py": "django", "app.py": "flask", "main.py": "python", "requirements.txt": "python"}, "folders": {"src": "folder-source", "source": "folder-source", "components": "folder-components", "component": "folder-components", "hooks": "folder-source", "utils": "folder-source", "util": "folder-source", "tests": "folder-test", "test": "folder-test", "__tests__": "folder-test", "config": "folder-config", "configs": "folder-config", "assets": "folder-assets", "public": "folder-assets", "images": "folder-assets", "img": "folder-assets", "docs": "folder-docs", "scripts": "folder-scripts", "crates": "folder-rust", "rust": "folder-rust", "src-tauri": "folder-rust", "tauri": "folder-rust", "node_modules": "folder-packages", ".git": "folder-git", ".github": "folder-git", ".vscode": "folder-config", "build": "folder-build", "dist": "folder-build", "database": "folder-database", "databases": "folder-database", "api": "folder-routes", "routes": "folder-routes", "router": "folder-routes", "stores": "folder-source", "store": "folder-source", "features": "folder-source", "ui": "folder-components", "pages": "folder-components", "app": "folder-components", "views": "folder-components", "view": "folder-components", "lib": "folder-source", "libs": "folder-source", "services": "folder-source", "service": "folder-source", "types": "folder-source", "typings": "folder-source", "styles": "folder-styles", "style": "folder-styles", "css": "folder-styles", "locales": "folder-locales", "i18n": "folder-locales", "terminal": "folder-scripts", "shell": "folder-scripts", "auth": "folder-security", "security": "folder-security", ".circleci": "folder-git", ".buildkite": "folder-git", ".docker": "folder-cloud", "docker": "folder-cloud", "k8s": "folder-cloud", "kubernetes": "folder-cloud", "helm": "folder-cloud", "cloud": "folder-cloud", ".firebase": "folder-database", "firebase": "folder-database", ".supabase": "folder-database", "supabase": "folder-database", "prisma": "folder-database", "cache": "folder-build", "logs": "folder-build", "log": "folder-build", "tmp": "folder-build", "temp": "folder-build", "mobile": "folder-mobile", "ios": "folder-mobile", "android": "folder-mobile", ".storybook": "folder-components", "storybook": "folder-components", "fixtures": "folder-test", "mocks": "folder-test", "mock": "folder-test", "generated": "folder-build", "gen": "folder-build", "benchmark": "folder-test", "benchmarks": "folder-test", "extensions": "folder-extensions", "extension": "folder-extensions", "themes": "folder-styles", "theme": "folder-styles", "ai": "folder-ai", ".agents": "folder-ai", "agents": "folder-ai", ".codex": "folder-ai", "codex": "folder-ai", ".claude": "folder-ai", "claude": "folder-ai", "packages": "folder-packages", "package": "folder-packages", "examples": "folder-docs", "example": "folder-docs", "playground": "folder-test", "play": "folder-test", "commands": "folder-scripts", "command": "folder-scripts", "plugins": "folder-extensions", "plugin": "folder-extensions", "workflows": "folder-git", "workflow": "folder-git", ".devcontainer": "folder-cloud", "devcontainer": "folder-cloud", "web": "folder-mobile", "www": "folder-mobile", "server": "folder-routes", "client": "folder-mobile", "shared": "folder-source", "common": "folder-source"}, "expandedFolders": {"src": "folder-source-open", "source": "folder-source-open", "components": "folder-components-open", "component": "folder-components-open", "hooks": "folder-source-open", "utils": "folder-source-open", "util": "folder-source-open", "tests": "folder-test-open", "test": "folder-test-open", "__tests__": "folder-test-open", "config": "folder-config-open", "configs": "folder-config-open", "assets": "folder-assets-open", "public": "folder-assets-open", "images": "folder-assets-open", "img": "folder-assets-open", "docs": "folder-docs-open", "scripts": "folder-scripts-open", "crates": "folder-rust-open", "rust": "folder-rust-open", "src-tauri": "folder-rust-open", "tauri": "folder-rust-open", "node_modules": "folder-packages-open", ".git": "folder-git-open", ".github": "folder-git-open", ".vscode": "folder-config-open", "build": "folder-build-open", "dist": "folder-build-open", "database": "folder-database-open", "databases": "folder-database-open", "api": "folder-routes-open", "routes": "folder-routes-open", "router": "folder-routes-open", "stores": "folder-source-open", "store": "folder-source-open", "features": "folder-source-open", "ui": "folder-components-open", "pages": "folder-components-open", "app": "folder-components-open", "views": "folder-components-open", "view": "folder-components-open", "lib": "folder-source-open", "libs": "folder-source-open", "services": "folder-source-open", "service": "folder-source-open", "types": "folder-source-open", "typings": "folder-source-open", "styles": "folder-styles-open", "style": "folder-styles-open", "css": "folder-styles-open", "locales": "folder-locales-open", "i18n": "folder-locales-open", "terminal": "folder-scripts-open", "shell": "folder-scripts-open", "auth": "folder-security-open", "security": "folder-security-open", ".circleci": "folder-git-open", ".buildkite": "folder-git-open", ".docker": "folder-cloud-open", "docker": "folder-cloud-open", "k8s": "folder-cloud-open", "kubernetes": "folder-cloud-open", "helm": "folder-cloud-open", "cloud": "folder-cloud-open", ".firebase": "folder-database-open", "firebase": "folder-database-open", ".supabase": "folder-database-open", "supabase": "folder-database-open", "prisma": "folder-database-open", "cache": "folder-build-open", "logs": "folder-build-open", "log": "folder-build-open", "tmp": "folder-build-open", "temp": "folder-build-open", "mobile": "folder-mobile-open", "ios": "folder-mobile-open", "android": "folder-mobile-open", ".storybook": "folder-components-open", "storybook": "folder-components-open", "fixtures": "folder-test-open", "mocks": "folder-test-open", "mock": "folder-test-open", "generated": "folder-build-open", "gen": "folder-build-open", "benchmark": "folder-test-open", "benchmarks": "folder-test-open", "extensions": "folder-extensions-open", "extension": "folder-extensions-open", "themes": "folder-styles-open", "theme": "folder-styles-open", "ai": "folder-ai-open", ".agents": "folder-ai-open", "agents": "folder-ai-open", ".codex": "folder-ai-open", "codex": "folder-ai-open", ".claude": "folder-ai-open", "claude": "folder-ai-open", "packages": "folder-packages-open", "package": "folder-packages-open", "examples": "folder-docs-open", "example": "folder-docs-open", "playground": "folder-test-open", "play": "folder-test-open", "commands": "folder-scripts-open", "command": "folder-scripts-open", "plugins": "folder-extensions-open", "plugin": "folder-extensions-open", "workflows": "folder-git-open", "workflow": "folder-git-open", ".devcontainer": "folder-cloud-open", "devcontainer": "folder-cloud-open", "web": "folder-mobile-open", "www": "folder-mobile-open", "server": "folder-routes-open", "client": "folder-mobile-open", "shared": "folder-source-open", "common": "folder-source-open"}} \ No newline at end of file +{"defaultFile": "idea-text", "defaultFolder": "idea-folder", "defaultFolderOpen": "idea-folder", "iconDefinitions": {"idea-typeScript": "./icons/expui/fileTypes/typeScript_dark.svg", "idea-javaScript": "./icons/expui/fileTypes/javaScript_dark.svg", "idea-json": "./icons/expui/fileTypes/json_dark.svg", "idea-markdown": "./icons/expui/fileTypes/markdown_dark.svg", "idea-java": "./icons/expui/fileTypes/java_dark.svg", "idea-xml": "./icons/expui/fileTypes/xml_dark.svg", "idea-xsd": "./icons/expui/fileTypes/xsd_dark.svg", "idea-wsdl": "./icons/expui/fileTypes/wsdl_dark.svg", "idea-xhtml": "./icons/expui/fileTypes/xhtml_dark.svg", "idea-yaml": "./icons/expui/fileTypes/yaml_dark.svg", "idea-html": "./icons/expui/fileTypes/html_dark.svg", "idea-css": "./icons/expui/fileTypes/css_dark.svg", "idea-sql": "./icons/expui/fileTypes/sql_dark.svg", "idea-shell": "./icons/expui/fileTypes/shell_dark.svg", "idea-powershell": "./icons/expui/fileTypes/powershell_dark.svg", "idea-properties": "./icons/expui/fileTypes/properties_dark.svg", "idea-toml": "./icons/expui/fileTypes/toml_dark.svg", "idea-csv": "./icons/expui/fileTypes/csv_dark.svg", "idea-http": "./icons/expui/fileTypes/http.svg", "idea-image": "./icons/expui/fileTypes/image_dark.svg", "idea-archive": "./icons/expui/fileTypes/archive_dark.svg", "idea-font": "./icons/expui/fileTypes/font_dark.svg", "idea-vue": "./icons/expui/fileTypes/vue.svg", "idea-graphql": "./icons/expui/fileTypes/graphql_dark.svg", "idea-terraform": "./icons/expui/fileTypes/terraform_dark.svg", "idea-swiftLang": "./icons/expui/fileTypes/swiftLang_dark.svg", "idea-c": "./icons/expui/fileTypes/c_dark.svg", "idea-h": "./icons/expui/fileTypes/h_dark.svg", "idea-cpp": "./icons/expui/fileTypes/cpp_dark.svg", "idea-Csharp": "./icons/expui/fileTypes/Csharp_dark.svg", "idea-jsp": "./icons/expui/fileTypes/jsp_dark.svg", "idea-gradle": "./icons/expui/fileTypes/gradle_dark.svg", "idea-groovy": "./icons/expui/fileTypes/groovy_dark.svg", "idea-perl": "./icons/expui/fileTypes/perl_dark.svg", "idea-rst": "./icons/expui/fileTypes/rst_dark.svg", "idea-patch": "./icons/expui/fileTypes/patch_dark.svg", "idea-text": "./icons/expui/fileTypes/text_dark.svg", "idea-class": "./icons/expui/nodes/class_dark.svg", "idea-interface": "./icons/expui/nodes/interface_dark.svg", "idea-enum": "./icons/expui/nodes/enum_dark.svg", "idea-annotation": "./icons/expui/nodes/annotation_dark.svg", "idea-record": "./icons/expui/nodes/record_dark.svg", "idea-exception": "./icons/expui/nodes/exception_dark.svg", "idea-maven": "./icons/expui/fileTypes/maven_dark.svg", "idea-docker": "./icons/expui/fileTypes/docker_dark.svg", "idea-gitignore": "./icons/expui/fileTypes/gitignore.svg", "idea-editorConfig": "./icons/expui/fileTypes/editorConfig_dark.svg", "idea-sourceRoot": "./icons/expui/nodes/sourceRoot_dark.svg", "idea-testRoot": "./icons/expui/nodes/testRoot_dark.svg", "idea-resourcesRoot": "./icons/expui/nodes/resourcesRoot_dark.svg", "idea-testResourcesRoot": "./icons/expui/nodes/testResourcesRoot_dark.svg", "idea-package": "./icons/expui/nodes/package_dark.svg", "idea-folder": "./icons/expui/nodes/folder_dark.svg"}, "lightIconDefinitions": {"idea-typeScript": "./icons/expui/fileTypes/typeScript.svg", "idea-javaScript": "./icons/expui/fileTypes/javaScript.svg", "idea-json": "./icons/expui/fileTypes/json.svg", "idea-markdown": "./icons/expui/fileTypes/markdown.svg", "idea-java": "./icons/expui/fileTypes/java.svg", "idea-xml": "./icons/expui/fileTypes/xml.svg", "idea-xsd": "./icons/expui/fileTypes/xsd.svg", "idea-wsdl": "./icons/expui/fileTypes/wsdl.svg", "idea-xhtml": "./icons/expui/fileTypes/xhtml.svg", "idea-yaml": "./icons/expui/fileTypes/yaml.svg", "idea-html": "./icons/expui/fileTypes/html.svg", "idea-css": "./icons/expui/fileTypes/css.svg", "idea-sql": "./icons/expui/fileTypes/sql.svg", "idea-shell": "./icons/expui/fileTypes/shell.svg", "idea-powershell": "./icons/expui/fileTypes/powershell.svg", "idea-properties": "./icons/expui/fileTypes/properties.svg", "idea-toml": "./icons/expui/fileTypes/toml.svg", "idea-csv": "./icons/expui/fileTypes/csv.svg", "idea-http": "./icons/expui/fileTypes/http.svg", "idea-image": "./icons/expui/fileTypes/image.svg", "idea-archive": "./icons/expui/fileTypes/archive.svg", "idea-font": "./icons/expui/fileTypes/font.svg", "idea-vue": "./icons/expui/fileTypes/vue.svg", "idea-graphql": "./icons/expui/fileTypes/graphql.svg", "idea-terraform": "./icons/expui/fileTypes/terraform.svg", "idea-swiftLang": "./icons/expui/fileTypes/swiftLang.svg", "idea-c": "./icons/expui/fileTypes/c.svg", "idea-h": "./icons/expui/fileTypes/h.svg", "idea-cpp": "./icons/expui/fileTypes/cpp.svg", "idea-Csharp": "./icons/expui/fileTypes/Csharp.svg", "idea-jsp": "./icons/expui/fileTypes/jsp.svg", "idea-gradle": "./icons/expui/fileTypes/gradle.svg", "idea-groovy": "./icons/expui/fileTypes/groovy.svg", "idea-perl": "./icons/expui/fileTypes/perl.svg", "idea-rst": "./icons/expui/fileTypes/rst.svg", "idea-patch": "./icons/expui/fileTypes/patch.svg", "idea-text": "./icons/expui/fileTypes/text.svg", "idea-class": "./icons/expui/nodes/class.svg", "idea-interface": "./icons/expui/nodes/interface.svg", "idea-enum": "./icons/expui/nodes/enum.svg", "idea-annotation": "./icons/expui/nodes/annotation.svg", "idea-record": "./icons/expui/nodes/record.svg", "idea-exception": "./icons/expui/nodes/exception.svg", "idea-maven": "./icons/expui/fileTypes/maven.svg", "idea-docker": "./icons/expui/fileTypes/docker.svg", "idea-gitignore": "./icons/expui/fileTypes/gitignore.svg", "idea-editorConfig": "./icons/expui/fileTypes/editorConfig.svg", "idea-sourceRoot": "./icons/expui/nodes/sourceRoot.svg", "idea-testRoot": "./icons/expui/nodes/testRoot.svg", "idea-resourcesRoot": "./icons/expui/nodes/resourcesRoot.svg", "idea-testResourcesRoot": "./icons/expui/nodes/testResourcesRoot.svg", "idea-package": "./icons/expui/nodes/package.svg", "idea-folder": "./icons/expui/nodes/folder.svg"}, "fileExtensions": {".ts": "idea-typeScript", ".mts": "idea-typeScript", ".cts": "idea-typeScript", ".tsx": "idea-typeScript", ".js": "idea-javaScript", ".mjs": "idea-javaScript", ".cjs": "idea-javaScript", ".jsx": "idea-javaScript", ".json": "idea-json", ".jsonc": "idea-json", ".json5": "idea-json", ".md": "idea-markdown", ".mdx": "idea-markdown", ".java": "idea-java", ".xml": "idea-xml", ".xsd": "idea-xsd", ".wsdl": "idea-wsdl", ".xhtml": "idea-xhtml", ".yml": "idea-yaml", ".yaml": "idea-yaml", ".html": "idea-html", ".htm": "idea-html", ".css": "idea-css", ".scss": "idea-css", ".less": "idea-css", ".sass": "idea-css", ".sql": "idea-sql", ".sh": "idea-shell", ".bash": "idea-shell", ".zsh": "idea-shell", ".ps1": "idea-powershell", ".psm1": "idea-powershell", ".psd1": "idea-powershell", ".ps1xml": "idea-powershell", ".psc1": "idea-powershell", ".pssc": "idea-powershell", ".properties": "idea-properties", ".toml": "idea-toml", ".csv": "idea-csv", ".http": "idea-http", ".png": "idea-image", ".jpg": "idea-image", ".jpeg": "idea-image", ".gif": "idea-image", ".svg": "idea-image", ".webp": "idea-image", ".ico": "idea-image", ".bmp": "idea-image", ".zip": "idea-archive", ".tar": "idea-archive", ".gz": "idea-archive", ".tgz": "idea-archive", ".7z": "idea-archive", ".rar": "idea-archive", ".ttf": "idea-font", ".otf": "idea-font", ".woff": "idea-font", ".woff2": "idea-font", ".vue": "idea-vue", ".graphql": "idea-graphql", ".gql": "idea-graphql", ".tf": "idea-terraform", ".hcl": "idea-terraform", ".swift": "idea-swiftLang", ".c": "idea-c", ".h": "idea-h", ".cpp": "idea-cpp", ".cc": "idea-cpp", ".cxx": "idea-cpp", ".hpp": "idea-h", ".cs": "idea-Csharp", ".jsp": "idea-jsp", ".gradle": "idea-gradle", ".kts": "idea-gradle", ".groovy": "idea-groovy", ".pl": "idea-perl", ".rst": "idea-rst", ".patch": "idea-patch", ".diff": "idea-patch", ".txt": "idea-text", ".log": "idea-text", ".lock": "idea-text"}, "filenames": {"\u0000lithe:java.class": "idea-class", "\u0000lithe:java.interface": "idea-interface", "\u0000lithe:java.enum": "idea-enum", "\u0000lithe:java.annotation": "idea-annotation", "\u0000lithe:java.record": "idea-record", "\u0000lithe:java.exception": "idea-exception", "pom.xml": "idea-maven", "dockerfile": "idea-docker", ".gitignore": "idea-gitignore", ".editorconfig": "idea-editorConfig", ".env": "idea-properties", "license": "idea-text", "notice": "idea-text"}, "folders": {"\u0000lithe:folder.source-root": "idea-sourceRoot", "\u0000lithe:folder.test-root": "idea-testRoot", "\u0000lithe:folder.resources-root": "idea-resourcesRoot", "\u0000lithe:folder.test-resources-root": "idea-testResourcesRoot", "\u0000lithe:folder.package": "idea-package"}, "expandedFolders": {"\u0000lithe:folder.source-root": "idea-sourceRoot", "\u0000lithe:folder.test-root": "idea-testRoot", "\u0000lithe:folder.resources-root": "idea-resourcesRoot", "\u0000lithe:folder.test-resources-root": "idea-testResourcesRoot", "\u0000lithe:folder.package": "idea-package"}} \ No newline at end of file diff --git a/linux/src/workbench/editor.rs b/linux/src/workbench/editor.rs index a8c31fab7..d2fcecd80 100644 --- a/linux/src/workbench/editor.rs +++ b/linux/src/workbench/editor.rs @@ -167,6 +167,13 @@ impl EditorView { }; let language = Self::language_name(&tab.path).to_lowercase(); + // 上游 `Language::from_name` 只认 `bash`/`sh`,`language_name` 的 `Shell` + // 转小写后是 `shell`,此处映射回 `sh` 否则高亮回退到 Plain。 + let language = if language == "shell" { + "sh" + } else { + language.as_str() + }; let content = tab.content.clone(); self.editor_state.update(cx, |editor, cx| { editor.set_value(content, window, cx); From 58662f3f25130a6342d09353918318f010864408 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Thu, 24 Sep 2026 23:57:02 +0800 Subject: [PATCH 029/109] =?UTF-8?q?chore(core):=20tree-sitter=200.25=20?= =?UTF-8?q?=E5=8D=87=200.26=EF=BC=8C=E4=B8=8E=20gpui-kit=20=E9=AB=98?= =?UTF-8?q?=E4=BA=AE=E7=89=B9=E6=80=A7=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - links 冲突:全图只允许一份 tree-sitter 原生库 - core 仅用 Parser/Node 基础 API,适配 Node::child 改取 u32 --- linux/Cargo.lock | 109 ++++++++++++++++++++++- rust/lithe-core/Cargo.toml | 2 +- rust/lithe-core/src/languages/mybatis.rs | 3 +- 3 files changed, 108 insertions(+), 6 deletions(-) diff --git a/linux/Cargo.lock b/linux/Cargo.lock index c7ef06208..600338172 100644 --- a/linux/Cargo.lock +++ b/linux/Cargo.lock @@ -880,9 +880,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.7" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", @@ -2464,6 +2464,17 @@ dependencies = [ "smallvec", "smol", "tracing", + "tree-sitter", + "tree-sitter-bash", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-json", + "tree-sitter-md", + "tree-sitter-rust", + "tree-sitter-sequel", + "tree-sitter-toml-ng", + "tree-sitter-typescript", + "tree-sitter-yaml", "uuid", "windows 0.58.0", ] @@ -7638,9 +7649,9 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.25.10" +version = "0.26.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" +checksum = "17ebdd3a5a7e28a1890b876fdbd0c3c0fe0a6336cffaa104f11b9f720c9daa29" dependencies = [ "cc", "regex", @@ -7650,6 +7661,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-bash" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "329a4d48623ac337d42b1df84e81a1c9dbb2946907c102ca72db158c1964a52e" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-java" version = "0.23.5" @@ -7660,12 +7681,92 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-javascript" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[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 = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081" +[[package]] +name = "tree-sitter-md" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efd398be546456c814598ee56c0f51769a77241511b4a58077815d120afa882" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-sequel" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d198ad3c319c02e43c21efa1ec796b837afcb96ffaef1a40c1978fbdcec7d17" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-toml-ng" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9adc2c898ae49730e857d75be403da3f92bb81d8e37a2f918a08dd10de5ebb1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-yaml" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "triomphe" version = "0.1.16" diff --git a/rust/lithe-core/Cargo.toml b/rust/lithe-core/Cargo.toml index 53800e763..68f7618d9 100644 --- a/rust/lithe-core/Cargo.toml +++ b/rust/lithe-core/Cargo.toml @@ -23,7 +23,7 @@ serde_json = "1.0" sha1 = "0.10" sha2 = "0.10" toml = { version = "1.1.4", default-features = false, features = ["parse", "serde"] } -tree-sitter = "0.25" +tree-sitter = "0.26" tree-sitter-java = "0.23" serde_yaml_ng = "0.10.0" zip = { version = "2.4", default-features = false, features = ["deflate"] } diff --git a/rust/lithe-core/src/languages/mybatis.rs b/rust/lithe-core/src/languages/mybatis.rs index 2bd9fb63f..b8aeeaaba 100644 --- a/rust/lithe-core/src/languages/mybatis.rs +++ b/rust/lithe-core/src/languages/mybatis.rs @@ -216,8 +216,9 @@ fn collect_types( } } // Reverse the push order to preserve the recursive walk's source preorder. + // tree-sitter 0.26 起 `Node::child` 取 `u32`,`child_count` 仍为 `usize`。 for index in (0..node.child_count()).rev() { - if let Some(child) = node.child(index) { + if let Some(child) = node.child(index as u32) { pending.push(child); } } From 2bcdaf3e2b2c9989bd570c1683991421eaec439b Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 00:01:09 +0800 Subject: [PATCH 030/109] =?UTF-8?q?feat(linux):=20Run=20=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E6=89=A7=E8=A1=8C=E9=93=BE=E4=B8=8E=20Maven/?= =?UTF-8?q?LSP=20=E6=8E=A5=E7=BA=BF=EF=BC=8C=E5=AF=B9=E9=BD=90=20Tauri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 底部 Run Tab:maven.scan 探工程 + runConfig.generate 取配置 + createLaunchPlan 执行,子进程输出流式展示,可停止;无 LSP 时文本扫描 main 方法兜底 - 运行统一经 send_terminal_command 计入历史并点亮 maven 项;run.start 补命令映射 - Maven 面板:core 扫描状态、依赖展开、自定义 goal、Reload - LSP 分组:语言服务器探测状态诚实展示(本机无 jdtls,不伪造启动) - 工作区切换经 set_working_dir 触发 Run 重探 --- linux/src/workbench/bottom_panel.rs | 1037 +++++++++++++++++++++++- linux/src/workbench/maven.rs | 563 +++++++++++-- linux/src/workbench/settings_dialog.rs | 80 +- linux/src/workbench/view.rs | 4 +- 4 files changed, 1579 insertions(+), 105 deletions(-) diff --git a/linux/src/workbench/bottom_panel.rs b/linux/src/workbench/bottom_panel.rs index 1109dc8a7..d24063ac0 100644 --- a/linux/src/workbench/bottom_panel.rs +++ b/linux/src/workbench/bottom_panel.rs @@ -1,19 +1,64 @@ +use std::sync::{mpsc, Arc, Mutex}; + use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::component::scroll::ScrollableElement as _; -use gpui_kit::component::{h_flex, v_flex, Icon, Sizable as _}; +use gpui_kit::component::{h_flex, v_flex, Disableable as _, Icon, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ div, px, AnyElement, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, Window, }; +use crate::core::CoreClient; use crate::theme::ThemeColors; use crate::workbench::terminal::TerminalView; /// 运行历史上限。 const MAX_RUN_HISTORY: usize = 50; +/// Run 输出保留上限(行),超出丢弃最旧。 +const MAX_RUN_OUTPUT: usize = 2000; + +/// Run 面板工程状态,对齐 Tauri RunPane 的 missing/ready/invalid 三态 +/// (Linux 可用子集:invalid 统一为 `Failed` 并携带 core 错误文案)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RunProjectState { + /// 无可运行配置(非 Java/Maven/npm 工程或配置为空)。 + Missing, + /// 正在经 core 探测工程与配置。 + Loading, + /// 配置列表可用。 + Ready, + /// 探测失败,内附展示文案。 + Failed(String), +} + +/// Run 配置行:本地 `.lithe/run/configurations.json` 与 +/// `runConfig.generate` 结果的显示子集。 +#[derive(Debug, Clone)] +pub struct RunConfigItem { + /// core 配置 id(本地文件项用配置名代替;`createLaunchPlan` + /// 可能因此找不到而走退化执行)。 + pub id: String, + pub name: String, + /// `mainClass`(java)|`maven`|`npm`。 + pub kind: String, + /// 副标题:主类 / 模块 / 脚本参数。 + pub detail: String, + pub main_class: Option, + /// java 源码路径(generate 结果的 `extensions.java.source`),单文件退化编译用。 + pub source: Option, +} + +/// 后台运行步骤(退化执行用,不经 core 常驻进程宿主)。 +#[derive(Debug, Clone)] +struct RunStep { + program: String, + args: Vec, + cwd: String, +} + /// Git 提交记录行数上限。 const MAX_GIT_LOG_ENTRIES: usize = 50; @@ -43,6 +88,24 @@ pub struct BottomPanelView { /// 运行历史(`TerminalView::send_command` 被调用时由宿主经 /// [`BottomPanelView::record_run`] 落入),上限 [`MAX_RUN_HISTORY`]。 pub run_history: Vec, + /// Run 面板状态机(Tauri RunPane 的 Linux 可用子集)。 + pub(crate) run_state: RunProjectState, + /// Run 面板工程展示名(工作区目录名)。 + pub(crate) run_project_name: String, + /// Run 配置列表(本地文件优先,否则 generate 结果)。 + pub(crate) run_configs: Vec, + /// 选中的 Run 配置 id。 + pub(crate) selected_run_config: Option, + /// 运行输出行(后台线程经 channel 推送,`cx.spawn` 泵入)。 + pub(crate) run_output: Vec, + /// 是否有进程在跑。 + pub(crate) run_running: bool, + /// core 客户端(`reload_run_project` / `createLaunchPlan` 经它走 core JSON 命令)。 + client: CoreClient, + /// 在跑子进程句柄:停止按钮经 `kill` 停,运行线程经 `try_wait` 短锁轮询收割。 + run_child: Arc>>, + /// reload 序号,丢弃过期探测结果。 + run_seq: u64, /// 最近一次加载的 Git 提交记录。 pub git_log: Vec, /// Git 记录加载失败时的展示文案;成功后清空。 @@ -59,8 +122,17 @@ impl BottomPanelView { height: 240.0, terminal, diagnostics: Vec::new(), - working_dir, + working_dir: working_dir.clone(), run_history: Vec::new(), + run_state: RunProjectState::Missing, + run_project_name: crate::settings::project_dir_name(&working_dir).to_string(), + run_configs: Vec::new(), + selected_run_config: None, + run_output: Vec::new(), + run_running: false, + client: CoreClient::new(), + run_child: Arc::new(Mutex::new(None)), + run_seq: 0, git_log: Vec::new(), git_log_error: None, } @@ -83,6 +155,11 @@ impl BottomPanelView { self.refresh_git_log(cx); return; } + if tab == BottomTab::Run { + // 打开即重探 Run 工程;reload_run_project 内已 notify。 + self.reload_run_project(cx); + return; + } cx.notify(); } @@ -111,6 +188,232 @@ impl BottomPanelView { !self.run_history.is_empty() } + /// 更新工作目录并重探 Run 工程(替代直接写 `working_dir` 字段)。 + pub fn set_working_dir(&mut self, dir: String, cx: &mut Context) { + self.working_dir = dir; + self.reload_run_project(cx); + } + + /// 重探 Run 工程:本地 `.lithe/run/configurations.json` 优先命中即 `Ready`; + /// 否则先 `maven.scan`(payload `{root, paths}`)探测工程类型,再 + /// `runConfig.generate`(payload `{root}`,返回 + /// `{generated: {configurations[]}, entryCount}`)取配置列表,失败即 `Failed`。 + pub fn reload_run_project(&mut self, cx: &mut Context) { + self.run_seq += 1; + let seq = self.run_seq; + self.run_project_name = crate::settings::project_dir_name(&self.working_dir).to_string(); + let local = read_local_run_configs(&self.working_dir); + if !local.is_empty() { + self.run_configs = local; + self.keep_run_selection(); + self.run_state = RunProjectState::Ready; + cx.notify(); + return; + } + self.run_state = RunProjectState::Loading; + cx.notify(); + let client = self.client.clone(); + let root = self.working_dir.clone(); + cx.spawn(async move |this, cx| { + // 先探 Maven 工程(只作工程类型参考;失败不直接 Failed,交给 generate 定夺)。 + let _ = client + .execute::( + &cx, + "maven.scan", + serde_json::json!({ "root": root, "paths": [] }), + ) + .await; + let generated = client + .execute::( + &cx, + "runConfig.generate", + serde_json::json!({ "root": root }), + ) + .await; + let _ = this.update(cx, |view, cx| { + if view.run_seq != seq { + return; + } + match generated { + Ok(value) => { + view.run_configs = parse_generated_configs(&value); + // 无 LSP 时 generate 为空:本地文本扫描 main 方法兜底 + //(对齐 Tauri 入口点发现,Linux 无 JDT 故用源码文本匹配)。 + if view.run_configs.is_empty() { + view.run_configs = scan_java_mains(&root); + } + view.keep_run_selection(); + view.run_state = if view.run_configs.is_empty() { + RunProjectState::Missing + } else { + RunProjectState::Ready + }; + } + Err(err) => { + view.run_state = RunProjectState::Failed(err); + } + } + cx.notify(); + }); + }) + .detach(); + } + + /// 运行选中配置;运行中则改为停止。 + pub fn run_selected_config(&mut self, cx: &mut Context) { + if self.run_running { + self.stop_running(cx); + return; + } + let Some(item) = self.selected_run_item() else { + return; + }; + self.run_running = true; + push_run_line(&mut self.run_output, format!("开始运行:{}", item.name)); + cx.notify(); + let (tx, rx) = mpsc::channel::(); + let child_slot = self.run_child.clone(); + let client = self.client.clone(); + let root = self.working_dir.clone(); + cx.spawn(async move |this, cx| { + // 先问 core 要可执行计划(payload `{root, configurationId}`,返回 + // `{executable, arguments, workingDirectory}`);本地配置或工具链 + // 未解析时拿不到可直跑的命令,退化到按 kind 拼的本地命令。 + let plan = client + .execute::( + &cx, + "runConfig.createLaunchPlan", + serde_json::json!({ "root": root, "configurationId": item.id }), + ) + .await + .ok(); + let steps = plan_to_steps(plan.as_ref(), &item, &root); + let started = this.update(cx, |view, cx| { + if steps.is_empty() { + view.run_running = false; + push_run_line(&mut view.run_output, "无可执行步骤".to_string()); + cx.notify(); + return false; + } + let display = format!("$ {} {}", steps[0].program, steps[0].args.join(" ")); + push_run_line(&mut view.run_output, display.clone()); + view.record_run(&display, cx); + cx.notify(); + true + }); + if !matches!(started, Ok(true)) { + return; + } + std::thread::spawn(move || run_steps_blocking(steps, child_slot, tx)); + // 输出泵:每行经 background 线程阻塞收,再回到主线程落盘展示。 + let rx = Arc::new(Mutex::new(rx)); + loop { + let slot = rx.clone(); + let next = cx + .background_executor() + .spawn(async move { + let guard = slot.lock().ok()?; + guard.recv().ok() + }) + .await; + match next { + Some(line) => { + if this + .update(cx, |view, cx| { + push_run_line(&mut view.run_output, line); + cx.notify(); + }) + .is_err() + { + break; + } + } + None => break, + } + } + let _ = this.update(cx, |view, cx| { + view.run_running = false; + cx.notify(); + }); + }) + .detach(); + } + + /// 停止在跑进程(只 kill 直接子进程;mvn 拉起的 java 孙进程不在此列)。 + pub fn stop_running(&mut self, cx: &mut Context) { + if let Ok(mut slot) = self.run_child.lock() { + if let Some(child) = slot.as_mut() { + let _ = child.kill(); + } + } + push_run_line(&mut self.run_output, "已发送停止信号…".to_string()); + cx.notify(); + } + + /// 当前选中项(无选中或已失效时回退首项)。 + fn selected_run_item(&self) -> Option { + self.selected_run_config + .as_deref() + .and_then(|id| self.run_configs.iter().find(|c| c.id == id).cloned()) + .or_else(|| self.run_configs.first().cloned()) + } + + /// 保持选中有效:原选中仍在则不动,否则选首项。 + fn keep_run_selection(&mut self) { + let keep = self + .selected_run_config + .as_ref() + .is_some_and(|id| self.run_configs.iter().any(|c| &c.id == id)); + if !keep { + self.selected_run_config = self.run_configs.first().map(|c| c.id.clone()); + } + } + + /// Run 面板状态行文案。 + fn run_state_text(&self, cx: &gpui_kit::App) -> String { + let zh = crate::i18n::is_zh(cx); + match &self.run_state { + RunProjectState::Missing => { + if zh { + "无可运行配置".to_string() + } else { + "No runnable configurations".to_string() + } + } + RunProjectState::Loading => { + if zh { + "加载中…".to_string() + } else { + "Loading…".to_string() + } + } + RunProjectState::Ready => { + if self.run_running { + if zh { + "运行中".to_string() + } else { + "Running".to_string() + } + } else if zh { + format!("就绪 · {} 个配置", self.run_configs.len()) + } else { + format!("Ready · {} configurations", self.run_configs.len()) + } + } + RunProjectState::Failed(err) => { + if err.is_empty() { + if zh { + "加载失败".to_string() + } else { + "Failed to load".to_string() + } + } else { + err.clone() + } + } + } + } + /// 重新加载 Git 提交记录。core 的 `git.historyPage` 需经宿主异步接线, /// 这里用 `git -C log --oneline -50` 同步退化实现, /// 解析 hash + 首行 message 只读展示。 @@ -172,64 +475,229 @@ impl BottomPanelView { })) } - /// Run 面板:运行历史列表,每行重跑按钮经 terminal 重放命令。 + /// Run 面板:Tauri RunPane 子集——顶部状态行(工程名+状态+运行/停止+ + /// 重扫+清空)+ 配置列表(选中高亮)+ 输出区(monospace)。 fn render_run_panel(&self, cx: &mut Context) -> AnyElement { - if self.run_history.is_empty() { - return div() - .size_full() - .flex() - .items_center() - .justify_center() - .text_xs() - .text_color(ThemeColors::text_muted()) - .child(run_empty_text(cx)) - .into_any_element(); + if self.run_configs.is_empty() { + match &self.run_state { + RunProjectState::Loading => { + return run_center_text(run_ui_text(cx, "加载中…", "Loading…")); + } + RunProjectState::Failed(err) => { + let msg = if err.is_empty() { + run_ui_text(cx, "加载失败", "Failed to load") + } else { + err.clone() + }; + return v_flex() + .size_full() + .items_center() + .justify_center() + .gap_2() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(div().child(msg)) + .child( + Button::new("run-retry") + .small() + .ghost() + .label(run_ui_text(cx, "重试", "Retry")) + .on_click(cx.listener(|this, _event, _window, cx| { + this.reload_run_project(cx); + })), + ) + .into_any_element(); + } + _ => { + // Missing 空态 + 生成配置按钮。 + return v_flex() + .size_full() + .items_center() + .justify_center() + .gap_2() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(div().child(run_ui_text( + cx, + "暂无可运行配置", + "No runnable configurations", + ))) + .child( + Button::new("run-generate") + .small() + .primary() + .label(run_ui_text(cx, "生成配置", "Generate")) + .on_click(cx.listener(|this, _event, _window, cx| { + let root = this.working_dir.clone(); + let _ = append_default_run_config(&root); + this.reload_run_project(cx); + })), + ) + .into_any_element(); + } + } } + let running = self.run_running; + let run_label = if running { + run_ui_text(cx, "停止", "Stop") + } else { + run_ui_text(cx, "运行", "Run") + }; + let can_run = !matches!(self.run_state, RunProjectState::Loading); + let status = format!("{} · {}", self.run_project_name, self.run_state_text(cx)); + let rows: Vec = self - .run_history + .run_configs .iter() - .enumerate() - .map(|(idx, cmd)| { - let rerun = cmd.clone(); + .map(|item| { + let id = item.id.clone(); + let run_id = item.id.clone(); + let selected = self.selected_run_config.as_deref() == Some(item.id.as_str()); h_flex() - .id(format!("run-history-{idx}")) - .h(px(26.0)) + .id(format!("run-config-{}", item.id)) .w_full() .items_center() - .gap_2() - .px_3() + .gap_1p5() + .px_2() + .py_1() .rounded_sm() + .cursor_pointer() .text_xs() - .text_color(ThemeColors::text_primary()) - .hover(|h| h.bg(ThemeColors::bg_tab_hover())) + .when(selected, |row| { + row.bg(ThemeColors::bg_tab_hover()) + .text_color(ThemeColors::text_primary()) + }) + .when(!selected, |row| { + row.text_color(ThemeColors::text_muted()).hover(|h| { + h.bg(ThemeColors::bg_tab_hover()) + .text_color(ThemeColors::text_primary()) + }) + }) .child( - div() + v_flex() .flex_1() - .truncate() - .font_family("monospace") - .child(cmd.clone()), + .min_w_0() + .child(div().truncate().child(item.name.clone())) + .child( + div() + .truncate() + .font_family("monospace") + .child(format!("{} {}", item.kind, item.detail)), + ), ) .child( - Button::new(format!("run-rerun-{idx}")) + Button::new(format!("run-start-{}", item.id)) .small() .ghost() .icon(IconName::Play) + .tooltip(run_ui_text(cx, "运行", "Run")) .on_click(cx.listener(move |this, _event, _window, cx| { - let _ = - this.terminal.update(cx, |t, cx| t.send_command(&rerun, cx)); + this.selected_run_config = Some(run_id.clone()); + this.run_selected_config(cx); })), ) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.selected_run_config = Some(id.clone()); + cx.notify(); + })) .into_any_element() }) .collect(); - div() - .flex_1() - .w_full() - .overflow_y_scrollbar() - .py_1() - .children(rows) + let total = self.run_output.len(); + let start = total.saturating_sub(800); + let output: Vec = if self.run_output.is_empty() { + vec![div() + .text_color(ThemeColors::text_muted()) + .child(run_ui_text(cx, "暂无输出", "No output")) + .into_any_element()] + } else { + self.run_output[start..] + .iter() + .map(|line| div().child(line.clone()).into_any_element()) + .collect() + }; + + v_flex() + .size_full() + .child( + h_flex() + .flex_shrink_0() + .h(px(30.0)) + .w_full() + .items_center() + .gap_2() + .px_3() + .border_b_1() + .border_color(ThemeColors::border()) + .child( + div() + .flex_1() + .truncate() + .text_xs() + .text_color(ThemeColors::text_primary()) + .child(status), + ) + .child( + Button::new("run-toggle") + .small() + .primary() + .disabled(!can_run) + .label(run_label) + .on_click(cx.listener(|this, _event, _window, cx| { + this.run_selected_config(cx); + })), + ) + .child( + Button::new("run-rescan") + .small() + .ghost() + .disabled(running) + .label(run_ui_text(cx, "重扫", "Rescan")) + .on_click(cx.listener(|this, _event, _window, cx| { + this.reload_run_project(cx); + })), + ) + .child( + Button::new("run-clear-output") + .small() + .ghost() + .label(run_ui_text(cx, "清空输出", "Clear")) + .on_click(cx.listener(|this, _event, _window, cx| { + this.run_output.clear(); + cx.notify(); + })), + ), + ) + .child( + h_flex() + .flex_1() + .w_full() + .min_h_0() + .child( + div() + .flex_shrink_0() + .w(px(220.0)) + .h_full() + .overflow_y_scrollbar() + .border_r_1() + .border_color(ThemeColors::border()) + .py_1() + .children(rows), + ) + .child( + div() + .flex_1() + .h_full() + .overflow_y_scrollbar() + .p_2() + .font_family("monospace") + .text_xs() + .text_color(ThemeColors::text_primary()) + .children(output), + ), + ) .into_any_element() } @@ -386,6 +854,7 @@ impl Render for BottomPanelView { } BottomTab::Run => { this.run_history.clear(); + this.run_output.clear(); cx.notify(); } BottomTab::Diagnostics => { @@ -482,11 +951,501 @@ fn load_git_log(workdir: &str) -> Result, String> { Ok(entries) } -fn run_empty_text(cx: &gpui_kit::App) -> &'static str { +/// 读本地运行配置(`/.lithe/run/configurations.json`),兼容数组与 +/// `{configurations: []}` 两种形状;字段沿用 v1 形态(`name/type/mainClass`)。 +fn read_local_run_configs(workspace_root: &str) -> Vec { + let path = std::path::Path::new(workspace_root) + .join(".lithe") + .join("run") + .join("configurations.json"); + let Ok(text) = std::fs::read_to_string(&path) else { + return Vec::new(); + }; + let Ok(value) = serde_json::from_str::(&text) else { + return Vec::new(); + }; + let arr = if let Some(arr) = value.as_array() { + arr.clone() + } else if let Some(arr) = value.get("configurations").and_then(|v| v.as_array()) { + arr.clone() + } else { + return Vec::new(); + }; + arr.iter() + .filter_map(|item| { + let name = item.get("name")?.as_str()?; + let kind_raw = item.get("type").and_then(|t| t.as_str()).unwrap_or("java"); + let main_class = item + .get("mainClass") + .and_then(|m| m.as_str()) + .map(str::to_string); + let kind = if kind_raw.contains("npm") || kind_raw.contains("node") { + "npm" + } else if kind_raw.contains("maven") { + "maven" + } else { + "mainClass" + }; + let detail = main_class.clone().unwrap_or_else(|| kind_raw.to_string()); + Some(RunConfigItem { + id: name.to_string(), + name: name.to_string(), + kind: kind.to_string(), + detail, + main_class, + source: None, + }) + }) + .collect() +} + +/// 追加一个默认 Java 运行配置(保留已有配置),供 Missing 空态的生成按钮用。 +fn append_default_run_config(workspace_root: &str) -> bool { + let path = std::path::Path::new(workspace_root) + .join(".lithe") + .join("run") + .join("configurations.json"); + if let Some(parent) = path.parent() { + if std::fs::create_dir_all(parent).is_err() { + return false; + } + } + let mut list: Vec = std::fs::read_to_string(&path) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .and_then(|v| { + if let Some(arr) = v.as_array() { + Some(arr.clone()) + } else { + v.get("configurations").and_then(|c| c.as_array()).cloned() + } + }) + .unwrap_or_default(); + list.push(serde_json::json!({"name": "Run Main", "type": "java", "mainClass": "Main"})); + let value = serde_json::json!({"configurations": list}); + serde_json::to_string_pretty(&value) + .ok() + .and_then(|text| std::fs::write(&path, text).ok()) + .is_some() +} + +/// 解析 `runConfig.generate` 返回的 `{generated: {configurations[]}}`。 +/// 本地 Java 入口扫描:无 LSP 时的兜底(对齐 Tauri `discoverJavaEntrypoints`, +/// Linux 无 JDT 故用源码文本匹配 `public static void main`)。 +/// 主类由 `package` 声明 + 文件名推导;上限 20 个,按路径排序保证稳定。 +fn scan_java_mains(root: &str) -> Vec { + const MAX_MAINS: usize = 20; + const MAX_DEPTH: usize = 8; + const SKIP_DIRS: &[&str] = &[ + "target", + "build", + "out", + "dist", + "node_modules", + ".git", + ".idea", + ".vscode", + "vendor", + ]; + const MAX_FILE_BYTES: u64 = 512 * 1024; + + fn walk(dir: &std::path::Path, depth: usize, out: &mut Vec) { + if depth > MAX_DEPTH || out.len() >= MAX_MAINS * 4 { + return; + } + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + let mut entries: Vec<_> = entries.filter_map(|e| e.ok()).collect(); + entries.sort_by_key(|e| e.file_name()); + for entry in entries { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + if path.is_dir() { + if !name.starts_with('.') && !SKIP_DIRS.contains(&name.as_str()) { + walk(&path, depth + 1, out); + } + } else if name.ends_with(".java") { + out.push(path); + } + } + } + + fn package_of(text: &str) -> Option { + text.lines().find_map(|line| { + let line = line.trim(); + line.strip_prefix("package ") + .and_then(|rest| rest.strip_suffix(';')) + .map(|pkg| pkg.trim().to_string()) + .filter(|pkg| { + !pkg.is_empty() + && pkg + .chars() + .all(|c| c.is_alphanumeric() || c == '.' || c == '_') + }) + }) + } + + let root_path = std::path::Path::new(root); + let mut files = Vec::new(); + walk(root_path, 0, &mut files); + let mut items = Vec::new(); + for path in files { + if items.len() >= MAX_MAINS { + break; + } + if std::fs::metadata(&path).is_ok_and(|m| m.len() > MAX_FILE_BYTES) { + continue; + } + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + if !text.contains("public static void main") { + continue; + } + let stem = path + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + if stem.is_empty() { + continue; + } + let main_class = match package_of(&text) { + Some(pkg) => format!("{pkg}.{stem}"), + None => stem, + }; + let source = path + .strip_prefix(root_path) + .map(|p| p.to_string_lossy().replace('\\', "/")) + .unwrap_or_default(); + items.push(RunConfigItem { + id: format!("local-main:{main_class}"), + name: main_class.clone(), + kind: "mainClass".to_string(), + detail: main_class.clone(), + main_class: Some(main_class), + source: Some(source), + }); + } + items.sort_by(|a, b| a.name.cmp(&b.name)); + items +} + +fn parse_generated_configs(value: &serde_json::Value) -> Vec { + value + .get("generated") + .and_then(|g| g.get("configurations")) + .and_then(|c| c.as_array()) + .map(|arr| arr.iter().filter_map(config_item_from_generated).collect()) + .unwrap_or_default() +} + +fn config_item_from_generated(item: &serde_json::Value) -> Option { + let id = item.get("id")?.as_str()?; + let name = item.get("name").and_then(|n| n.as_str()).unwrap_or(id); + let provider = item.get("provider").and_then(|p| p.as_str()).unwrap_or(""); + let command = item.get("command").and_then(|c| c.as_str()).unwrap_or(""); + let maven = &item["extensions"]["maven"]; + let main_class = maven + .get("mainClass") + .and_then(|m| m.as_str()) + .map(str::to_string); + let module = maven + .get("module") + .and_then(|m| m.as_str()) + .map(str::to_string); + let source = item + .get("extensions") + .and_then(|e| e.get("java")) + .and_then(|j| j.get("source")) + .and_then(|s| s.as_str()) + .map(str::to_string); + let kind = if provider.contains("npm") || provider.contains("node") || command == "npm" { + "npm" + } else if provider.contains("maven") { + "maven" + } else { + "mainClass" + }; + let detail = if kind == "npm" { + item.get("args") + .and_then(|a| a.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(" ") + }) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "run dev".to_string()) + } else { + main_class + .clone() + .or(module.clone()) + .unwrap_or_else(|| provider.to_string()) + }; + Some(RunConfigItem { + id: id.to_string(), + name: name.to_string(), + kind: kind.to_string(), + detail, + main_class, + source, + }) +} + +/// 由 `createLaunchPlan` 结果拼直跑步骤:只有 `executable.command` +/// 是真实可执行路径;`executable.toolchain` 需宿主解析,拿不到就走退化。 +fn plan_to_steps( + plan: Option<&serde_json::Value>, + item: &RunConfigItem, + root: &str, +) -> Vec { + if let Some(plan) = plan { + let cwd = plan + .get("workingDirectory") + .and_then(|v| v.as_str()) + .unwrap_or("."); + let cwd = if cwd == "." || cwd.is_empty() { + root.to_string() + } else { + format!("{root}/{cwd}") + }; + if let Some(command) = plan + .get("executable") + .and_then(|e| e.get("command")) + .and_then(|c| c.as_str()) + .filter(|c| !c.is_empty()) + { + let args = plan + .get("arguments") + .and_then(|a| a.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + return vec![RunStep { + program: command.to_string(), + args, + cwd, + }]; + } + } + fallback_run_steps(item, root) +} + +/// 退化执行规则:maven 用 mvn 跑 `compile exec:java`;npm 跑 `run dev`; +/// java 单文件走 javac+java,否则直跑主类。 +fn fallback_run_steps(item: &RunConfigItem, root: &str) -> Vec { + match item.kind.as_str() { + "maven" => { + let mut args = vec!["-B".to_string(), "-ntp".to_string(), "compile".to_string()]; + if let Some(main) = item.main_class.as_deref().filter(|s| !s.is_empty()) { + args.push("exec:java".to_string()); + args.push(format!("-Dexec.mainClass={main}")); + } + vec![RunStep { + program: resolve_maven_executable(root), + args, + cwd: root.to_string(), + }] + } + "npm" => vec![RunStep { + program: "npm".to_string(), + args: vec!["run".to_string(), pick_npm_script(root)], + cwd: root.to_string(), + }], + _ => { + let classes = format!("{root}/.lithe/run/classes"); + if let (Some(source), Some(main)) = (item.source.as_deref(), item.main_class.as_deref()) + { + let abs = format!("{root}/{source}"); + if !source.is_empty() && !main.is_empty() && std::path::Path::new(&abs).is_file() { + let _ = std::fs::create_dir_all(&classes); + return vec![ + RunStep { + program: "javac".to_string(), + args: vec!["-d".to_string(), classes.clone(), abs], + cwd: root.to_string(), + }, + RunStep { + program: "java".to_string(), + args: vec!["-cp".to_string(), classes, main.to_string()], + cwd: root.to_string(), + }, + ]; + } + } + let main = item.main_class.clone().unwrap_or_else(|| item.name.clone()); + vec![RunStep { + program: "java".to_string(), + args: vec![main], + cwd: root.to_string(), + }] + } + } +} + +/// mvn 路径:先看 `.lithe/run/local.json` 的 `mavenExecutablePath` +///(含 `toolchain` 下),没有就用 `PATH` 里的 `mvn`。 +fn resolve_maven_executable(root: &str) -> String { + let path = std::path::Path::new(root) + .join(".lithe") + .join("run") + .join("local.json"); + let from_file = std::fs::read_to_string(&path) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .and_then(|value| { + value + .get("mavenExecutablePath") + .or_else(|| { + value + .get("toolchain") + .and_then(|t| t.get("mavenExecutablePath")) + }) + .and_then(|v| v.as_str()) + .map(str::to_string) + }) + .filter(|s| !s.trim().is_empty()); + from_file.unwrap_or_else(|| "mvn".to_string()) +} + +/// npm 脚本选择:`package.json` 有 dev 用 dev,否则 start,再没有还用 dev(报错由输出展示)。 +fn pick_npm_script(root: &str) -> String { + let has = |name: &str, value: &serde_json::Value| { + value.get("scripts").and_then(|s| s.get(name)).is_some() + }; + std::fs::read_to_string(std::path::Path::new(root).join("package.json")) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .map(|value| { + if has("dev", &value) { + "dev".to_string() + } else if has("start", &value) { + "start".to_string() + } else { + "dev".to_string() + } + }) + .unwrap_or_else(|| "dev".to_string()) +} + +/// 顺序跑步骤并把输出行推入 channel;首个失败步骤后停。stdout/stderr 各一根 +/// 转发线程;子进程句柄共享给停止按钮,`try_wait` 短锁轮询避免 `wait` +/// 占锁导致 kill 拿不到锁。 +fn run_steps_blocking( + steps: Vec, + child_slot: Arc>>, + tx: mpsc::Sender, +) { + use std::io::BufRead as _; + for step in &steps { + let _ = tx.send(format!("$ {} {}", step.program, step.args.join(" "))); + let mut child = match std::process::Command::new(&step.program) + .args(&step.args) + .current_dir(&step.cwd) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + { + Ok(child) => child, + Err(err) => { + let _ = tx.send(format!("启动失败:{err}")); + return; + } + }; + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + if let Ok(mut slot) = child_slot.lock() { + *slot = Some(child); + } + let tx_out = tx.clone(); + let out_handle = stdout.map(|out| { + std::thread::spawn(move || { + for line in std::io::BufReader::new(out).lines().map_while(Result::ok) { + if tx_out.send(line).is_err() { + break; + } + } + }) + }); + let tx_err = tx.clone(); + let err_handle = stderr.map(|err| { + std::thread::spawn(move || { + for line in std::io::BufReader::new(err).lines().map_while(Result::ok) { + if tx_err.send(line).is_err() { + break; + } + } + }) + }); + let status = loop { + let Ok(mut slot) = child_slot.lock() else { + break None; + }; + match slot.as_mut().map(|child| child.try_wait()) { + Some(Ok(Some(status))) => break Some(status), + Some(Ok(None)) => { + drop(slot); + std::thread::sleep(std::time::Duration::from_millis(50)); + } + _ => break None, + } + }; + if let Some(handle) = out_handle { + let _ = handle.join(); + } + if let Some(handle) = err_handle { + let _ = handle.join(); + } + if let Ok(mut slot) = child_slot.lock() { + let _ = slot.take(); + } + match status { + Some(status) if status.success() => { + let _ = tx.send("退出码:0".to_string()); + } + Some(status) => { + let _ = tx.send(format!("进程已退出:{status}")); + return; + } + None => { + let _ = tx.send("运行已结束".to_string()); + return; + } + } + } +} + +/// 输出行入列(超限丢最旧)。 +fn push_run_line(output: &mut Vec, line: String) { + output.push(line); + if output.len() > MAX_RUN_OUTPUT { + let overflow = output.len() - MAX_RUN_OUTPUT; + output.drain(..overflow); + } +} + +fn run_center_text(text: String) -> AnyElement { + div() + .size_full() + .flex() + .items_center() + .justify_center() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(text) + .into_any_element() +} + +fn run_ui_text(cx: &gpui_kit::App, zh: &str, en: &str) -> String { if crate::i18n::is_zh(cx) { - "暂无运行历史" + zh.to_string() } else { - "No run history" + en.to_string() } } diff --git a/linux/src/workbench/maven.rs b/linux/src/workbench/maven.rs index c0dc017d9..ce6ea2333 100644 --- a/linux/src/workbench/maven.rs +++ b/linux/src/workbench/maven.rs @@ -1,21 +1,28 @@ -//! 右侧 Maven 导航工具窗口:项目树 + 生命周期阶段,对齐 Tauri +//! 右侧 Maven 导航工具窗口:项目树 + 生命周期阶段 + 依赖 + 自定义 goal,对齐 Tauri //! `windows/tauri/src/features/maven/` 的导航语义。 //! -//! 本面板只负责导航展示与事件发射:双击/点击阶段行发射 -//! [`MavenEvent::RunGoal`],实际命令执行由外部经底部 Terminal 完成。 +//! 本面板只负责导航展示与事件发射:点击阶段行 / 自定义 goal 执行按钮发射 +//! [`MavenEvent::RunGoal`],实际命令执行由外部经底部 Terminal 完成(`view.rs` +//! 拼出 `mvn -f "" `)。工程结构(`maven.scan`)与依赖加载 +//! (`maven.dependencyPlan` + `maven.dependencies`)经 [`CoreClient`] 在 +//! `cx.spawn` 里异步调用 core 命令;本地 pom 发现([`find_pom_files`])先行, +//! core 失败只落工程/依赖状态,不阻塞导航展示。 use std::collections::HashSet; use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::input::{Input, InputEvent, InputState}; use gpui_kit::component::scroll::ScrollableElement as _; -use gpui_kit::component::{h_flex, v_flex, Icon, Sizable as _}; +use gpui_kit::component::{h_flex, v_flex, Disableable as _, Icon, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, Context, EventEmitter, FontWeight, InteractiveElement as _, IntoElement, - ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, Window, + div, px, AppContext as _, Context, Entity, EventEmitter, FontWeight, InteractiveElement as _, + IntoElement, ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, + Subscription, Window, }; +use crate::core::CoreClient; use crate::theme::ThemeColors; /// 对齐 Tauri `MAVEN_LIFECYCLE_PHASES` 的 Maven 生命周期阶段。 @@ -81,11 +88,23 @@ fn lifecycle_key(pom: &str) -> String { format!("{pom}#lifecycle") } +/// 依赖组的展开键。 +fn deps_key(pom: &str) -> String { + format!("{pom}#deps") +} + /// phase 行的稳定节点 id,同时用作选中键。 fn phase_id(pom: &str, phase: &str) -> String { format!("{pom}#lifecycle#{phase}") } +/// 从选中行 id 反推所属 pom:只有 phase 行可反推,其余返回空。 +fn selected_pom(selected: &Option) -> Option { + selected + .as_ref() + .and_then(|id| id.split_once("#lifecycle#").map(|(pom, _)| pom.to_string())) +} + /// pom 行的显示路径:相对 root,根 pom 显示 `pom.xml`。 fn display_pom_path(root: &str, pom: &str) -> String { std::path::Path::new(pom) @@ -101,6 +120,81 @@ fn display_pom_path(root: &str, pom: &str) -> String { .unwrap_or_else(|_| pom.to_string()) } +/// 传给 `maven.scan` 的 workspace 相对 pom 路径:core 只接受相对路径, +/// 绝对路径会被 core 侧忽略(扫描退化为无工程,不报错)。 +fn relative_pom_paths(root: &str, poms: &[String]) -> Vec { + poms.iter() + .map(|pom| { + std::path::Path::new(pom) + .strip_prefix(root) + .map(|rel| rel.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|_| pom.clone()) + }) + .collect() +} + +/// pom 所在目录相对 root 的模块路径:根 pom 为 `.`,与 core 的模块路径约定一致。 +fn module_path_for_pom(root: &str, pom: &str) -> String { + let relative = std::path::Path::new(pom) + .parent() + .and_then(|dir| dir.strip_prefix(root).ok()) + .map(|rel| rel.to_string_lossy().replace('\\', "/")) + .unwrap_or_default(); + let trimmed = relative.trim_matches('/').to_string(); + if trimmed.is_empty() { + ".".to_string() + } else { + trimmed + } +} + +/// 对齐 Tauri `projectStatus` 的工程状态:`idle` / `loading` / `ready` / `failed`。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MavenStatus { + Idle, + Loading, + Ready, + Failed(String), +} + +/// 依赖列表行的最小展示字段,来自 `maven.dependencies` 返回的 +/// `dependencies[]`(`groupId` / `artifactId` / `version` / `scope`)。 +#[derive(Debug, Clone)] +pub struct MavenDep { + pub group: String, + pub artifact: String, + pub version: String, + pub scope: String, +} + +/// 从 `maven.dependencies` 的返回体解析依赖列表,缺失字段的行直接丢弃。 +fn parse_deps(data: &serde_json::Value) -> Vec { + data.get("dependencies") + .and_then(|value| value.as_array()) + .map(|items| { + items + .iter() + .filter_map(|item| { + Some(MavenDep { + group: item.get("groupId")?.as_str()?.to_string(), + artifact: item.get("artifactId")?.as_str()?.to_string(), + version: item + .get("version") + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_string(), + scope: item + .get("scope") + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_string(), + }) + }) + .collect() + }) + .unwrap_or_default() +} + /// Maven 导航面板派发的事件:执行只发射,由外部经底部 Terminal 执行。 #[derive(Debug, Clone)] pub enum MavenEvent { @@ -108,37 +202,64 @@ pub enum MavenEvent { Close, } -/// 右侧 Maven 导航工具窗口:项目树 + 生命周期阶段。 +/// 右侧 Maven 导航工具窗口:项目树 + 生命周期阶段 + 依赖 + 自定义 goal。 pub struct MavenView { pub root: String, poms: Vec, expanded: HashSet, selected: Option, + project_status: MavenStatus, + project_name: Option, + dependencies: Vec, + dep_status: MavenStatus, + /// 一次只展开一个模块的依赖组,存 pom 绝对路径。 + expanded_deps: Option, + custom_goal: String, + goal_input: Option>, + _goal_subscription: Option, + client: CoreClient, + scan_seq: u64, + dep_seq: u64, } impl EventEmitter for MavenView {} impl MavenView { - pub fn new(root: String, _cx: &mut Context) -> Self { + pub fn new(root: String, cx: &mut Context) -> Self { let mut view = Self { root, poms: Vec::new(), expanded: HashSet::new(), selected: None, + project_status: MavenStatus::Idle, + project_name: None, + dependencies: Vec::new(), + dep_status: MavenStatus::Idle, + expanded_deps: None, + custom_goal: String::new(), + goal_input: None, + _goal_subscription: None, + client: CoreClient::new(), + scan_seq: 0, + dep_seq: 0, }; - view.rescan(); + view.reload(cx); view } pub fn set_root(&mut self, root: String, cx: &mut Context) { self.root = root; - self.rescan(); - cx.notify(); + self.expanded_deps = None; + self.dependencies.clear(); + self.dep_status = MavenStatus::Idle; + self.reload(cx); } pub fn refresh(&mut self, cx: &mut Context) { - self.rescan(); - cx.notify(); + self.expanded_deps = None; + self.dependencies.clear(); + self.dep_status = MavenStatus::Idle; + self.reload(cx); } pub fn has_projects(&self) -> bool { @@ -155,23 +276,183 @@ impl MavenView { } } + /// 本地重扫 + core `maven.scan` 拿模块结构: + /// payload `{root, paths}`(相对 pom 路径),返回 `MavenScanResponse` + ///(`relativePath` / `artifactId` / `modules` / `profiles` …)或 `null` + ///(未检出工程)。失败只落 `project_status`,导航照常展示本地 pom。 + fn reload(&mut self, cx: &mut Context) { + self.rescan(); + self.project_status = MavenStatus::Loading; + self.scan_seq += 1; + let seq = self.scan_seq; + cx.notify(); + + let client = self.client.clone(); + let root = self.root.clone(); + let paths = relative_pom_paths(&self.root, &self.poms); + cx.spawn(async move |this, cx| { + let payload = serde_json::json!({ "root": root, "paths": paths }); + let result = client + .execute::(&cx, "maven.scan", payload) + .await; + let _ = this.update(cx, |view, cx| { + if view.scan_seq != seq { + return; + } + match result { + Ok(data) => { + view.project_name = data + .get("artifactId") + .and_then(|value| value.as_str()) + .map(str::to_string); + view.project_status = MavenStatus::Ready; + } + Err(err) => { + view.project_name = None; + view.project_status = MavenStatus::Failed(err); + } + } + cx.notify(); + }); + }) + .detach(); + } + fn toggle_node(&mut self, key: &str, cx: &mut Context) { if !self.expanded.remove(key) { self.expanded.insert(key.to_string()); } cx.notify(); } + + /// 依赖组一次只展开一个:收起直接关,展开则触发依赖加载。 + fn toggle_deps(&mut self, pom: &str, cx: &mut Context) { + if self.expanded_deps.as_deref() == Some(pom) { + self.expanded_deps = None; + cx.notify(); + } else { + self.expanded_deps = Some(pom.to_string()); + self.load_deps(pom, cx); + } + } + + /// 先 `maven.dependencyPlan` 校验模块并拿出确定的离线调用计划 + /// (payload `{root, context: {version: 1, reactorPath: "."}, module}`, + /// 返回 `MavenLaunchPlanResponse`),再 `maven.dependencies` + /// (payload `{modulePath, output}`,返回 `{modulePath, dependencies[]}`) + /// 解析依赖列表。Linux 侧无 Maven 进程宿主,`output` 为空即返回空列表; + /// 真实树需在终端运行 `dependency:tree`,失败文案直接展示。 + fn load_deps(&mut self, pom: &str, cx: &mut Context) { + self.dep_seq += 1; + let seq = self.dep_seq; + self.dep_status = MavenStatus::Loading; + self.dependencies.clear(); + cx.notify(); + + let client = self.client.clone(); + let root = self.root.clone(); + let module = module_path_for_pom(&self.root, pom); + cx.spawn(async move |this, cx| { + let plan = client + .execute::( + &cx, + "maven.dependencyPlan", + serde_json::json!({ + "root": root, + "context": { "version": 1, "reactorPath": "." }, + "module": module, + }), + ) + .await; + let outcome: Result, String> = match plan { + Ok(_) => match client + .execute::( + &cx, + "maven.dependencies", + serde_json::json!({ "modulePath": module, "output": "" }), + ) + .await + { + Ok(data) => Ok(parse_deps(&data)), + Err(err) => Err(err), + }, + Err(err) => Err(err), + }; + let _ = this.update(cx, |view, cx| { + if view.dep_seq != seq { + return; + } + match outcome { + Ok(deps) => { + view.dependencies = deps; + view.dep_status = MavenStatus::Ready; + } + Err(err) => { + view.dep_status = MavenStatus::Failed(err); + } + } + cx.notify(); + }); + }) + .detach(); + } + + /// 懒创建自定义 goal 输入框(参考 `settings_dialog.rs::ensure_input`)。 + fn ensure_goal_input( + slot: &mut Option>, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + if let Some(entity) = slot.clone() { + return entity; + } + let entity = cx.new(|cx| InputState::new(window, cx)); + *slot = Some(entity.clone()); + entity + } } impl Render for MavenView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // 自定义 goal 输入框懒创建 + 一次性订阅 Change 事件,值同步到 custom_goal。 + let goal_entity = Self::ensure_goal_input(&mut self.goal_input, window, cx); + if self._goal_subscription.is_none() { + let entity = goal_entity.clone(); + self._goal_subscription = Some(cx.subscribe( + &goal_entity, + move |this: &mut Self, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + this.custom_goal = entity.read(cx).value().to_string(); + } + }, + )); + } + // 快照后构建行,避免在 `.children` 闭包里同时借用 self 与 cx。 let root = self.root.clone(); let poms = self.poms.clone(); let expanded = self.expanded.clone(); let selected = self.selected.clone(); + let project_status = self.project_status.clone(); + let project_name = self.project_name.clone(); + let expanded_deps = self.expanded_deps.clone(); + let dep_status = self.dep_status.clone(); + let dependencies = self.dependencies.clone(); + let custom_goal = self.custom_goal.clone(); let has_projects = !poms.is_empty(); + let title_text = match &project_name { + Some(name) => format!("{} · {name}", crate::i18n::menu_text(cx, "maven.title")), + None => crate::i18n::menu_text(cx, "maven.title").to_string(), + }; + let status_banner: Option<(bool, String)> = match &project_status { + MavenStatus::Loading => Some((false, "正在扫描 Maven 项目…".to_string())), + MavenStatus::Failed(err) => Some((true, err.clone())), + MavenStatus::Idle | MavenStatus::Ready => None, + }; + // 自定义 goal 的执行目标:选中 phase 行归属的 pom,否则首个 pom。 + let goal_target = selected_pom(&selected).or_else(|| poms.first().cloned()); + let mut rows = Vec::new(); for pom in &poms { let pom_expanded = expanded.contains(pom); @@ -263,56 +544,164 @@ impl Render for MavenView { })) .into_any_element(), ); - if !group_expanded { - continue; + if group_expanded { + for phase in MAVEN_LIFECYCLE_PHASES { + let row_id = phase_id(pom, phase); + let is_selected = selected.as_deref() == Some(row_id.as_str()); + rows.push( + h_flex() + .id(row_id.clone()) + .h(px(24.0)) + .w_full() + .items_center() + .cursor_pointer() + .rounded_sm() + .pl(px(30.0)) + .pr_2() + .gap_1p5() + .text_xs() + .when(is_selected, |row| { + row.bg(ThemeColors::subtle_selection()) + .text_color(ThemeColors::text_primary()) + }) + .when(!is_selected, |row| { + row.text_color(ThemeColors::text_primary()) + .hover(|h| h.bg(ThemeColors::bg_tab_hover())) + }) + .child( + Icon::new(IconName::Play) + .size(px(12.0)) + .text_color(ThemeColors::text_muted()), + ) + .child(div().flex_1().truncate().child(phase)) + .on_click(cx.listener({ + let pom = pom.clone(); + let phase = phase.to_string(); + move |this, _event, _window, cx| { + this.selected = Some(phase_id(&pom, &phase)); + cx.emit(MavenEvent::RunGoal { + pom_path: pom.clone(), + phase: phase.clone(), + }); + cx.notify(); + } + })) + .into_any_element(), + ); + } } - for phase in MAVEN_LIFECYCLE_PHASES { - let row_id = phase_id(pom, phase); - let is_selected = selected.as_deref() == Some(row_id.as_str()); - rows.push( - h_flex() - .id(row_id.clone()) - .h(px(24.0)) - .w_full() - .items_center() - .cursor_pointer() - .rounded_sm() - .pl(px(30.0)) - .pr_2() - .gap_1p5() - .text_xs() - .when(is_selected, |row| { - row.bg(ThemeColors::subtle_selection()) - .text_color(ThemeColors::text_primary()) - }) - .when(!is_selected, |row| { - row.text_color(ThemeColors::text_primary()) - .hover(|h| h.bg(ThemeColors::bg_tab_hover())) - }) - .child( - Icon::new(IconName::Play) - .size(px(12.0)) - .text_color(ThemeColors::text_muted()), - ) - .child(div().flex_1().truncate().child(phase)) - .on_click(cx.listener({ - let pom = pom.clone(); - let phase = phase.to_string(); - move |this, _event, _window, cx| { - this.selected = Some(phase_id(&pom, &phase)); - cx.emit(MavenEvent::RunGoal { - pom_path: pom.clone(), - phase: phase.clone(), - }); - cx.notify(); - } - })) - .into_any_element(), - ); + // 依赖组:一次只展开一个模块,点击触发 dependencyPlan + dependencies。 + let deps_open = expanded_deps.as_deref() == Some(pom.as_str()); + let deps_chevron = if deps_open { + IconName::ChevronDown + } else { + IconName::ChevronRight + }; + rows.push( + h_flex() + .id(deps_key(pom)) + .h(px(24.0)) + .w_full() + .items_center() + .cursor_pointer() + .rounded_sm() + .pl(px(18.0)) + .pr_2() + .gap_1p5() + .text_xs() + .text_color(ThemeColors::text_primary()) + .hover(|h| h.bg(ThemeColors::bg_tab_hover())) + .child( + Icon::new(deps_chevron) + .size(px(12.0)) + .text_color(ThemeColors::text_muted()), + ) + .child( + Icon::new(IconName::Package) + .size(px(14.0)) + .text_color(ThemeColors::accent_blue()), + ) + .child(div().flex_1().truncate().child("依赖")) + .on_click(cx.listener({ + let pom = pom.clone(); + move |this, _event, _window, cx| { + this.toggle_deps(&pom, cx); + } + })) + .into_any_element(), + ); + if deps_open { + match &dep_status { + MavenStatus::Loading => { + rows.push( + div() + .w_full() + .pl(px(30.0)) + .pr_2() + .py_1() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child("正在加载依赖…") + .into_any_element(), + ); + } + MavenStatus::Failed(err) => { + rows.push( + div() + .w_full() + .pl(px(30.0)) + .pr_2() + .py_1() + .text_xs() + .text_color(ThemeColors::accent_red()) + .child(err.clone()) + .into_any_element(), + ); + } + MavenStatus::Idle | MavenStatus::Ready if dependencies.is_empty() => { + rows.push( + div() + .w_full() + .pl(px(30.0)) + .pr_2() + .py_1() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child("暂无依赖数据") + .into_any_element(), + ); + } + MavenStatus::Idle | MavenStatus::Ready => { + for (index, dep) in dependencies.iter().enumerate() { + let label = format!( + "{}:{}:{} [{}]", + dep.group, dep.artifact, dep.version, dep.scope + ); + rows.push( + h_flex() + .id(format!("{pom}#dep#{index}")) + .w_full() + .items_center() + .rounded_sm() + .pl(px(30.0)) + .pr_2() + .py_1() + .gap_1p5() + .text_xs() + .text_color(ThemeColors::text_primary()) + .hover(|h| h.bg(ThemeColors::bg_tab_hover())) + .child(div().flex_1().truncate().child(label)) + .into_any_element(), + ); + } + } + } } } + let goal_entity_for_run = goal_entity.clone(); + let goal_target_for_run = goal_target.clone(); v_flex() .size_full() .bg(ThemeColors::bg_sidebar()) @@ -333,7 +722,7 @@ impl Render for MavenView { .text_xs() .font_weight(FontWeight::BOLD) .text_color(ThemeColors::text_muted()) - .child(crate::i18n::menu_text(cx, "maven.title")), + .child(title_text), ) .child( h_flex() @@ -361,6 +750,23 @@ impl Render for MavenView { ), ), ) + .when_some(status_banner, |this, (is_error, text)| { + this.child( + div() + .w_full() + .px_3() + .py_1p5() + .text_xs() + .border_b_1() + .border_color(ThemeColors::border()) + .text_color(if is_error { + ThemeColors::accent_red() + } else { + ThemeColors::text_muted() + }) + .child(text), + ) + }) .child(if has_projects { div() .flex_1() @@ -380,5 +786,42 @@ impl Render for MavenView { .child(crate::i18n::menu_text(cx, "maven.noProjects")) .into_any_element() }) + .child( + h_flex() + .w_full() + .items_center() + .gap_1p5() + .p_2() + .border_t_1() + .border_color(ThemeColors::border()) + .child( + div() + .flex_1() + .child(Input::new(&goal_entity).cleanable(true)), + ) + .child( + Button::new("maven-run-goal") + .small() + .primary() + .icon(IconName::Play) + .tooltip("执行自定义 goal") + .disabled( + custom_goal.trim().is_empty() || goal_target_for_run.is_none(), + ) + .on_click(cx.listener(move |_this, _event, _window, cx| { + let goal = goal_entity_for_run.read(cx).value().to_string(); + let goal = goal.trim().to_string(); + if goal.is_empty() { + return; + } + if let Some(pom) = goal_target_for_run.clone() { + cx.emit(MavenEvent::RunGoal { + pom_path: pom, + phase: goal, + }); + } + })), + ), + ) } } diff --git a/linux/src/workbench/settings_dialog.rs b/linux/src/workbench/settings_dialog.rs index 2f1ee2d7c..8342ceac5 100644 --- a/linux/src/workbench/settings_dialog.rs +++ b/linux/src/workbench/settings_dialog.rs @@ -1863,14 +1863,86 @@ impl SettingsDialog { .child( self.render_group( crate::i18n::menu_text(cx, "settings.mac.detectedServers").to_string(), - self.render_note( - crate::i18n::menu_text(cx, "settings.mac.detectedServersDescription") - .to_string(), - ), + v_flex() + .w_full() + .gap_3() + .child( + self.render_note( + crate::i18n::menu_text( + cx, + "settings.mac.detectedServersDescription", + ) + .to_string(), + ), + ) + .children(Self::detected_language_servers().into_iter().map( + |(name, path)| { + self.render_row( + name.to_string(), + None, + self.render_value(path.unwrap_or_else(|| "未安装".to_string())), + ) + }, + )) + .child(self.render_note(Self::lsp_status_note())), ), ) } + /// PATH 中探测语言服务器可执行文件,`OnceLock` 缓存避免每次渲染重复遍历。 + /// 只做存在性展示,不触发任何启动。 + fn detected_language_servers() -> Vec<(&'static str, Option)> { + static CACHE: std::sync::OnceLock)>> = + std::sync::OnceLock::new(); + CACHE + .get_or_init(|| { + ["jdtls", "clangd", "rust-analyzer", "pyright"] + .into_iter() + .map(|name| (name, Self::find_in_path(name))) + .collect() + }) + .clone() + } + + /// 在 `PATH` 中查找可执行文件,返回完整路径。 + fn find_in_path(name: &str) -> Option { + let path = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path) { + let candidate = dir.join(name); + if !candidate.is_file() { + continue; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let executable = std::fs::metadata(&candidate) + .map(|m| m.permissions().mode() & 0o111 != 0) + .unwrap_or(false); + if !executable { + continue; + } + } + return Some(candidate.to_string_lossy().into_owned()); + } + None + } + + /// 诚实状态说明:JDTLS 缺失则不伪造启动,仅说明缺失项。 + fn lsp_status_note() -> String { + let servers = Self::detected_language_servers(); + let jdtls_missing = servers + .iter() + .any(|(name, path)| *name == "jdtls" && path.is_none()); + if jdtls_missing { + "JDTLS 未安装:lsp.startServer 要求 executablePath/rootUri/workingDirectory,\ + Java 还需 jdtlsLaunchResources(launcher jar/配置目录/lombok),本机均缺失,\ + 故不启动;编辑器暂无诊断/悬停/补全接入。" + .to_string() + } else { + "已检测到服务器但尚未接入:编辑器暂无诊断/悬停/补全客户端,未自动启动。".to_string() + } + } + /// AI 聊天与编辑:提供商/模型/自动补全开关,全部落盘 ///(对齐 Tauri `AISettings` 的 Lithe Agent 分组)。 fn render_ai_content( diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index 9fc7731fe..c050cc113 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -905,8 +905,8 @@ impl WorkbenchView { let _ = self.maven.update(cx, |m, cx| { m.set_root(path.clone(), cx); }); - let _ = self.bottom_panel.update(cx, |bp, _cx| { - bp.working_dir = path.clone(); + let _ = self.bottom_panel.update(cx, |bp, cx| { + bp.set_working_dir(path.clone(), cx); }); // 右侧通知中心投递项目打开事件(对齐 Tauri 系统事件通知)。 let _ = self.notifications.update(cx, |n, cx| { From 97ad639ebebbd9324c1f027aa523d5d121c4a2e1 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 00:14:46 +0800 Subject: [PATCH 031/109] =?UTF-8?q?fix(linux):=20=E5=8F=B3=E4=BE=A7?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=AA=97=E5=8F=AF=E5=B7=A6=E5=8F=B3=E6=8B=96?= =?UTF-8?q?=E5=8A=A8=E7=BC=A9=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 主工作区编辑区+右侧改入 h_resizable workbench-center - 右侧 size_range 240-600,初值读 right_tool_window_width - on_resize 写回设置,隐藏时单面板无手柄 --- linux/src/workbench/view.rs | 87 +++++++++++++++++++++++++------------ 1 file changed, 59 insertions(+), 28 deletions(-) diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index c050cc113..8981d4504 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -3,6 +3,7 @@ //! 结构对齐 Tauri `MainLayout`:顶栏 / 多项目标签条 / 活动栏 + 侧边栏 + 编辑区 + //! 右侧插件活动栏 / 底部面板 / 状态栏;无项目时显示欢迎页,浮层由模态状态控制。 +use gpui_kit::component::resizable::{h_resizable, resizable_panel}; use gpui_kit::component::{h_flex, v_flex}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ @@ -20,7 +21,7 @@ use crate::workbench::activity_rail::{ use crate::workbench::bottom_panel::{BottomPanelView, BottomTab}; use crate::workbench::branch_manager::{BranchManagerEvent, BranchManagerView}; use crate::workbench::command_palette::{CommandPaletteEvent, CommandPaletteModal}; -use crate::workbench::editor::EditorView; +use crate::workbench::editor::{EditorTabEvent, EditorView}; use crate::workbench::extensions_panel::{ExtensionsEvent, ExtensionsView}; use crate::workbench::go_to_line::{GoToLineEvent, GoToLineModal}; use crate::workbench::maven::{MavenEvent, MavenView}; @@ -577,6 +578,17 @@ impl WorkbenchView { }, ); + // 8b. 订阅编辑器标签页事件(在终端中打开) + let sub_editor_tab = + cx.subscribe( + &editor, + move |this, _ed, event: &EditorTabEvent, cx| match event { + EditorTabEvent::OpenInTerminal { dir } => { + this.send_terminal_command(&format!("cd \"{dir}\""), cx); + } + }, + ); + // 9. 订阅设置对话框事件 let sub_settings = cx.subscribe( &settings_dialog, @@ -752,6 +764,7 @@ impl WorkbenchView { sub_quick_open, sub_go_to_line, sub_command_palette, + sub_editor_tab, sub_settings, sub_project_dialog, sub_branch_manager, @@ -1690,7 +1703,50 @@ impl WorkbenchView { fn render_workbench(&self, show_status_bar: bool, cx: &mut Context) -> impl IntoElement { let bottom_visible = self.bottom_panel.read(cx).is_visible(); let bottom_splitter = self.render_bottom_splitter(cx); - let right_width = settings::get(cx).right_tool_window_width; + let right_width = settings::get(cx) + .right_tool_window_width + .clamp(240.0, 600.0); + let right_panel = self.right_tool.map(|tool| match tool { + RightToolView::Notifications => self.notifications.clone().into_any_element(), + RightToolView::Maven => self.maven.clone().into_any_element(), + RightToolView::Extensions => self.extensions.clone().into_any_element(), + }); + let mut center = h_resizable("workbench-center") + .on_resize(|state, _, cx| { + if state.read(cx).sizes().len() < 2 { + return; + } + if let Some(width) = state.read(cx).sizes().last() { + let w = width.as_f32().clamp(240.0, 600.0); + settings::update(cx, |s| s.right_tool_window_width = w); + } + }) + .child( + resizable_panel().child( + div() + .flex_1() + .h_full() + .min_w_0() + .bg(ThemeColors::background()) + .child(self.editor.clone()), + ), + ); + if let Some(panel) = right_panel { + center = center.child( + resizable_panel() + .size(px(right_width)) + .size_range(px(240.0)..px(600.0)) + .flex_none() + .child( + div() + .h_full() + .w_full() + .border_l_1() + .border_color(ThemeColors::border()) + .child(panel), + ), + ); + } v_flex() .size_full() .bg(ThemeColors::background()) @@ -1713,32 +1769,7 @@ impl WorkbenchView { ) .child(self.render_sidebar_splitter(cx)) }) - .child( - div() - .flex_1() - .h_full() - .min_w_0() - .bg(ThemeColors::background()) - .child(self.editor.clone()), - ) - .when_some(self.right_tool, |layout, tool| { - let panel = match tool { - RightToolView::Notifications => { - self.notifications.clone().into_any_element() - } - RightToolView::Maven => self.maven.clone().into_any_element(), - RightToolView::Extensions => self.extensions.clone().into_any_element(), - }; - layout.child( - div() - .w(px(right_width)) - .h_full() - .flex_shrink_0() - .border_l_1() - .border_color(ThemeColors::border()) - .child(panel), - ) - }) + .child(div().flex_1().h_full().min_w_0().child(center)) .child(self.plugin_rail.clone()), ) .when(bottom_visible, |layout| layout.child(bottom_splitter)) From 18c88ac9838e435d3383266cacdc90023865ef19 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 00:16:31 +0800 Subject: [PATCH 032/109] =?UTF-8?q?feat(linux):=20=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E6=A0=87=E7=AD=BE=E9=A1=B5=E5=8F=B3=E9=94=AE=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=EF=BC=8C=E5=AF=B9=E9=BD=90=20Tauri=20TabContextMenu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 关闭/关闭其他/关闭到右侧/关闭全部(指定索引版方法) - 复制路径/相对路径/文件管理器显示/终端打开/重新加载 - 固定/拆分/锁定组暂不适用(无分屏与固定态) --- linux/src/i18n.rs | 10 ++ linux/src/workbench/editor.rs | 119 ++++++++++++++++++- linux/src/workbench/mod.rs | 1 + linux/src/workbench/tab_menu.rs | 197 ++++++++++++++++++++++++++++++++ 4 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 linux/src/workbench/tab_menu.rs diff --git a/linux/src/i18n.rs b/linux/src/i18n.rs index 15572e002..145231428 100644 --- a/linux/src/i18n.rs +++ b/linux/src/i18n.rs @@ -214,6 +214,16 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { "quickOpen.fileCountOne" => ("1 个文件", "1 file"), "commandPalette.placeholder" => ("输入命令...", "Type a command..."), "search.noResults" => ("未找到结果", "No results found"), + // ---- 标签页右键菜单 / 文件上下文菜单(与 Tauri locale.ts tabs.* / files.* 对齐) ---- + "tabs.close" => ("关闭", "Close"), + "tabs.closeOthers" => ("关闭其他", "Close Others"), + "tabs.closeToRight" => ("关闭右侧", "Close to Right"), + "tabs.closeAll" => ("全部关闭", "Close All"), + "tabs.reload" => ("重新加载", "Reload"), + "files.copyPath" => ("复制路径", "Copy Path"), + "files.copyRelativePath" => ("复制相对路径", "Copy Relative Path"), + "files.reveal" => ("在资源管理器中显示", "Reveal in File Explorer"), + "files.openInTerminal" => ("在终端中打开", "Open in Terminal"), // ---- Welcome 屏(与 Tauri locale.ts welcome.* 对齐) ---- "welcome.searchProjects" => ("搜索项目", "Search projects"), "welcome.recentProjects" => ("最近项目", "Recent Projects"), diff --git a/linux/src/workbench/editor.rs b/linux/src/workbench/editor.rs index d2fcecd80..abad408d0 100644 --- a/linux/src/workbench/editor.rs +++ b/linux/src/workbench/editor.rs @@ -1,10 +1,11 @@ use gpui_kit::assets::IconName; use gpui_kit::component::input::{Editor, EditorState, InputEvent, Position}; +use gpui_kit::component::menu::ContextMenuExt as _; use gpui_kit::component::scroll::ScrollableElement as _; use gpui_kit::component::{h_flex, v_flex, Icon}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, relative, AppContext as _, ClipboardItem, Context, Entity, FontWeight, + div, px, relative, AppContext as _, ClipboardItem, Context, Entity, EventEmitter, FontWeight, InteractiveElement as _, IntoElement, ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, Subscription, Window, }; @@ -55,6 +56,8 @@ pub struct EditorView { redo_stack: Vec, /// 已关闭标签(标签快照,原索引),后进先出供恢复。 closed_stack: Vec<(EditorTab, usize)>, + /// 右键菜单目标标签(UI 层读写,方法层不消费)。 + pub context_menu_tab: Option, } impl EditorView { @@ -92,6 +95,7 @@ impl EditorView { undo_stack: Vec::new(), redo_stack: Vec::new(), closed_stack: Vec::new(), + context_menu_tab: None, } } @@ -650,6 +654,91 @@ impl EditorView { cx.notify(); } + /// 关闭除 `idx` 外的全部标签(`close_other_tabs` 的指定索引版,供右键菜单调用)。 + pub fn close_others_at(&mut self, idx: usize, cx: &mut Context) { + if idx >= self.tabs.len() { + return; + } + let mut kept = None; + for (index, tab) in self.tabs.drain(..).enumerate() { + if index == idx { + kept = Some(tab); + } else { + self.closed_stack.push((tab, index)); + } + } + if self.closed_stack.len() > 30 { + let overflow = self.closed_stack.len() - 30; + self.closed_stack.drain(..overflow); + } + if let Some(tab) = kept { + self.tabs.push(tab); + self.active_tab_index = Some(0); + } else { + self.active_tab_index = None; + } + self.sync_needed = true; + cx.notify(); + } + + /// 关闭 `idx` 右侧的全部标签(`close_tabs_to_right` 的指定索引版,供右键菜单调用)。 + pub fn close_to_right_at(&mut self, idx: usize, cx: &mut Context) { + if idx >= self.tabs.len() || idx + 1 >= self.tabs.len() { + return; + } + for index in idx + 1..self.tabs.len() { + if let Some(tab) = self.tabs.get(index).cloned() { + self.closed_stack.push((tab, index)); + } + } + if self.closed_stack.len() > 30 { + let overflow = self.closed_stack.len() - 30; + self.closed_stack.drain(..overflow); + } + self.tabs.truncate(idx + 1); + if let Some(active) = self.active_tab_index { + if active > idx { + self.active_tab_index = Some(idx); + } + } + self.sync_needed = true; + cx.notify(); + } + + /// 从磁盘重读指定标签(失败忽略;同步走既有 `sync_needed` 机制)。 + pub fn reload_tab(&mut self, idx: usize, cx: &mut Context) { + let Some(tab) = self.tabs.get(idx) else { + return; + }; + let path = tab.path.clone(); + let root = self.workspace_root.clone(); + let client = self.client.clone(); + + cx.spawn(async move |this, cx| { + let task = client.read_file(&cx, &root, &path); + if let Ok(text) = task.await { + let _ = this.update(cx, |ed, cx| { + if let Some(t) = ed.tabs.get_mut(idx) { + if t.path == path { + t.content = text; + t.is_dirty = false; + ed.sync_needed = true; + cx.notify(); + } + } + }); + } + }) + .detach(); + } + + /// 返回指定标签的绝对路径(`workspace_root/path` 拼接,供复制路径/文件管理器显示用)。 + pub fn tab_abs_path(&self, idx: usize) -> Option { + self.tabs + .get(idx) + .map(|tab| format!("{}/{}", self.workspace_root.trim_end_matches('/'), tab.path)) + } + /// 恢复最近关闭的标签到原位(后进先出,活动标签指向恢复项)。 pub fn reopen_closed_tab(&mut self, cx: &mut Context) { let Some((tab, index)) = self.closed_stack.pop() else { @@ -822,6 +911,14 @@ impl EditorView { } } +/// 编辑器标签派发的事件(右键菜单动作由 UI 层触发,父视图订阅处理)。 +#[derive(Debug, Clone)] +pub enum EditorTabEvent { + OpenInTerminal { dir: String }, +} + +impl EventEmitter for EditorView {} + /// 行注释风格:行前缀或行级块包裹。 /// 行注释风格:行前缀或行级块包裹。 @@ -1008,6 +1105,26 @@ impl Render for EditorView { .bg(ThemeColors::accent_blue()), ) }) + // 右键记 `context_menu_tab` 并弹出标签菜单(上游 + // `context_menu` 管显隐,浮层不占布局;点选后由 + // 菜单动作清 `None`,点外由下式清 `None`)。 + .on_mouse_down( + gpui_kit::MouseButton::Right, + cx.listener(move |this, _event, _window, cx| { + this.context_menu_tab = Some(idx); + cx.notify(); + }), + ) + .on_mouse_down_out(cx.listener(|this, _event, _window, cx| { + if this.context_menu_tab.is_some() { + this.context_menu_tab = None; + cx.notify(); + } + })) + .context_menu(crate::workbench::tab_menu::tab_context_menu( + cx.entity(), + idx, + )) })), ) .when_some(active_tab.as_ref(), |this, tab| { diff --git a/linux/src/workbench/mod.rs b/linux/src/workbench/mod.rs index d8af16c5f..d0ee70fd1 100644 --- a/linux/src/workbench/mod.rs +++ b/linux/src/workbench/mod.rs @@ -14,6 +14,7 @@ pub mod search_everywhere; pub mod settings_dialog; pub mod sidebar; pub mod status_bar; +pub mod tab_menu; pub mod terminal; pub mod toolbar; pub mod view; diff --git a/linux/src/workbench/tab_menu.rs b/linux/src/workbench/tab_menu.rs new file mode 100644 index 000000000..466e042ce --- /dev/null +++ b/linux/src/workbench/tab_menu.rs @@ -0,0 +1,197 @@ +//! 标签页右键菜单:Windows `tab-context-menu.tsx` 按键帽外子集。 +//! +//! 顺序固定为关闭/关闭其他/关闭到右侧/关闭全部|复制路径/复制相对路径/ +//! 在文件管理器中显示/在终端中打开/重新加载。文案经 +//! `crate::i18n::menu_text` 取 `tabs.*` 与 `files.*`,缺 key 回退为空。 +//! 触发与显隐走上游 `ContextMenuExt::context_menu`(右键冒泡打开,点选或点外 +//! 自动关闭,浮层 absolute 不占布局);`EditorView::context_menu_tab` 只做 +//! “右键目标”记账,右键按下时记 `idx`,选中动作与点外关闭时清 `None`。 + +use gpui_kit::assets::IconName; +use gpui_kit::component::h_flex; +use gpui_kit::component::menu::{PopupMenu, PopupMenuItem}; +use gpui_kit::{div, px, ClipboardItem, Context, Entity, ParentElement as _, Styled as _, Window}; + +use super::editor::{EditorTabEvent, EditorView}; +use crate::theme::ThemeColors; + +/// 右键菜单构造器:`view` 与 `idx` 以 owned 方式 move 进 `Fn` 闭包 +/// (`context_menu` 的 builder 要求 `Fn + 'static`,每次打开时调用)。 +pub fn tab_context_menu( + view: Entity, + idx: usize, +) -> impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static { + move |menu, _window, cx| build_tab_menu(menu, cx, &view, idx) +} + +/// 取绝对路径父目录(终端打开与文件管理器显示共用)。 +fn parent_dir(abs: &str) -> &str { + abs.rsplit_once('/').map(|(dir, _)| dir).unwrap_or(abs) +} + +/// 在文件管理器中显示所在目录(Linux-frame:`xdg-open`,失败忽略)。 +fn reveal_in_file_manager(dir: &str) { + let _ = std::process::Command::new("xdg-open").arg(dir).spawn(); +} + +fn build_tab_menu( + mut menu: PopupMenu, + cx: &mut Context, + view: &Entity, + idx: usize, +) -> PopupMenu { + // ---- 关闭组(Windows `close/close-others/close-right/close-all`) ---- + // 关闭带右侧 `Ctrl+W` 展示文本,用 element 自定义行(仿 `toolbar.rs`)。 + let v = view.clone(); + let close_label = crate::i18n::menu_text(cx, "tabs.close").to_string(); + menu = menu.item( + PopupMenuItem::element(move |_window, _cx| { + let label = close_label.clone(); + h_flex() + .w_full() + .items_center() + .gap_2() + .child( + div() + .flex_1() + .text_xs() + .text_color(ThemeColors::foreground()) + .child(label), + ) + .child( + div() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child("Ctrl+W"), + ) + }) + .icon(IconName::Close) + .on_click(move |_, _, cx| { + v.update(cx, |this, cx| { + this.close_tab(idx, cx); + this.context_menu_tab = None; + cx.notify(); + }); + }), + ); + + let v = view.clone(); + let close_others_label = crate::i18n::menu_text(cx, "tabs.closeOthers").to_string(); + menu = menu.item( + PopupMenuItem::new(close_others_label).on_click(move |_, _, cx| { + v.update(cx, |this, cx| { + this.close_others_at(idx, cx); + this.context_menu_tab = None; + cx.notify(); + }); + }), + ); + + let v = view.clone(); + let close_right_label = crate::i18n::menu_text(cx, "tabs.closeToRight").to_string(); + menu = menu.item( + PopupMenuItem::new(close_right_label).on_click(move |_, _, cx| { + v.update(cx, |this, cx| { + this.close_to_right_at(idx, cx); + this.context_menu_tab = None; + cx.notify(); + }); + }), + ); + + let v = view.clone(); + let close_all_label = crate::i18n::menu_text(cx, "tabs.closeAll").to_string(); + menu = menu.item( + PopupMenuItem::new(close_all_label).on_click(move |_, _, cx| { + v.update(cx, |this, cx| { + this.close_all_tabs(cx); + this.context_menu_tab = None; + cx.notify(); + }); + }), + ); + + menu = menu.separator(); + + // ---- 路径组(复制路径用绝对路径,相对路径用 `tab.path` 原样) ---- + let v = view.clone(); + let copy_path_label = crate::i18n::menu_text(cx, "files.copyPath").to_string(); + menu = menu.item( + PopupMenuItem::new(copy_path_label) + .icon(IconName::Copy) + .on_click(move |_, _, cx| { + v.update(cx, |this, cx| { + if let Some(abs) = this.tab_abs_path(idx) { + cx.write_to_clipboard(ClipboardItem::new_string(abs)); + } + this.context_menu_tab = None; + cx.notify(); + }); + }), + ); + + let v = view.clone(); + let copy_rel_label = crate::i18n::menu_text(cx, "files.copyRelativePath").to_string(); + menu = menu.item( + PopupMenuItem::new(copy_rel_label) + .icon(IconName::Copy) + .on_click(move |_, _, cx| { + v.update(cx, |this, cx| { + if let Some(rel) = this.tabs.get(idx).map(|tab| tab.path.clone()) { + cx.write_to_clipboard(ClipboardItem::new_string(rel)); + } + this.context_menu_tab = None; + cx.notify(); + }); + }), + ); + + let v = view.clone(); + let reveal_label = crate::i18n::menu_text(cx, "files.reveal").to_string(); + menu = menu.item( + PopupMenuItem::new(reveal_label) + .icon(IconName::FolderOpen) + .on_click(move |_, _, cx| { + v.update(cx, |this, cx| { + if let Some(abs) = this.tab_abs_path(idx) { + reveal_in_file_manager(parent_dir(&abs)); + } + this.context_menu_tab = None; + cx.notify(); + }); + }), + ); + + let v = view.clone(); + let terminal_label = crate::i18n::menu_text(cx, "files.openInTerminal").to_string(); + menu = menu.item( + PopupMenuItem::new(terminal_label) + .icon(IconName::Terminal) + .on_click(move |_, _, cx| { + v.update(cx, |this, cx| { + if let Some(abs) = this.tab_abs_path(idx) { + let dir = parent_dir(&abs).to_string(); + cx.emit(EditorTabEvent::OpenInTerminal { dir }); + } + this.context_menu_tab = None; + cx.notify(); + }); + }), + ); + + let v = view.clone(); + let reload_label = crate::i18n::menu_text(cx, "tabs.reload").to_string(); + menu = menu.item( + PopupMenuItem::new(reload_label) + .icon(IconName::RotateCw) + .on_click(move |_, _, cx| { + v.update(cx, |this, cx| { + this.reload_tab(idx, cx); + this.context_menu_tab = None; + cx.notify(); + }); + }), + ); + + menu +} From d0e0eb9461bb963f3d048cd7c51b611212a92a76 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 00:17:54 +0800 Subject: [PATCH 033/109] =?UTF-8?q?fix(linux):=20=E5=8E=BB=E6=8E=89=20tab?= =?UTF-8?q?=5Fmenu=20=E6=9C=AA=E4=BD=BF=E7=94=A8=E7=9A=84=20px=20=E5=AF=BC?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- linux/src/workbench/tab_menu.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linux/src/workbench/tab_menu.rs b/linux/src/workbench/tab_menu.rs index 466e042ce..e13142cd2 100644 --- a/linux/src/workbench/tab_menu.rs +++ b/linux/src/workbench/tab_menu.rs @@ -10,7 +10,7 @@ use gpui_kit::assets::IconName; use gpui_kit::component::h_flex; use gpui_kit::component::menu::{PopupMenu, PopupMenuItem}; -use gpui_kit::{div, px, ClipboardItem, Context, Entity, ParentElement as _, Styled as _, Window}; +use gpui_kit::{div, ClipboardItem, Context, Entity, ParentElement as _, Styled as _, Window}; use super::editor::{EditorTabEvent, EditorView}; use crate::theme::ThemeColors; From 37868f17d57ff7287461ac549fa05a945102200e Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 00:23:27 +0800 Subject: [PATCH 034/109] =?UTF-8?q?fix(linux):=20=E5=BA=95=E9=83=A8?= =?UTF-8?q?=E7=BB=88=E7=AB=AF=E6=94=B6=E8=BF=9B=E7=BC=96=E8=BE=91=E5=8C=BA?= =?UTF-8?q?=E5=88=97=E5=AE=BD=EF=BC=8C=E5=AF=B9=E9=BD=90=20Tauri=20editor?= =?UTF-8?q?=20=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 底部 splitter+panel 移入中间列 v_flex,编辑器上、终端下 - 右侧工具窗移出到底部范围外,终端不再横跨全宽 --- linux/src/workbench/view.rs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index 8981d4504..eec1117eb 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -1723,12 +1723,22 @@ impl WorkbenchView { }) .child( resizable_panel().child( - div() - .flex_1() + v_flex() .h_full() + .w_full() .min_w_0() - .bg(ThemeColors::background()) - .child(self.editor.clone()), + .child( + div() + .flex_1() + .w_full() + .min_h_0() + .bg(ThemeColors::background()) + .child(self.editor.clone()), + ) + .when(bottom_visible, |layout| layout.child(bottom_splitter)) + .when(bottom_visible, |layout| { + layout.child(self.bottom_panel.clone()) + }), ), ); if let Some(panel) = right_panel { @@ -1772,10 +1782,6 @@ impl WorkbenchView { .child(div().flex_1().h_full().min_w_0().child(center)) .child(self.plugin_rail.clone()), ) - .when(bottom_visible, |layout| layout.child(bottom_splitter)) - .when(bottom_visible, |layout| { - layout.child(self.bottom_panel.clone()) - }) .when(show_status_bar, |layout| { layout.child(self.status_bar.clone()) }) From abe7491ed42ee493886806eef75c751842970fc5 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 00:32:24 +0800 Subject: [PATCH 035/109] =?UTF-8?q?fix(linux):=20=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E8=A1=8C=E4=B8=BA=E5=AF=B9=E9=BD=90=20Tauri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 工具栏 Run/Debug 走底部 Run 页受管进程,不再发终端假命令 - Stop 杀 Run/Maven 子进程;Maven 目标走新增底部 Maven 页 - 底部新增 Maven 页签(launchPlan 参数 + $ mvn 首行输出) - 左侧 maven 项改由 Maven 任务点亮,终端命令不再误触 --- linux/src/workbench/bottom_panel.rs | 308 +++++++++++++++++++++++++++- linux/src/workbench/view.rs | 52 +++-- 2 files changed, 344 insertions(+), 16 deletions(-) diff --git a/linux/src/workbench/bottom_panel.rs b/linux/src/workbench/bottom_panel.rs index d24063ac0..6838914f5 100644 --- a/linux/src/workbench/bottom_panel.rs +++ b/linux/src/workbench/bottom_panel.rs @@ -66,6 +66,7 @@ const MAX_GIT_LOG_ENTRIES: usize = 50; pub enum BottomTab { Terminal, Run, + Maven, Diagnostics, GitLog, } @@ -100,10 +101,20 @@ pub struct BottomPanelView { pub(crate) run_output: Vec, /// 是否有进程在跑。 pub(crate) run_running: bool, + /// Maven 任务标题(对齐 Tauri `taskTitle`,如 `compile · pom.xml`)。 + pub(crate) maven_title: Option, + /// Maven 任务输出行(`$ mvn …` 开头,对齐 Tauri Maven 页)。 + pub(crate) maven_output: Vec, + /// Maven 任务是否在跑。 + pub(crate) maven_running: bool, /// core 客户端(`reload_run_project` / `createLaunchPlan` 经它走 core JSON 命令)。 client: CoreClient, /// 在跑子进程句柄:停止按钮经 `kill` 停,运行线程经 `try_wait` 短锁轮询收割。 run_child: Arc>>, + /// Maven 任务子进程句柄(与 Run 共用停止语义,分开存放可各自启停)。 + maven_child: Arc>>, + /// Maven 启动序号,丢弃过期 `launchPlan` 结果。 + maven_seq: u64, /// reload 序号,丢弃过期探测结果。 run_seq: u64, /// 最近一次加载的 Git 提交记录。 @@ -130,6 +141,11 @@ impl BottomPanelView { selected_run_config: None, run_output: Vec::new(), run_running: false, + maven_title: None, + maven_output: Vec::new(), + maven_running: false, + maven_child: Arc::new(Mutex::new(None)), + maven_seq: 0, client: CoreClient::new(), run_child: Arc::new(Mutex::new(None)), run_seq: 0, @@ -339,15 +355,144 @@ impl BottomPanelView { .detach(); } - /// 停止在跑进程(只 kill 直接子进程;mvn 拉起的 java 孙进程不在此列)。 + /// 停止在跑进程(Run 与 Maven 各自 kill;mvn 拉起的 java 孙进程不在此列)。 pub fn stop_running(&mut self, cx: &mut Context) { + let mut stopped = false; if let Ok(mut slot) = self.run_child.lock() { if let Some(child) = slot.as_mut() { let _ = child.kill(); + stopped = true; } } - push_run_line(&mut self.run_output, "已发送停止信号…".to_string()); + if let Ok(mut slot) = self.maven_child.lock() { + if let Some(child) = slot.as_mut() { + let _ = child.kill(); + stopped = true; + } + } + if self.run_running { + push_run_line(&mut self.run_output, "已发送停止信号…".to_string()); + stopped = true; + } + if self.maven_running { + push_run_line(&mut self.maven_output, "已发送停止信号…".to_string()); + stopped = true; + } + if stopped { + cx.notify(); + } + } + + /// 是否发生过 Maven 运行;宿主据此决定左侧栏是否展示 maven 项 + ///(对齐 Tauri `hasMavenRun`:任务跑过即真,与终端历史无关)。 + pub fn has_maven_run(&self) -> bool { + self.maven_running || !self.maven_output.is_empty() + } + + /// 运行 Maven 目标:切 Maven 页 + 经 core `maven.launchPlan` 拿确定性参数 + ///(payload `{root, context: {version: 1, reactorPath: "."}, module, goals}`), + /// 起受管进程并把输出泵入 Maven 页(首行 `$ mvn …`,对齐 Tauri Maven 页)。 + /// 已有任务在跑时先停掉(对齐 Tauri 停掉上一个 session)。 + pub fn run_maven_goal(&mut self, pom_path: &str, goal: &str, cx: &mut Context) { + let goal = goal.trim().to_string(); + if goal.is_empty() { + return; + } + if self.maven_running { + if let Ok(mut slot) = self.maven_child.lock() { + if let Some(child) = slot.as_mut() { + let _ = child.kill(); + } + } + } + self.maven_seq += 1; + let seq = self.maven_seq; + let title = format!( + "{goal} · {}", + maven_display_pom(&self.working_dir, pom_path) + ); + self.active_tab = BottomTab::Maven; + self.is_collapsed = false; + self.maven_title = Some(title.clone()); + self.maven_output.clear(); + self.maven_running = true; cx.notify(); + + let client = self.client.clone(); + let root = self.working_dir.clone(); + let module = maven_module_for_pom(&self.working_dir, pom_path); + let child_slot = self.maven_child.clone(); + cx.spawn(async move |this, cx| { + let plan = client + .execute::( + &cx, + "maven.launchPlan", + serde_json::json!({ + "root": root, + "context": { "version": 1, "reactorPath": "." }, + "module": module, + "goals": [goal], + }), + ) + .await + .ok(); + let (program, args, cwd) = maven_plan_to_step(plan.as_ref(), &root, &goal); + let display = format!("$ {} {}", program, args.join(" ")); + let started = this.update(cx, |view, cx| { + if view.maven_seq != seq { + return false; + } + push_run_line(&mut view.maven_output, display.clone()); + view.record_run(&display, cx); + cx.notify(); + true + }); + if !matches!(started, Ok(true)) { + return; + } + let steps = vec![RunStep { program, args, cwd }]; + let (tx, rx) = mpsc::channel::(); + std::thread::spawn(move || run_steps_blocking(steps, child_slot, tx)); + let rx = Arc::new(Mutex::new(rx)); + loop { + let slot = rx.clone(); + let next = cx + .background_executor() + .spawn(async move { + let guard = slot.lock().ok()?; + guard.recv().ok() + }) + .await; + match next { + Some(line) => { + // `run_steps_blocking` 首行会再发一次 `$ …`,Maven 页已有 + // 展示首行,跳过重复(对齐 Tauri 单首行)。 + let skip = this + .update(cx, |view, cx| { + let duplicate = line.trim_start().starts_with("$ ") + && !view.maven_output.is_empty(); + if !duplicate { + push_run_line(&mut view.maven_output, line); + } + cx.notify(); + view.maven_seq == seq + }) + .unwrap_or(false); + if !skip { + break; + } + } + None => break, + } + } + let _ = this.update(cx, |view, cx| { + if view.maven_seq == seq { + view.maven_running = false; + } + cx.notify(); + }); + }) + .detach(); } /// 当前选中项(无选中或已失效时回退首项)。 @@ -701,6 +846,85 @@ impl BottomPanelView { .into_any_element() } + /// Maven 面板:对齐 Tauri Maven 页——顶部任务标题+运行状态+停止/清空, + /// 输出区首行 `$ mvn …`,流式追加进程输出。 + fn render_maven_panel(&self, cx: &mut Context) -> AnyElement { + let status = if self.maven_running { + run_ui_text(cx, "运行中…", "Running…") + } else if let Some(title) = self.maven_title.clone() { + title + } else { + run_ui_text(cx, "尚未运行 Maven 目标", "No Maven goal has run yet") + }; + let total = self.maven_output.len(); + let start = total.saturating_sub(800); + let output: Vec = if self.maven_output.is_empty() { + vec![div() + .text_color(ThemeColors::text_muted()) + .child(run_ui_text(cx, "暂无输出", "No output")) + .into_any_element()] + } else { + self.maven_output[start..] + .iter() + .map(|line| div().child(line.clone()).into_any_element()) + .collect() + }; + v_flex() + .size_full() + .child( + h_flex() + .flex_shrink_0() + .h(px(30.0)) + .w_full() + .items_center() + .gap_2() + .px_3() + .border_b_1() + .border_color(ThemeColors::border()) + .child( + div() + .flex_1() + .truncate() + .text_xs() + .text_color(ThemeColors::text_primary()) + .child(status), + ) + .child( + Button::new("maven-stop") + .small() + .primary() + .disabled(!self.maven_running) + .label(run_ui_text(cx, "停止", "Stop")) + .on_click(cx.listener(|this, _event, _window, cx| { + this.stop_running(cx); + })), + ) + .child( + Button::new("maven-clear-output") + .small() + .ghost() + .label(run_ui_text(cx, "清空输出", "Clear")) + .on_click(cx.listener(|this, _event, _window, cx| { + this.maven_output.clear(); + cx.notify(); + })), + ), + ) + .child( + div() + .flex_1() + .w_full() + .min_h_0() + .overflow_y_scrollbar() + .p_2() + .font_family("monospace") + .text_xs() + .text_color(ThemeColors::text_primary()) + .children(output), + ) + .into_any_element() + } + /// GitLog 面板:只读提交列表,行点击不跳转;空态与失败文案兜底。 fn render_git_log_panel(&self, cx: &mut Context) -> AnyElement { if let Some(err) = self.git_log_error.clone() { @@ -815,6 +1039,14 @@ impl Render for BottomPanelView { BottomTab::Run, cx, )) + .child(self.render_tab_button( + "tab-maven", + IconName::Box, + crate::i18n::menu_text(cx, "maven.title").to_string(), + self.active_tab == BottomTab::Maven, + BottomTab::Maven, + cx, + )) .child(self.render_tab_button( "tab-diagnostics", IconName::TriangleAlert, @@ -857,6 +1089,10 @@ impl Render for BottomPanelView { this.run_output.clear(); cx.notify(); } + BottomTab::Maven => { + this.maven_output.clear(); + cx.notify(); + } BottomTab::Diagnostics => { this.diagnostics.clear(); cx.notify(); @@ -888,6 +1124,7 @@ impl Render for BottomPanelView { .child(self.terminal.clone()) .into_any_element(), BottomTab::Run => self.render_run_panel(cx), + BottomTab::Maven => self.render_maven_panel(cx), BottomTab::Diagnostics => div() .size_full() .p_3() @@ -1236,6 +1473,73 @@ fn plan_to_steps( fallback_run_steps(item, root) } +/// Maven 页展示用的 pom 路径:相对工作区,根 pom 显示 `pom.xml`。 +fn maven_display_pom(root: &str, pom: &str) -> String { + std::path::Path::new(pom) + .strip_prefix(root) + .map(|rel| { + let s = rel.to_string_lossy().into_owned(); + if s.is_empty() { + pom.to_string() + } else { + s + } + }) + .unwrap_or_else(|_| pom.to_string()) +} + +/// pom 所在目录相对 root 的模块路径:根 pom 为 `.`,与 core 模块约定一致。 +fn maven_module_for_pom(root: &str, pom: &str) -> String { + let relative = std::path::Path::new(pom) + .parent() + .and_then(|dir| dir.strip_prefix(root).ok()) + .map(|rel| rel.to_string_lossy().replace('\\', "/")) + .unwrap_or_default(); + let trimmed = relative.trim_matches('/').to_string(); + if trimmed.is_empty() { + ".".to_string() + } else { + trimmed + } +} + +/// `maven.launchPlan`(`{executable.toolchain, arguments[], workingDirectory}`) +/// 转本地执行步骤;拿不到计划时退化为 `mvn `。 +fn maven_plan_to_step( + plan: Option<&serde_json::Value>, + root: &str, + goal: &str, +) -> (String, Vec, String) { + if let Some(plan) = plan { + let cwd = plan + .get("workingDirectory") + .and_then(|v| v.as_str()) + .unwrap_or("."); + let cwd = if cwd == "." || cwd.is_empty() { + root.to_string() + } else { + format!("{root}/{cwd}") + }; + let args = plan + .get("arguments") + .and_then(|a| a.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect::>() + }) + .filter(|args| !args.is_empty()); + if let Some(args) = args { + return (resolve_maven_executable(root), args, cwd); + } + } + ( + resolve_maven_executable(root), + vec![goal.to_string()], + root.to_string(), + ) +} + /// 退化执行规则:maven 用 mvn 跑 `compile exec:java`;npm 跑 `run dev`; /// java 单文件走 javac+java,否则直跑主类。 fn fallback_run_steps(item: &RunConfigItem, root: &str) -> Vec { diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index eec1117eb..0d83075da 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -344,10 +344,17 @@ impl WorkbenchView { ); // 4b. 订阅 Maven 视图事件(执行目标、关闭工具窗口) + // 对齐 Tauri:目标走底部 Maven 页受管进程,不进交互终端。 let sub_maven = cx.subscribe(&maven, |this, _maven, event: &MavenEvent, cx| match event { MavenEvent::RunGoal { pom_path, phase } => { - let cmd = format!("mvn -f \"{pom_path}\" {phase}"); - this.send_terminal_command(&cmd, cx); + let _ = this.bottom_panel.update(cx, |bp, cx| { + bp.run_maven_goal(pom_path, phase, cx); + }); + let has_run = this.bottom_panel.read(cx).has_maven_run(); + let _ = this.activity_rail.update(cx, |r, cx| { + r.set_has_maven_run(has_run, cx); + }); + cx.notify(); } MavenEvent::Close => { this.right_tool = None; @@ -402,13 +409,17 @@ impl WorkbenchView { this.open_quick_open(cx); } ToolbarEvent::Run => { - this.send_terminal_command("echo '[Lithe Run]' && cargo check", cx); + this.open_run_pane(cx); } ToolbarEvent::Debug => { - this.append_log("[Debug] Launching DAP debug session...", cx); + // Linux 无独立 DAP 面板:对齐 Tauri 可见行为,Debug 同样 + // 走底部受管进程(Run 页),不再只记一条假日志。 + this.open_run_pane(cx); } ToolbarEvent::Stop => { - this.append_log("[Stop] Session terminated by user.", cx); + let _ = this.bottom_panel.update(cx, |bp, cx| { + bp.stop_running(cx); + }); } ToolbarEvent::OpenSettings => { this.open_settings(cx); @@ -880,8 +891,8 @@ impl WorkbenchView { cx.notify(); } - /// 经底部终端发送命令:执行 + 计入运行历史 + 点亮左侧 maven 项 - ///(对齐 Tauri `toggleTerminalPane` 系 + `hasMavenRun` 挂载语义)。 + /// 经底部终端发送命令:只执行,不计入 maven 运行门控 + ///(对齐 Tauri:左侧 maven 项仅由 Maven 任务点亮)。 fn send_terminal_command(&mut self, cmd: &str, cx: &mut Context) { let cmd = cmd.to_string(); let _ = self.bottom_panel.update(cx, |bp, cx| { @@ -891,9 +902,15 @@ impl WorkbenchView { }); bp.record_run(&cmd, cx); }); - let has_run = self.bottom_panel.read(cx).has_run_history(); - let _ = self.activity_rail.update(cx, |r, cx| { - r.set_has_maven_run(has_run, cx); + cx.notify(); + } + + /// 打开底部 Run 页并运行选中配置:对齐 Tauri `openRunDecisionPane` + + /// `runConfiguration`(页签可见 + 受管进程,不进交互终端)。 + fn open_run_pane(&mut self, cx: &mut Context) { + let _ = self.bottom_panel.update(cx, |bp, cx| { + bp.set_tab(BottomTab::Run, cx); + bp.run_selected_config(cx); }); cx.notify(); } @@ -1176,10 +1193,15 @@ impl WorkbenchView { }); } "workbench.run" | "run.run" | "run.start" => { - self.send_terminal_command("cargo check", cx); + self.open_run_pane(cx); } "workbench.debug" | "run.debug" => { - self.append_log("[Debug] Launching DAP debug session...", cx); + self.open_run_pane(cx); + } + "workbench.stop" | "run.stop" => { + let _ = self.bottom_panel.update(cx, |bp, cx| { + bp.stop_running(cx); + }); } "workbench.clear_terminal" | "terminal.clear" => { let _ = self.bottom_panel.update(cx, |bp, cx| { @@ -1492,11 +1514,13 @@ fn sidebar_tab_for(view_id: &str) -> Option { } } -/// 活动栏底部项 id → 底部面板标签(对齐 Tauri `BottomPaneTab`)。 +/// 活动栏底部项 id → 底部面板标签(对齐 Tauri `BottomPaneTab`: +/// `run` 与 `maven` 是两个独立页)。 fn bottom_tab_for(pane_id: &str) -> Option { match pane_id { "terminal" => Some(BottomTab::Terminal), - "run" | "maven" => Some(BottomTab::Run), + "run" => Some(BottomTab::Run), + "maven" => Some(BottomTab::Maven), "diagnostics" => Some(BottomTab::Diagnostics), "gitLog" => Some(BottomTab::GitLog), _ => None, From b1dee2a757a2e1cdebca3f21e5b0d420268cf9ee Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 00:39:47 +0800 Subject: [PATCH 036/109] =?UTF-8?q?feat(linux):=20=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E9=A1=B5=E4=B8=8E=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E5=88=86=E5=B1=8F=EF=BC=8C=E5=AF=B9=E9=BD=90=20Tauri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 固定标签:is_pinned 置顶组,批量关闭跳过,菜单固定项 - 窗格树 panes:任意嵌套左右/上下拆分、关闭合并、比例拖拽 - 锁定窗格:新文件路由自动跳过锁定窗格 - 菜单补齐拆分/锁定项 --- linux/src/i18n.rs | 7 + linux/src/workbench/editor.rs | 189 +++++++++++---- linux/src/workbench/mod.rs | 1 + linux/src/workbench/panes.rs | 357 +++++++++++++++++++++++++++ linux/src/workbench/tab_menu.rs | 105 +++++++- linux/src/workbench/view.rs | 414 ++++++++++++++++++++++++++------ 6 files changed, 952 insertions(+), 121 deletions(-) create mode 100644 linux/src/workbench/panes.rs diff --git a/linux/src/i18n.rs b/linux/src/i18n.rs index 145231428..b4a62c637 100644 --- a/linux/src/i18n.rs +++ b/linux/src/i18n.rs @@ -220,6 +220,13 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { "tabs.closeToRight" => ("关闭右侧", "Close to Right"), "tabs.closeAll" => ("全部关闭", "Close All"), "tabs.reload" => ("重新加载", "Reload"), + "tabs.pin" => ("固定标签页", "Pin Tab"), + "tabs.unpin" => ("取消固定标签页", "Unpin Tab"), + "tabs.splitRight" => ("向右拆分", "Split Right"), + "tabs.splitDown" => ("向下拆分", "Split Down"), + "tabs.lockEditorGroup" => ("锁定编辑器组", "Lock Editor Group"), + "tabs.unlockEditorGroup" => ("解锁编辑器组", "Unlock Editor Group"), + "tabs.cannotClosePinned" => ("无法关闭已固定标签页 {name}", "Cannot close pinned tab {name}"), "files.copyPath" => ("复制路径", "Copy Path"), "files.copyRelativePath" => ("复制相对路径", "Copy Relative Path"), "files.reveal" => ("在资源管理器中显示", "Reveal in File Explorer"), diff --git a/linux/src/workbench/editor.rs b/linux/src/workbench/editor.rs index abad408d0..32b542776 100644 --- a/linux/src/workbench/editor.rs +++ b/linux/src/workbench/editor.rs @@ -10,6 +10,11 @@ use gpui_kit::{ StatefulInteractiveElement as _, Styled as _, Subscription, Window, }; +use std::rc::Rc; + +use super::panes::PaneId; +use super::tab_menu::{SplitCallback, ToggleLockCallback}; + use crate::core::CoreClient; use crate::settings; use crate::theme::ThemeColors; @@ -20,6 +25,8 @@ pub struct EditorTab { pub title: String, pub content: String, pub is_dirty: bool, + /// 固定标签:批量关闭跳过,固定组永远排在标签栏前段。 + pub is_pinned: bool, #[allow(dead_code)] pub cursor_line: usize, #[allow(dead_code)] @@ -58,6 +65,14 @@ pub struct EditorView { closed_stack: Vec<(EditorTab, usize)>, /// 右键菜单目标标签(UI 层读写,方法层不消费)。 pub context_menu_tab: Option, + /// 所属窗格(工作台装配;`None` 时右键菜单用 0 与空回调兜底)。 + pub pane_id: Option, + /// 所属窗格锁定镜像(工作台回写,决定锁定项文案)。 + pub pane_locked: bool, + /// 拆分回调(工作台注入,经 workbench entity 回写)。 + pub on_split: Option, + /// 锁定回调(工作台注入,经 workbench entity 回写)。 + pub on_toggle_lock: Option, } impl EditorView { @@ -96,6 +111,10 @@ impl EditorView { redo_stack: Vec::new(), closed_stack: Vec::new(), context_menu_tab: None, + pane_id: None, + pane_locked: false, + on_split: None, + on_toggle_lock: None, } } @@ -124,6 +143,7 @@ impl EditorView { title, content, is_dirty: false, + is_pinned: false, cursor_line: 1, cursor_col: 1, }); @@ -533,25 +553,35 @@ impl EditorView { } } - /// 关闭全部标签(逐个进恢复栈)。 + /// 关闭全部标签(逐个进恢复栈;固定标签保留)。 pub fn close_all_tabs(&mut self, cx: &mut Context) { if self.tabs.is_empty() { return; } - for (index, tab) in self.tabs.iter().enumerate() { - self.closed_stack.push((tab.clone(), index)); + let active_path = self + .active_tab_index + .and_then(|index| self.tabs.get(index)) + .map(|tab| tab.path.clone()); + let mut kept = Vec::new(); + for (index, tab) in self.tabs.drain(..).enumerate() { + if tab.is_pinned { + kept.push(tab); + } else { + self.closed_stack.push((tab, index)); + } } if self.closed_stack.len() > 30 { let overflow = self.closed_stack.len() - 30; self.closed_stack.drain(..overflow); } - self.tabs.clear(); - self.active_tab_index = None; + self.tabs = kept; + self.active_tab_index = + active_path.and_then(|path| self.tabs.iter().position(|tab| tab.path == path)); self.sync_needed = true; cx.notify(); } - /// 关闭除活动标签外的全部标签。 + /// 关闭除活动标签外的全部标签(固定标签保留,固定组在前)。 pub fn close_other_tabs(&mut self, cx: &mut Context) { let Some(active) = self.active_tab_index else { return; @@ -559,10 +589,11 @@ impl EditorView { if active >= self.tabs.len() { return; } - let mut kept = None; + let active_path = self.tabs.get(active).map(|tab| tab.path.clone()); + let mut kept: Vec<(usize, EditorTab)> = Vec::new(); for (index, tab) in self.tabs.drain(..).enumerate() { - if index == active { - kept = Some(tab); + if index == active || tab.is_pinned { + kept.push((index, tab)); } else { self.closed_stack.push((tab, index)); } @@ -571,17 +602,16 @@ impl EditorView { let overflow = self.closed_stack.len() - 30; self.closed_stack.drain(..overflow); } - if let Some(tab) = kept { - self.tabs.push(tab); - self.active_tab_index = Some(0); - } else { - self.active_tab_index = None; - } + // 固定组在前并保持原相对顺序。 + kept.sort_by_key(|(index, tab)| (!tab.is_pinned, *index)); + self.tabs = kept.into_iter().map(|(_, tab)| tab).collect(); + self.active_tab_index = + active_path.and_then(|path| self.tabs.iter().position(|tab| tab.path == path)); self.sync_needed = true; cx.notify(); } - /// 关闭全部未修改(`is_dirty == false`)的标签。 + /// 关闭全部未修改(`is_dirty == false`)的标签(固定标签保留)。 pub fn close_saved_tabs(&mut self, cx: &mut Context) { if self.tabs.is_empty() { return; @@ -592,7 +622,7 @@ impl EditorView { .map(|tab| tab.path.clone()); let mut kept = Vec::new(); for (index, tab) in self.tabs.drain(..).enumerate() { - if tab.is_dirty { + if tab.is_dirty || tab.is_pinned { kept.push(tab); } else { self.closed_stack.push((tab, index)); @@ -609,7 +639,7 @@ impl EditorView { cx.notify(); } - /// 关闭活动标签左侧的全部标签。 + /// 关闭活动标签左侧的全部标签(固定标签保留在前段)。 pub fn close_tabs_to_left(&mut self, cx: &mut Context) { let Some(active) = self.active_tab_index else { return; @@ -617,8 +647,12 @@ impl EditorView { if active == 0 || active >= self.tabs.len() { return; } - for index in 0..active { - if let Some(tab) = self.tabs.get(index).cloned() { + let active_path = self.tabs.get(active).map(|tab| tab.path.clone()); + let mut pinned_left: Vec = Vec::new(); + for (index, tab) in self.tabs.drain(..active).enumerate() { + if tab.is_pinned { + pinned_left.push(tab); + } else { self.closed_stack.push((tab, index)); } } @@ -626,13 +660,16 @@ impl EditorView { let overflow = self.closed_stack.len() - 30; self.closed_stack.drain(..overflow); } - self.tabs.drain(..active); - self.active_tab_index = Some(0); + for (offset, tab) in pinned_left.into_iter().enumerate() { + self.tabs.insert(offset, tab); + } + self.active_tab_index = + active_path.and_then(|path| self.tabs.iter().position(|tab| tab.path == path)); self.sync_needed = true; cx.notify(); } - /// 关闭活动标签右侧的全部标签。 + /// 关闭活动标签右侧的全部标签(固定标签保留)。 pub fn close_tabs_to_right(&mut self, cx: &mut Context) { let Some(active) = self.active_tab_index else { return; @@ -640,29 +677,33 @@ impl EditorView { if active >= self.tabs.len() || active + 1 >= self.tabs.len() { return; } - for index in active + 1..self.tabs.len() { - if let Some(tab) = self.tabs.get(index).cloned() { - self.closed_stack.push((tab, index)); + let mut pinned_right: Vec = Vec::new(); + for (offset, tab) in self.tabs.drain(active + 1..).enumerate() { + if tab.is_pinned { + pinned_right.push(tab); + } else { + self.closed_stack.push((tab, active + 1 + offset)); } } if self.closed_stack.len() > 30 { let overflow = self.closed_stack.len() - 30; self.closed_stack.drain(..overflow); } - self.tabs.truncate(active + 1); + self.tabs.extend(pinned_right); self.sync_needed = true; cx.notify(); } - /// 关闭除 `idx` 外的全部标签(`close_other_tabs` 的指定索引版,供右键菜单调用)。 + /// 关闭除 `idx` 外的全部标签(`close_other_tabs` 的指定索引版,供右键菜单调用;固定标签保留,固定组在前)。 pub fn close_others_at(&mut self, idx: usize, cx: &mut Context) { if idx >= self.tabs.len() { return; } - let mut kept = None; + let kept_path = self.tabs.get(idx).map(|tab| tab.path.clone()); + let mut kept: Vec<(usize, EditorTab)> = Vec::new(); for (index, tab) in self.tabs.drain(..).enumerate() { - if index == idx { - kept = Some(tab); + if index == idx || tab.is_pinned { + kept.push((index, tab)); } else { self.closed_stack.push((tab, index)); } @@ -671,40 +712,77 @@ impl EditorView { let overflow = self.closed_stack.len() - 30; self.closed_stack.drain(..overflow); } - if let Some(tab) = kept { - self.tabs.push(tab); - self.active_tab_index = Some(0); - } else { - self.active_tab_index = None; - } + // 固定组在前并保持原相对顺序;`idx` 未固定时排在固定段之后。 + kept.sort_by_key(|(index, tab)| (!tab.is_pinned, *index)); + self.tabs = kept.into_iter().map(|(_, tab)| tab).collect(); + self.active_tab_index = + kept_path.and_then(|path| self.tabs.iter().position(|tab| tab.path == path)); self.sync_needed = true; cx.notify(); } - /// 关闭 `idx` 右侧的全部标签(`close_tabs_to_right` 的指定索引版,供右键菜单调用)。 + /// 关闭 `idx` 右侧的全部标签(`close_tabs_to_right` 的指定索引版,供右键菜单调用;固定标签保留)。 pub fn close_to_right_at(&mut self, idx: usize, cx: &mut Context) { if idx >= self.tabs.len() || idx + 1 >= self.tabs.len() { return; } - for index in idx + 1..self.tabs.len() { - if let Some(tab) = self.tabs.get(index).cloned() { - self.closed_stack.push((tab, index)); + let active_path = self + .active_tab_index + .and_then(|index| self.tabs.get(index)) + .map(|tab| tab.path.clone()); + let mut pinned_right: Vec = Vec::new(); + for (offset, tab) in self.tabs.drain(idx + 1..).enumerate() { + if tab.is_pinned { + pinned_right.push(tab); + } else { + self.closed_stack.push((tab, idx + 1 + offset)); } } if self.closed_stack.len() > 30 { let overflow = self.closed_stack.len() - 30; self.closed_stack.drain(..overflow); } - self.tabs.truncate(idx + 1); + self.tabs.extend(pinned_right); if let Some(active) = self.active_tab_index { if active > idx { - self.active_tab_index = Some(idx); + // 活动页被关掉时回退到 `idx`;固定活动页幸存时按 path 跟随。 + self.active_tab_index = active_path + .and_then(|path| self.tabs.iter().position(|tab| tab.path == path)) + .or(Some(idx)); } } self.sync_needed = true; cx.notify(); } + /// 翻转 `idx` 标签的固定状态并物理重排(固定组永远在前)。 + /// 固定→移到固定段末尾,取消固定→移到固定段之后紧邻;活动页与已同步 + /// 位按 path 跟随被移动的标签。内容未变故不置 `sync_needed`,只 `notify`。 + pub fn toggle_pin_at(&mut self, idx: usize, cx: &mut Context) { + if idx >= self.tabs.len() { + return; + } + let active_path = self + .active_tab_index + .and_then(|index| self.tabs.get(index)) + .map(|tab| tab.path.clone()); + let synced_path = self + .synced_tab + .and_then(|index| self.tabs.get(index)) + .map(|tab| tab.path.clone()); + let mut tab = self.tabs.remove(idx); + tab.is_pinned = !tab.is_pinned; + let pinned_count = self.tabs.iter().filter(|tab| tab.is_pinned).count(); + self.tabs.insert(pinned_count.min(self.tabs.len()), tab); + if let Some(path) = active_path { + self.active_tab_index = self.tabs.iter().position(|tab| tab.path == path); + } + if let Some(path) = synced_path { + self.synced_tab = self.tabs.iter().position(|tab| tab.path == path); + } + cx.notify(); + } + /// 从磁盘重读指定标签(失败忽略;同步走既有 `sync_needed` 机制)。 pub fn reload_tab(&mut self, idx: usize, cx: &mut Context) { let Some(tab) = self.tabs.get(idx) else { @@ -1121,10 +1199,27 @@ impl Render for EditorView { cx.notify(); } })) - .context_menu(crate::workbench::tab_menu::tab_context_menu( - cx.entity(), - idx, - )) + .context_menu({ + let view = cx.entity(); + let pane_id = self.pane_id.unwrap_or(0); + let locked = self.pane_locked; + let on_split: SplitCallback = self + .on_split + .clone() + .unwrap_or_else(|| Rc::new(|_, _, _, _| {})); + let on_toggle_lock: ToggleLockCallback = self + .on_toggle_lock + .clone() + .unwrap_or_else(|| Rc::new(|_, _| {})); + crate::workbench::tab_menu::tab_context_menu( + view, + idx, + pane_id, + locked, + on_split, + on_toggle_lock, + ) + }) })), ) .when_some(active_tab.as_ref(), |this, tab| { diff --git a/linux/src/workbench/mod.rs b/linux/src/workbench/mod.rs index d0ee70fd1..3ae503ed6 100644 --- a/linux/src/workbench/mod.rs +++ b/linux/src/workbench/mod.rs @@ -8,6 +8,7 @@ pub mod file_icon; pub mod go_to_line; pub mod maven; pub mod notifications; +pub mod panes; pub mod project_dialog; pub mod quick_open; pub mod search_everywhere; diff --git a/linux/src/workbench/panes.rs b/linux/src/workbench/panes.rs new file mode 100644 index 000000000..81c401004 --- /dev/null +++ b/linux/src/workbench/panes.rs @@ -0,0 +1,357 @@ +//! 窗格树纯逻辑:对齐 Tauri `features/panes` 的拆分与路由语义。 +//! +//! 窗格树可任意嵌套左右/上下拆分([`SplitDir::Horizontal`] 为左右并排, +//! [`SplitDir::Vertical`] 为上下堆叠)。锁定窗格不接收新文件: +//! [`PaneTree::route_target`] 在 active 未锁定时返回 active,否则返回 +//! 第一个未锁定叶,全锁定时返回 `None`(对齐 `pane-routing.ts` +//! `resolveWritablePaneForBuffer` 的回退语义)。 +//! +//! 本模块不依赖 gpui,只表达树形结构与路由规则,方便单测。 + +use std::collections::HashSet; + +/// 窗格标识:[`PaneTree::new`] 从 0 起按拆分顺序递增分配。 +pub type PaneId = u64; + +/// 拆分方向:`Horizontal` 为左右并排,`Vertical` 为上下堆叠。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SplitDir { + /// 左右并排。 + Horizontal, + /// 上下堆叠。 + Vertical, +} + +/// 窗格树节点:叶为单个窗格,`Split` 为一次拆分(`first` 为原叶所在侧, +/// `second` 为新叶所在侧,`ratio` 为 `first` 所占比例)。 +#[derive(Debug, Clone, PartialEq)] +pub enum PaneNode { + /// 单个窗格。 + Leaf(PaneId), + /// 一次拆分。 + Split { + /// 拆分方向。 + dir: SplitDir, + /// 原叶所在侧。 + first: Box, + /// 新叶所在侧。 + second: Box, + /// `first` 所占比例,写入时钳制在 `0.1..=0.9`。 + ratio: f32, + }, +} + +/// 比例下界:所有写入 [`PaneNode::Split::ratio`] 的值都钳制到 +/// `MIN_RATIO..=MAX_RATIO`。 +const MIN_RATIO: f32 = 0.1; +/// 比例上界,见 [`MIN_RATIO`]。 +const MAX_RATIO: f32 = 0.9; + +/// 窗格树:持有根节点、下个待分配 id、活动窗格与锁定集合。 +#[derive(Debug, Clone)] +pub struct PaneTree { + root: Option, + next_id: u64, + active: Option, + locked: HashSet, +} + +impl PaneTree { + /// 新建单叶树:叶 id 为 0,active 为 `Some(0)`。 + pub fn new() -> Self { + Self { + root: Some(PaneNode::Leaf(0)), + next_id: 1, + active: Some(0), + locked: HashSet::new(), + } + } + + /// 当前活动窗格(关闭后会自动跟随到存活叶)。 + pub fn active(&self) -> Option { + self.active + } + + /// 根节点只读视图(渲染层递归用)。 + pub fn root(&self) -> Option<&PaneNode> { + self.root.as_ref() + } + + /// 先序收集所有叶 id(`Split` 先 `first` 后 `second`)。 + pub fn leaves(&self) -> Vec { + let mut out = Vec::new(); + if let Some(root) = &self.root { + collect_leaves(root, &mut out); + } + out + } + + /// 拆分叶 `id`:原叶变为 `Split` 的 `first`,新叶为 `second`, + /// 比例为 0.5;返回新叶 id,叶不存在时返回 `None`。 + pub fn split_leaf(&mut self, id: PaneId, dir: SplitDir) -> Option { + let slot = self + .root + .as_mut() + .and_then(|root| find_leaf_mut(root, id))?; + let new_id = self.next_id; + self.next_id += 1; + *slot = PaneNode::Split { + dir, + first: Box::new(PaneNode::Leaf(id)), + second: Box::new(PaneNode::Leaf(new_id)), + ratio: 0.5, + }; + Some(new_id) + } + + /// 关闭叶 `id`:兄弟子树顶替父 `Split`;只剩根叶时保留不删返回 + /// `false`,叶不存在时同样返回 `false`。关闭的叶若为 active, + /// active 指向顶替兄弟的首叶(就近存活叶)。 + pub fn close_leaf(&mut self, id: PaneId) -> bool { + if matches!(self.root, Some(PaneNode::Leaf(root_id)) if root_id == id) { + return false; + } + let fallback = match self.root.as_mut() { + Some(root) => delete_leaf(root, id), + None => None, + }; + let Some(fallback) = fallback else { + return false; + }; + self.locked.remove(&id); + if self.active == Some(id) { + self.active = Some(fallback); + } + true + } + + /// 按叶子归属定位其父 `Split` 并更新比例(钳制在 `0.1..=0.9`); + /// 根叶或叶不存在时返回 `false`。 + pub fn set_ratio(&mut self, leaf: PaneId, ratio: f32) -> bool { + let parent = self + .root + .as_mut() + .and_then(|root| find_parent_split_mut(root, leaf)); + match parent { + Some(PaneNode::Split { ratio: slot, .. }) => { + *slot = clamp_ratio(ratio); + true + } + _ => false, + } + } + + /// 叶存在时设为 active 并返回 `true`,否则保持原值返回 `false`。 + pub fn set_active(&mut self, id: PaneId) -> bool { + if self.leaves().contains(&id) { + self.active = Some(id); + true + } else { + false + } + } + + /// 翻转叶 `id` 的锁定态并返回翻转后状态;叶不存在时不记录返回 `false`。 + pub fn toggle_lock(&mut self, id: PaneId) -> bool { + if !self.leaves().contains(&id) { + return false; + } + if self.locked.contains(&id) { + self.locked.remove(&id); + false + } else { + self.locked.insert(id); + true + } + } + + /// 叶 `id` 是否被锁定(不存在的叶返回 `false`)。 + pub fn is_locked(&self, id: PaneId) -> bool { + self.locked.contains(&id) + } + + /// 新文件路由目标:active 存在且未锁定时返回 active,否则返回 + /// 第一个未锁定叶;全部锁定时返回 `None`。 + pub fn route_target(&self) -> Option { + let leaves = self.leaves(); + if let Some(active) = self.active { + if leaves.contains(&active) && !self.locked.contains(&active) { + return Some(active); + } + } + leaves.into_iter().find(|id| !self.locked.contains(id)) + } +} + +/// 空树默认值(`root` 为空,`active` 为空);常规入口仍用 [`PaneTree::new`]。 +impl Default for PaneTree { + fn default() -> Self { + Self { + root: None, + next_id: 0, + active: None, + locked: HashSet::new(), + } + } +} + +/// 比例钳制到 `MIN_RATIO..=MAX_RATIO`。 +fn clamp_ratio(ratio: f32) -> f32 { + ratio.clamp(MIN_RATIO, MAX_RATIO) +} + +/// 先序收集叶 id。 +fn collect_leaves(node: &PaneNode, out: &mut Vec) { + match node { + PaneNode::Leaf(id) => out.push(*id), + PaneNode::Split { first, second, .. } => { + collect_leaves(first, out); + collect_leaves(second, out); + } + } +} + +/// 子树首叶(先序第一个叶)。 +fn first_leaf(node: &PaneNode) -> Option { + match node { + PaneNode::Leaf(id) => Some(*id), + PaneNode::Split { first, .. } => first_leaf(first), + } +} + +/// 在子树中定位叶 `id` 的可写槽。 +fn find_leaf_mut(node: &mut PaneNode, id: PaneId) -> Option<&mut PaneNode> { + match node { + PaneNode::Leaf(leaf_id) if *leaf_id == id => Some(node), + PaneNode::Leaf(_) => None, + PaneNode::Split { first, second, .. } => { + find_leaf_mut(first, id).or_else(|| find_leaf_mut(second, id)) + } + } +} + +/// 叶 `id` 的父 `Split`(根叶无父,返回 `None`)。 +/// +/// 先用不可变借用判直属(借用即时结束),再决定返回 `node` 或向下递归, +/// 避免可变借用与返回值的生命周期冲突。 +fn find_parent_split_mut(node: &mut PaneNode, leaf: PaneId) -> Option<&mut PaneNode> { + fn is_leaf_id(node: &PaneNode, leaf: PaneId) -> bool { + matches!(node, PaneNode::Leaf(id) if *id == leaf) + } + let is_direct_child = match node { + PaneNode::Leaf(_) => return None, + PaneNode::Split { first, second, .. } => { + is_leaf_id(first, leaf) || is_leaf_id(second, leaf) + } + }; + if is_direct_child { + return Some(node); + } + match node { + PaneNode::Leaf(_) => None, + PaneNode::Split { first, second, .. } => { + find_parent_split_mut(first, leaf).or_else(|| find_parent_split_mut(second, leaf)) + } + } +} + +/// 删除子树中的叶 `id`,兄弟子树顶替父 `Split`;成功返回顶替兄弟的首叶。 +fn delete_leaf(node: &mut PaneNode, id: PaneId) -> Option { + let PaneNode::Split { first, second, .. } = node else { + return None; + }; + if matches!(**first, PaneNode::Leaf(leaf_id) if leaf_id == id) { + let fallback = first_leaf(second); + let mut empty = PaneNode::Leaf(0); + std::mem::swap(&mut empty, second); + *node = empty; + return fallback; + } + if matches!(**second, PaneNode::Leaf(leaf_id) if leaf_id == id) { + let fallback = first_leaf(first); + let mut empty = PaneNode::Leaf(0); + std::mem::swap(&mut empty, first); + *node = empty; + return fallback; + } + delete_leaf(first, id).or_else(|| delete_leaf(second, id)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 拆分:原叶变 `first`,新叶为 `second`,比例 0.5。 + #[test] + fn split_leaf_appends_second_leaf() { + let mut tree = PaneTree::new(); + assert_eq!(tree.split_leaf(0, SplitDir::Horizontal), Some(1)); + assert_eq!(tree.leaves(), vec![0, 1]); + assert!(matches!( + tree.root, + Some(PaneNode::Split { + dir: SplitDir::Horizontal, + ratio, + .. + }) if ratio == 0.5 + )); + assert_eq!(tree.split_leaf(99, SplitDir::Vertical), None); + } + + /// 关闭:兄弟顶替父节点;只剩根叶时保留不删返回 `false`。 + #[test] + fn close_leaf_collapses_sibling_and_keeps_last_root() { + let mut tree = PaneTree::new(); + let second = tree.split_leaf(0, SplitDir::Vertical).unwrap(); + assert!(!tree.close_leaf(99)); + assert!(tree.close_leaf(second)); + assert_eq!(tree.leaves(), vec![0]); + assert!(!tree.close_leaf(0)); + assert_eq!(tree.leaves(), vec![0]); + } + + /// 关闭 active 叶时 active 跟随到就近存活叶。 + #[test] + fn close_active_leaf_moves_active_to_survivor() { + let mut tree = PaneTree::new(); + let second = tree.split_leaf(0, SplitDir::Horizontal).unwrap(); + assert!(tree.set_active(second)); + assert!(tree.close_leaf(second)); + assert_eq!(tree.active(), Some(0)); + assert_eq!(tree.route_target(), Some(0)); + } + + /// 路由:active 未锁定返回 active;锁定后回退首个未锁叶;全锁返回 `None`。 + #[test] + fn route_target_prefers_unlocked_active_then_falls_back() { + let mut tree = PaneTree::new(); + let second = tree.split_leaf(0, SplitDir::Horizontal).unwrap(); + assert_eq!(tree.route_target(), Some(0)); + assert!(tree.toggle_lock(0)); + assert!(tree.is_locked(0)); + assert_eq!(tree.route_target(), Some(second)); + tree.toggle_lock(second); + assert_eq!(tree.route_target(), None); + } + + /// 比例钳制在 `0.1..=0.9`。 + #[test] + fn set_ratio_clamps_to_bounds() { + let mut tree = PaneTree::new(); + tree.split_leaf(0, SplitDir::Horizontal).unwrap(); + assert!(tree.set_ratio(0, 10.0)); + assert_eq!(root_ratio(&tree), Some(0.9)); + assert!(tree.set_ratio(1, -2.0)); + assert_eq!(root_ratio(&tree), Some(0.1)); + assert!(tree.set_ratio(0, 0.3)); + assert_eq!(root_ratio(&tree), Some(0.3)); + } + + /// 测试辅助:读取根 `Split` 的比例(只读私有字段,单测内可访问)。 + fn root_ratio(tree: &PaneTree) -> Option { + match &tree.root { + Some(PaneNode::Split { ratio, .. }) => Some(*ratio), + _ => None, + } + } +} diff --git a/linux/src/workbench/tab_menu.rs b/linux/src/workbench/tab_menu.rs index e13142cd2..656403ada 100644 --- a/linux/src/workbench/tab_menu.rs +++ b/linux/src/workbench/tab_menu.rs @@ -1,27 +1,55 @@ //! 标签页右键菜单:Windows `tab-context-menu.tsx` 按键帽外子集。 //! -//! 顺序固定为关闭/关闭其他/关闭到右侧/关闭全部|复制路径/复制相对路径/ -//! 在文件管理器中显示/在终端中打开/重新加载。文案经 +//! 顺序固定为固定/取消固定|向右拆分/向下拆分|锁定组| +//! 关闭/关闭其他/关闭到右侧/关闭全部| +//! 复制路径/复制相对路径/在文件管理器中显示/在终端中打开/重新加载。文案经 //! `crate::i18n::menu_text` 取 `tabs.*` 与 `files.*`,缺 key 回退为空。 //! 触发与显隐走上游 `ContextMenuExt::context_menu`(右键冒泡打开,点选或点外 //! 自动关闭,浮层 absolute 不占布局);`EditorView::context_menu_tab` 只做 //! “右键目标”记账,右键按下时记 `idx`,选中动作与点外关闭时清 `None`。 +use std::rc::Rc; + use gpui_kit::assets::IconName; use gpui_kit::component::h_flex; use gpui_kit::component::menu::{PopupMenu, PopupMenuItem}; -use gpui_kit::{div, ClipboardItem, Context, Entity, ParentElement as _, Styled as _, Window}; +use gpui_kit::{div, App, ClipboardItem, Context, Entity, ParentElement as _, Styled as _, Window}; use super::editor::{EditorTabEvent, EditorView}; +use super::panes::{PaneId, SplitDir}; use crate::theme::ThemeColors; +/// 拆分回调:`Fn(pane_id, dir, window, cx)`,由工作台注入(内部经 workbench +/// entity 回写 `split_pane`),菜单点击时触发(`window` 供新建编辑器用)。 +pub type SplitCallback = Rc; +/// 锁定回调:`Fn(pane_id, cx)`,由工作台注入(内部经 workbench entity 回写 +/// `toggle_lock`),菜单点击时触发。 +pub type ToggleLockCallback = Rc; + /// 右键菜单构造器:`view` 与 `idx` 以 owned 方式 move 进 `Fn` 闭包 /// (`context_menu` 的 builder 要求 `Fn + 'static`,每次打开时调用)。 +/// `pane_id` 为标签所在窗格,`is_locked` 决定锁定项文案;拆分/锁定动作经 +/// `on_split` / `on_toggle_lock` 回工作台执行,本层不直接改窗格树。 pub fn tab_context_menu( view: Entity, idx: usize, + pane_id: PaneId, + is_locked: bool, + on_split: SplitCallback, + on_toggle_lock: ToggleLockCallback, ) -> impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static { - move |menu, _window, cx| build_tab_menu(menu, cx, &view, idx) + move |menu, _window, cx| { + build_tab_menu( + menu, + cx, + &view, + idx, + pane_id, + is_locked, + &on_split, + &on_toggle_lock, + ) + } } /// 取绝对路径父目录(终端打开与文件管理器显示共用)。 @@ -39,7 +67,76 @@ fn build_tab_menu( cx: &mut Context, view: &Entity, idx: usize, + pane_id: PaneId, + is_locked: bool, + on_split: &SplitCallback, + on_toggle_lock: &ToggleLockCallback, ) -> PopupMenu { + // ---- 固定组(Windows `pin` 首项,其后分隔线;`IconName` 无 Pin 变体故不用图标) ---- + let is_pinned = view.read(cx).tabs.get(idx).is_some_and(|tab| tab.is_pinned); + let v = view.clone(); + let pin_label = if is_pinned { + crate::i18n::menu_text(cx, "tabs.unpin").to_string() + } else { + crate::i18n::menu_text(cx, "tabs.pin").to_string() + }; + menu = menu.item(PopupMenuItem::new(pin_label).on_click(move |_, _, cx| { + v.update(cx, |this, cx| { + this.toggle_pin_at(idx, cx); + this.context_menu_tab = None; + cx.notify(); + }); + })); + + menu = menu.separator(); + + // ---- 拆分组(Windows `split-right/split-down`,其后分隔线) ---- + let cb = on_split.clone(); + let v = view.clone(); + let split_right_label = crate::i18n::menu_text(cx, "tabs.splitRight").to_string(); + menu = menu.item( + PopupMenuItem::new(split_right_label).on_click(move |_, window, cx| { + cb(pane_id, SplitDir::Horizontal, window, cx); + v.update(cx, |this, cx| { + this.context_menu_tab = None; + cx.notify(); + }); + }), + ); + + let cb = on_split.clone(); + let v = view.clone(); + let split_down_label = crate::i18n::menu_text(cx, "tabs.splitDown").to_string(); + menu = menu.item( + PopupMenuItem::new(split_down_label).on_click(move |_, window, cx| { + cb(pane_id, SplitDir::Vertical, window, cx); + v.update(cx, |this, cx| { + this.context_menu_tab = None; + cx.notify(); + }); + }), + ); + + menu = menu.separator(); + + // ---- 锁定组(Windows `toggle-editor-group-lock`,其后分隔线) ---- + let cb = on_toggle_lock.clone(); + let v = view.clone(); + let lock_label = if is_locked { + crate::i18n::menu_text(cx, "tabs.unlockEditorGroup").to_string() + } else { + crate::i18n::menu_text(cx, "tabs.lockEditorGroup").to_string() + }; + menu = menu.item(PopupMenuItem::new(lock_label).on_click(move |_, _, cx| { + cb(pane_id, cx); + v.update(cx, |this, cx| { + this.context_menu_tab = None; + cx.notify(); + }); + })); + + menu = menu.separator(); + // ---- 关闭组(Windows `close/close-others/close-right/close-all`) ---- // 关闭带右侧 `Ctrl+W` 展示文本,用 element 自定义行(仿 `toolbar.rs`)。 let v = view.clone(); diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index 0d83075da..570e61f77 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -3,15 +3,19 @@ //! 结构对齐 Tauri `MainLayout`:顶栏 / 多项目标签条 / 活动栏 + 侧边栏 + 编辑区 + //! 右侧插件活动栏 / 底部面板 / 状态栏;无项目时显示欢迎页,浮层由模态状态控制。 -use gpui_kit::component::resizable::{h_resizable, resizable_panel}; -use gpui_kit::component::{h_flex, v_flex}; +use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::resizable::{h_resizable, resizable_panel, v_resizable, ResizableState}; +use gpui_kit::component::{h_flex, v_flex, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, AppContext as _, Context, Entity, FocusHandle, InteractiveElement as _, IntoElement, - KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, - Render, Styled as _, Subscription, Window, + div, px, AnyElement, App, AppContext as _, Context, Entity, FocusHandle, + InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, + MouseMoveEvent, MouseUpEvent, ParentElement as _, Render, Styled as _, Subscription, Window, }; +use std::collections::HashMap; +use std::rc::Rc; + use crate::core::CoreClient; use crate::settings; use crate::theme::ThemeColors; @@ -26,12 +30,14 @@ use crate::workbench::extensions_panel::{ExtensionsEvent, ExtensionsView}; use crate::workbench::go_to_line::{GoToLineEvent, GoToLineModal}; use crate::workbench::maven::{MavenEvent, MavenView}; use crate::workbench::notifications::{NotificationsEvent, NotificationsView}; +use crate::workbench::panes::{PaneId, PaneNode, PaneTree, SplitDir}; use crate::workbench::project_dialog::{ProjectDialog, ProjectDialogEvent, ProjectDialogMode}; use crate::workbench::quick_open::{QuickOpenEvent, QuickOpenModal}; use crate::workbench::search_everywhere::{SearchEverywhereEvent, SearchEverywhereModal}; use crate::workbench::settings_dialog::{SettingsCategory, SettingsDialog, SettingsEvent}; use crate::workbench::sidebar::{FileEntry, SidebarEvent, SidebarTab, SidebarView}; use crate::workbench::status_bar::{StatusBarEvent, StatusBarView}; +use crate::workbench::tab_menu::{SplitCallback, ToggleLockCallback}; use crate::workbench::toolbar::{ToolbarEvent, ToolbarView}; use crate::workbench::welcome_screen::{WelcomeEvent, WelcomeScreenView}; @@ -97,8 +103,10 @@ pub struct WorkbenchView { pending_goto_line: Option, /// 侧边栏面板 (Files / Git / Search) pub sidebar: Entity, - /// 主代码编辑器区 - pub editor: Entity, + /// 多窗格布局模型(`Split{Horizontal}` 左右并排,`{Vertical}` 上下堆叠) + pub pane_tree: PaneTree, + /// 各窗格编辑器实体(`PaneId` → `EditorView`) + pub pane_editors: HashMap>, /// 底部抽屉面板 (Terminal / Diagnostics,对齐 Tauri BottomPaneTab) pub bottom_panel: Entity, /// 底部状态栏 (Status Bar) @@ -141,7 +149,12 @@ impl WorkbenchView { let activity_rail = cx.new(|cx| ActivityRailView::new(cx)); let plugin_rail = cx.new(|_cx| PluginActivityRailView::new()); let sidebar = cx.new(|cx| SidebarView::new(root.clone(), cx)); + let pane_tree = PaneTree::new(); + let initial_pane = pane_tree.active().unwrap_or(0); let editor = cx.new(|cx| EditorView::new(root.clone(), window, cx)); + let mut pane_editors = HashMap::new(); + pane_editors.insert(initial_pane, editor.clone()); + Self::wire_pane_editor(&pane_tree, cx.entity(), initial_pane, &editor, cx); let bottom_panel = cx.new(|cx| BottomPanelView::new(root.clone(), cx)); let status_bar = cx.new(|_cx| StatusBarView::new()); let search_everywhere = cx.new(|cx| SearchEverywhereModal::new(cx)); @@ -177,9 +190,11 @@ impl WorkbenchView { }); } SidebarEvent::NewFile => { - let _ = this.editor.update(cx, |ed, cx| { - ed.open_file("untitled.txt".to_string(), String::new(), cx); - }); + if let Some(routed) = this.routed_editor() { + let _ = routed.update(cx, |ed, cx| { + ed.open_file("untitled.txt".to_string(), String::new(), cx); + }); + } let _ = status_bar_clone.update(cx, |sb, cx| { sb.set_file_info( Some("untitled.txt".to_string()), @@ -232,14 +247,18 @@ impl WorkbenchView { qo.set_files(file_list, cx); }); } - // 已打开标签页同步给快速打开置顶分组(对齐 Tauri `openBufferFiles`)。 - let open_files: Vec = this - .editor - .read(cx) - .tabs - .iter() - .map(|tab| tab.path.clone()) - .collect(); + // 已打开标签页同步给快速打开置顶分组(对齐 Tauri `openBufferFiles`, + // 多窗格取各窗格 tabs 去重并集)。 + let mut open_files: Vec = Vec::new(); + for leaf in this.pane_tree.leaves() { + if let Some(ed) = this.pane_editors.get(&leaf) { + for tab in ed.read(cx).tabs.iter() { + if !open_files.contains(&tab.path) { + open_files.push(tab.path.clone()); + } + } + } + } if !open_files.is_empty() { let _ = quick_open_sync.update(cx, |qo, cx| { qo.set_open_files(open_files, cx); @@ -368,21 +387,27 @@ impl WorkbenchView { &toolbar, |this, _toolbar, event: &ToolbarEvent, cx| match event { ToolbarEvent::NewFile => { - let _ = this.editor.update(cx, |ed, cx| { - ed.open_file("untitled.txt".to_string(), String::new(), cx); - }); + if let Some(active) = this.active_editor() { + let _ = active.update(cx, |ed, cx| { + ed.open_file("untitled.txt".to_string(), String::new(), cx); + }); + } } ToolbarEvent::Save => { - let _ = this.editor.update(cx, |ed, cx| { - ed.save_active(cx); - }); + if let Some(active) = this.active_editor() { + let _ = active.update(cx, |ed, cx| { + ed.save_active(cx); + }); + } } ToolbarEvent::CloseTab => { - let _ = this.editor.update(cx, |ed, cx| { - if let Some(idx) = ed.active_tab_index { - ed.close_tab(idx, cx); - } - }); + if let Some(active) = this.active_editor() { + let _ = active.update(cx, |ed, cx| { + if let Some(idx) = ed.active_tab_index { + ed.close_tab(idx, cx); + } + }); + } } ToolbarEvent::ToggleSidebar => { this.toggle_sidebar(cx); @@ -749,7 +774,8 @@ impl WorkbenchView { notifications, extensions, sidebar, - editor, + pane_tree, + pane_editors, bottom_panel, status_bar, search_everywhere, @@ -1000,26 +1026,144 @@ impl WorkbenchView { } } + /// 活动窗格 id(`pane_tree.active()` 直读)。 + fn active_pane_id(&self) -> Option { + self.pane_tree.active() + } + + /// 活动窗格编辑器(map 查 active,未命中 `None`)。 + fn active_editor(&self) -> Option> { + self.active_pane_id() + .and_then(|id| self.pane_editors.get(&id).cloned()) + } + + /// 新文件路由编辑器(`route_target()` 落 map,未命中回退 active; + /// 锁定窗格自动跳过由模型保证)。 + fn routed_editor(&self) -> Option> { + if let Some(id) = self.pane_tree.route_target() { + if let Some(ed) = self.pane_editors.get(&id).cloned() { + return Some(ed); + } + } + self.active_editor() + } + + /// 活动编辑器上执行动作(无活动窗格时忽略)。 + fn with_active_editor( + &self, + cx: &mut Context, + f: impl FnOnce(&mut EditorView, &mut Context), + ) { + if let Some(ed) = self.active_editor() { + let _ = ed.update(cx, f); + } + } + + /// 为窗格编辑器装配 pane 归属与拆分/锁定回调(回调经 workbench entity + /// 回写,保证右键菜单 builder 的 `'static` 要求)。 + fn wire_pane_editor( + pane_tree: &PaneTree, + workbench: Entity, + pane_id: PaneId, + editor: &Entity, + cx: &mut Context, + ) { + let wb = workbench.clone(); + let on_split: SplitCallback = Rc::new(move |pid, dir, window, cx: &mut App| { + let _ = wb.update(cx, |this, cx| { + this.split_pane(pid, dir, window, cx); + }); + }); + let wb = workbench; + let on_toggle_lock: ToggleLockCallback = Rc::new(move |pid, cx: &mut App| { + let _ = wb.update(cx, |this, cx| { + this.toggle_pane_lock(pid, cx); + }); + }); + let locked = pane_tree.is_locked(pane_id); + let _ = editor.update(cx, |ed, cx| { + ed.pane_id = Some(pane_id); + ed.pane_locked = locked; + ed.on_split = Some(on_split); + ed.on_toggle_lock = Some(on_toggle_lock); + cx.notify(); + }); + } + + /// 拆分窗格:新建空 `EditorView` + `split_leaf` + 订阅其标签事件; + /// 新窗格留空标签。 + fn split_pane( + &mut self, + pane_id: PaneId, + dir: SplitDir, + window: &mut Window, + cx: &mut Context, + ) { + let Some(new_id) = self.pane_tree.split_leaf(pane_id, dir) else { + return; + }; + let root = self.workspace_root.clone(); + let new_editor = cx.new(|cx| EditorView::new(root, window, cx)); + Self::wire_pane_editor(&self.pane_tree, cx.entity(), new_id, &new_editor, cx); + self.pane_editors.insert(new_id, new_editor.clone()); + let sub = cx.subscribe( + &new_editor, + |this, _ed, event: &EditorTabEvent, cx| match event { + EditorTabEvent::OpenInTerminal { dir } => { + this.send_terminal_command(&format!("cd \"{dir}\""), cx); + } + }, + ); + self._subscriptions.push(sub); + cx.notify(); + } + + /// 关闭窗格:`close_leaf` + 从 map 移除 editor;根叶返回 `false` 时忽略。 + fn close_pane(&mut self, pane_id: PaneId, cx: &mut Context) { + if self.pane_tree.close_leaf(pane_id) { + self.pane_editors.remove(&pane_id); + cx.notify(); + } + } + + /// 翻转窗格锁定并同步编辑器锁定镜像。 + fn toggle_pane_lock(&mut self, pane_id: PaneId, cx: &mut Context) { + self.pane_tree.toggle_lock(pane_id); + if let Some(ed) = self.pane_editors.get(&pane_id).cloned() { + let locked = self.pane_tree.is_locked(pane_id); + let _ = ed.update(cx, |ed, cx| { + ed.pane_locked = locked; + cx.notify(); + }); + } + cx.notify(); + } + /// 持有 `&mut Window` 调用方的编辑器动作直达通道(按键监听等已有 - /// window 的路径使用,避免经窗口句柄二次 `update` 的重入借用失败)。 + /// window 的路径使用,避免经窗口句柄二次 `update` 的重入借用失败); + /// 目标为活动窗格编辑器。 fn editor_direct( &mut self, window: &mut Window, cx: &mut Context, action: impl FnOnce(&mut EditorView, &mut Window, &mut Context), ) { - let _ = self.editor.update(cx, |ed, cx| action(ed, window, cx)); + if let Some(ed) = self.active_editor() { + let _ = ed.update(cx, |ed, cx| action(ed, window, cx)); + } } /// 无 `window` 上下文的编辑器动作经窗口句柄下发(菜单/面板事件路径, /// 非重入场景)。`active_window` 仅反映平台聚焦,Xvfb 下可能为 None, - /// 单窗口应用退化到 `windows()` 首个句柄。 + /// 单窗口应用退化到 `windows()` 首个句柄;目标为活动窗格编辑器。 fn editor_window_action( &self, cx: &mut Context, action: impl FnOnce(&mut EditorView, &mut Window, &mut Context) + 'static, ) { - let editor = self.editor.clone(); + let Some(editor) = self.active_editor() else { + return; + }; let handle = cx .active_window() .or_else(|| cx.windows().into_iter().next()); @@ -1040,8 +1184,11 @@ impl WorkbenchView { /// 目标在工作区内时经 core `file.write`(仿 `save_active`);工作区外 /// core 会拒绝绝对路径,改用 `std::fs` 直写。 fn save_active_as(&mut self, cx: &mut Context) { + let Some(active) = self.active_editor() else { + return; + }; let (old_path, text) = { - let ed = self.editor.read(cx); + let ed = active.read(cx); let Some(idx) = ed.active_tab_index else { return; }; @@ -1069,7 +1216,7 @@ impl WorkbenchView { let root = self.workspace_root.clone(); if let Ok(rel) = dest.strip_prefix(std::path::Path::new(&root)) { let rel = rel.to_string_lossy().to_string(); - let editor = self.editor.clone(); + let editor = active.clone(); let client = self.client.clone(); cx.spawn(async move |_this, cx| { if client.write_file(&cx, &root, &rel, &text).await.is_ok() { @@ -1090,7 +1237,7 @@ impl WorkbenchView { .detach(); } else if std::fs::write(&dest, text.as_bytes()).is_ok() { let abs = dest.to_string_lossy().to_string(); - let _ = self.editor.update(cx, |ed, cx| { + let _ = active.update(cx, |ed, cx| { if let Some(idx) = ed.active_tab_index { if let Some(t) = ed.tabs.get_mut(idx) { if t.path == old_path { @@ -1107,8 +1254,11 @@ impl WorkbenchView { /// 还原文件:重读活动文件内容并灌回编辑器(同路径 `open_file` 会刷新内容并清除脏标记)。 fn revert_active_file(&mut self, cx: &mut Context) { + let Some(active) = self.active_editor() else { + return; + }; let path = { - let ed = self.editor.read(cx); + let ed = active.read(cx); let Some(idx) = ed.active_tab_index else { return; }; @@ -1119,14 +1269,14 @@ impl WorkbenchView { }; if std::path::Path::new(&path).is_absolute() { if let Ok(text) = std::fs::read_to_string(&path) { - let _ = self.editor.update(cx, |ed, cx| { + let _ = active.update(cx, |ed, cx| { ed.open_file(path, text, cx); }); } return; } let root = self.workspace_root.clone(); - let editor = self.editor.clone(); + let editor = active.clone(); let client = self.client.clone(); cx.spawn(async move |_this, cx| { if let Ok(text) = client.read_file(&cx, &root, &path).await { @@ -1159,17 +1309,17 @@ impl WorkbenchView { } match action_id { "workbench.new_file" | "file.new_file" | "file.new_tab" => { - let _ = self.editor.update(cx, |ed, cx| { + self.with_active_editor(cx, |ed, cx| { ed.open_file("untitled.txt".to_string(), String::new(), cx); }); } "workbench.save" | "file.save" => { - let _ = self.editor.update(cx, |ed, cx| { + self.with_active_editor(cx, |ed, cx| { ed.save_active(cx); }); } "workbench.close_tab" | "file.close_editor" => { - let _ = self.editor.update(cx, |ed, cx| { + self.with_active_editor(cx, |ed, cx| { if let Some(idx) = ed.active_tab_index { ed.close_tab(idx, cx); } @@ -1313,7 +1463,7 @@ impl WorkbenchView { self.save_active_as(cx); } "file.save_all" => { - let _ = self.editor.update(cx, |ed, cx| { + self.with_active_editor(cx, |ed, cx| { ed.save_all_tabs(cx); }); } @@ -1321,32 +1471,32 @@ impl WorkbenchView { self.revert_active_file(cx); } "file.close_all" => { - let _ = self.editor.update(cx, |ed, cx| { + self.with_active_editor(cx, |ed, cx| { ed.close_all_tabs(cx); }); } "file.close_others" => { - let _ = self.editor.update(cx, |ed, cx| { + self.with_active_editor(cx, |ed, cx| { ed.close_other_tabs(cx); }); } "file.close_saved" => { - let _ = self.editor.update(cx, |ed, cx| { + self.with_active_editor(cx, |ed, cx| { ed.close_saved_tabs(cx); }); } "file.close_left" => { - let _ = self.editor.update(cx, |ed, cx| { + self.with_active_editor(cx, |ed, cx| { ed.close_tabs_to_left(cx); }); } "file.close_right" => { - let _ = self.editor.update(cx, |ed, cx| { + self.with_active_editor(cx, |ed, cx| { ed.close_tabs_to_right(cx); }); } "file.reopen_closed" => { - let _ = self.editor.update(cx, |ed, cx| { + self.with_active_editor(cx, |ed, cx| { ed.reopen_closed_tab(cx); }); } @@ -1361,7 +1511,7 @@ impl WorkbenchView { self.editor_window_action(cx, |ed, window, cx| ed.cut(window, cx)); } "edit.copy" => { - let _ = self.editor.update(cx, |ed, cx| { + self.with_active_editor(cx, |ed, cx| { ed.copy(cx); }); } @@ -1391,12 +1541,12 @@ impl WorkbenchView { self.open_go_to_line(cx); } "go.next_tab" => { - let _ = self.editor.update(cx, |ed, cx| { + self.with_active_editor(cx, |ed, cx| { ed.goto_next_tab(cx); }); } "go.prev_tab" => { - let _ = self.editor.update(cx, |ed, cx| { + self.with_active_editor(cx, |ed, cx| { ed.goto_prev_tab(cx); }); } @@ -1476,21 +1626,37 @@ impl WorkbenchView { } } - /// 打开指定文件:对接 `lithe-core` 的 `read_file` 并更新编辑器 + /// 打开指定文件:对接 `lithe-core` 的 `read_file` 并更新路由窗格编辑器 + /// (锁定窗格自动跳过由模型保证);成功后将 active 设到路由窗格。 pub fn open_file(&mut self, relative_path: &str, cx: &mut Context) { let root = self.workspace_root.clone(); let path = relative_path.to_string(); - let editor = self.editor.clone(); + let target_pane = self + .pane_tree + .route_target() + .or_else(|| self.pane_tree.active()); + let editor = target_pane + .and_then(|id| self.pane_editors.get(&id).cloned()) + .or_else(|| self.active_editor()); + let Some(editor) = editor else { + return; + }; let client = self.client.clone(); - cx.spawn(async move |_this, cx| { + cx.spawn(async move |this, cx| { let task = client.read_file(&cx, &root, &path); match task.await { Ok(text) => { let _ = editor.update(cx, |ed, cx| { - ed.open_file(path, text, cx); + ed.open_file(path.clone(), text, cx); }); + if let Some(pid) = target_pane { + let _ = this.update(cx, |this, cx| { + this.pane_tree.set_active(pid); + cx.notify(); + }); + } } Err(err) => { let err_msg = format!("// 读取文件失败: {}\n// 错误信息: {}", path, err); @@ -1504,6 +1670,14 @@ impl WorkbenchView { } } +/// 子树首叶(`h/v_resizable` 分组 id 与 ratio 回写定位用)。 +fn pane_first_leaf(node: &PaneNode) -> PaneId { + match node { + PaneNode::Leaf(id) => *id, + PaneNode::Split { first, .. } => pane_first_leaf(first), + } +} + /// 活动栏视图 id → 侧边栏标签。 fn sidebar_tab_for(view_id: &str) -> Option { match view_id { @@ -1532,14 +1706,18 @@ fn this_sync_files(this: &WorkbenchView, cx: &mut Context) { if let Some(root_node) = &this.sidebar.read(cx).root_node { WorkbenchView::collect_all_file_paths(root_node, &mut file_list); } - // 已打开标签页路径同步给快速打开的置顶分组(对齐 Tauri `openBufferFiles`)。 - let open_files: Vec = this - .editor - .read(cx) - .tabs - .iter() - .map(|tab| tab.path.clone()) - .collect(); + // 已打开标签页路径同步给快速打开的置顶分组(对齐 Tauri `openBufferFiles`, + // 多窗格取各窗格 tabs 去重并集)。 + let mut open_files: Vec = Vec::new(); + for leaf in this.pane_tree.leaves() { + if let Some(ed) = this.pane_editors.get(&leaf) { + for tab in ed.read(cx).tabs.iter() { + if !open_files.contains(&tab.path) { + open_files.push(tab.path.clone()); + } + } + } + } if !file_list.is_empty() { let _ = this.search_everywhere.update(cx, |search, cx| { search.set_files(file_list.clone(), cx); @@ -1559,11 +1737,13 @@ impl Render for WorkbenchView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let show_status_bar = settings::get(cx).show_status_bar; - // 通知诊断行点击的待跳转:文件加载完成后跳到指定行(1 起)。 + // 通知诊断行点击的待跳转:文件加载完成后跳到指定行(1 起,活动窗格)。 if let Some(line) = self.pending_goto_line.take() { - let _ = self.editor.update(cx, |ed, cx| { - ed.go_to_line(line, window, cx); - }); + if let Some(active) = self.active_editor() { + let _ = active.update(cx, |ed, cx| { + ed.go_to_line(line, window, cx); + }); + } } // 右侧铃铛角标与通知未读数同步(变化时才 notify,避免渲染循环)。 { @@ -1757,7 +1937,7 @@ impl WorkbenchView { .w_full() .min_h_0() .bg(ThemeColors::background()) - .child(self.editor.clone()), + .child(self.render_editor_area(cx)), ) .when(bottom_visible, |layout| layout.child(bottom_splitter)) .when(bottom_visible, |layout| { @@ -1811,6 +1991,100 @@ impl WorkbenchView { }) } + /// 编辑区:窗格树递归渲染(空树回退空占位)。 + fn render_editor_area(&self, cx: &mut Context) -> AnyElement { + match self.pane_tree.root().cloned() { + Some(root) => self.render_pane_node(&root, cx), + None => div().size_full().into_any_element(), + } + } + + /// 递归渲染窗格节点:`Split{Horizontal}` 左右并排(`h_resizable`), + /// `{Vertical}` 上下堆叠(`v_resizable`);拖拽比例经 `set_ratio` 回写。 + fn render_pane_node(&self, node: &PaneNode, cx: &mut Context) -> AnyElement { + match node { + PaneNode::Leaf(id) => self.render_pane_leaf(*id, cx), + PaneNode::Split { + dir, first, second, .. + } => { + let first_el = self.render_pane_node(first, cx); + let second_el = self.render_pane_node(second, cx); + let group_id = format!( + "pane-split-{}-{}", + pane_first_leaf(first), + pane_first_leaf(second) + ); + let ratio_leaf = pane_first_leaf(first); + let view = cx.entity(); + let on_resize = move |state: &Entity, _: &mut Window, cx: &mut App| { + let sizes = state.read(cx).sizes().clone(); + if sizes.len() < 2 { + return; + } + let total = sizes[0].as_f32() + sizes[1].as_f32(); + if total <= 0.0 { + return; + } + let ratio = sizes[0].as_f32() / total; + let _ = view.update(cx, |this, cx| { + this.pane_tree.set_ratio(ratio_leaf, ratio); + cx.notify(); + }); + }; + if *dir == SplitDir::Horizontal { + h_resizable(group_id) + .on_resize(on_resize) + .child(resizable_panel().child(first_el)) + .child(resizable_panel().child(second_el)) + .into_any_element() + } else { + v_resizable(group_id) + .on_resize(on_resize) + .child(resizable_panel().child(first_el)) + .child(resizable_panel().child(second_el)) + .into_any_element() + } + } + } + } + + /// 渲染单个窗格叶:对应 editor;整个叶包左键设 active;空标签叶居中 + /// 显示关闭窗格按钮(根叶关闭返回 `false` 时忽略)。 + fn render_pane_leaf(&self, id: PaneId, cx: &mut Context) -> AnyElement { + let Some(editor) = self.pane_editors.get(&id).cloned() else { + return div().size_full().into_any_element(); + }; + let is_empty = editor.read(cx).tabs.is_empty(); + let base = v_flex() + .size_full() + .min_w_0() + .min_h_0() + .bg(ThemeColors::background()) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _event: &MouseDownEvent, _window, cx| { + this.pane_tree.set_active(id); + cx.notify(); + }), + ); + if is_empty { + base.items_center() + .justify_center() + .child( + Button::new(format!("close-pane-{id}")) + .small() + .ghost() + .label(crate::i18n::menu_text(cx, "ui.close").to_string()) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.close_pane(id, cx); + })), + ) + .into_any_element() + } else { + base.child(editor).into_any_element() + } + } + fn render_sidebar_splitter(&self, cx: &mut Context) -> impl IntoElement { div() .id("sidebar-splitter") From ed49c7ba25282227f7fdad31c1f01b185edf9f41 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 00:44:52 +0800 Subject: [PATCH 037/109] =?UTF-8?q?fix(linux):=20=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E9=A1=B5=E5=AF=B9=E9=BD=90=20Tauri=20=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E4=BE=A7=E6=A0=8F=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 9 分类签带计数(搜索命中内计数),替代 5 项下拉 - 行点选代替行内开关;未启用行内加号一键启用 - 列表下加详情区:徽标/发布者/版本/启用停用/贡献项 - 文案键与 locale.ts 同键;删无用 has_run_history --- linux/src/i18n.rs | 21 + linux/src/workbench/bottom_panel.rs | 5 - linux/src/workbench/extensions_panel.rs | 637 ++++++++++++++++++------ 3 files changed, 493 insertions(+), 170 deletions(-) diff --git a/linux/src/i18n.rs b/linux/src/i18n.rs index b4a62c637..997877c87 100644 --- a/linux/src/i18n.rs +++ b/linux/src/i18n.rs @@ -180,6 +180,27 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { // ---- 空态 / 面板标题 ---- "diagnostics.empty" => ("未检测到问题", "No problems detected"), "extensions.title" => ("扩展", "Extensions"), + "extensions.all" => ("全部", "All"), + "extensions.languages" => ("语言", "Languages"), + "extensions.themes" => ("主题", "Themes"), + "extensions.iconThemes" => ("图标主题", "Icon Themes"), + "extensions.databases" => ("数据库", "Databases"), + "extensions.ai" => ("AI", "AI"), + "extensions.integrations" => ("集成", "Integrations"), + "extensions.skills" => ("技能", "Skills"), + "extensions.agents" => ("代理", "Agents"), + "extensions.searchPlaceholder" => ("搜索扩展", "Search extensions"), + "extensions.availableCount" => ("{count} 个可用", "{count} available"), + "extensions.installedCount" => ("{count} 个已安装", "{count} installed"), + "extensions.noneFound" => ("未找到扩展。", "No extensions found."), + "extensions.noneSelected" => ("未选择扩展。", "No extension selected."), + "extensions.byPublisher" => ("作者:{publisher}", "By {publisher}"), + "extensions.contributions" => ("贡献项", "Contributions"), + "extensions.installed" => ("已安装", "Installed"), + "extensions.disabled" => ("已禁用", "Disabled"), + "extensions.builtIn" => ("内置", "Built-in"), + "extensions.activate" => ("启用", "Activate"), + "extensions.deactivate" => ("停用", "Deactivate"), "notifications.title" => ("通知", "Notifications"), // ---- Git ---- "git.commit" => ("提交", "Commit"), diff --git a/linux/src/workbench/bottom_panel.rs b/linux/src/workbench/bottom_panel.rs index 6838914f5..237b567f1 100644 --- a/linux/src/workbench/bottom_panel.rs +++ b/linux/src/workbench/bottom_panel.rs @@ -199,11 +199,6 @@ impl BottomPanelView { cx.notify(); } - /// 是否有运行历史;宿主据此决定左侧栏是否展示 maven 项。 - pub fn has_run_history(&self) -> bool { - !self.run_history.is_empty() - } - /// 更新工作目录并重探 Run 工程(替代直接写 `working_dir` 字段)。 pub fn set_working_dir(&mut self, dir: String, cx: &mut Context) { self.working_dir = dir; diff --git a/linux/src/workbench/extensions_panel.rs b/linux/src/workbench/extensions_panel.rs index 8072a6c71..8e6cf0065 100644 --- a/linux/src/workbench/extensions_panel.rs +++ b/linux/src/workbench/extensions_panel.rs @@ -1,11 +1,13 @@ -//! 右侧扩展面板:搜索 + 分类 + 列表 + 启用开关,对齐 Tauri +//! 右侧扩展面板:搜索 + 分类签 + 列表点选 + 详情,对齐 Tauri //! `windows/tauri/src/extensions/ui/components/extensions-sidebar.tsx` -//! 的搜索与分类交互语义。 +//! 的展示语义(标题计数、分类签计数、行点选、详情徽标与主操作)。 //! -//! 平台适配说明:Tauri 的扩展在中央开 buffer 展示详情,Linux 中央是编辑器 -//! Tab,因此扩展列表改走右侧面板;交互保持一致(搜索框过滤、分类下拉、 -//! 列表行、启用开关)。头部 X 只发射 [`ExtensionsEvent::Close`],由外部 -//! (`view.rs`)决定面板显隐,与 [`crate::workbench::maven::MavenView`] 一致。 +//! 平台适配说明:Tauri 的扩展页在中央开 buffer 左右两栏展示,Linux 中央是 +//! 编辑器 Tab 且右侧面板较窄,因此列表与详情纵向堆叠(列表上、详情下); +//! 安装/更新/主题选用等需要远端市场与外观系统的能力暂不提供,主操作仅为 +//! 启用/停用(落盘到 `~/.config/lithe/extensions.json`)。头部 X 只发射 +//! [`ExtensionsEvent::Close`],由外部(`view.rs`)决定面板显隐,与 +//! [`crate::workbench::maven::MavenView`] 一致。 use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -13,38 +15,44 @@ use std::path::{Path, PathBuf}; use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::component::input::{Input, InputEvent, InputState}; -use gpui_kit::component::menu::DropdownMenu as _; use gpui_kit::component::scroll::ScrollableElement as _; use gpui_kit::component::{h_flex, v_flex, Icon, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, AppContext as _, Context, Entity, EventEmitter, FontWeight, InteractiveElement as _, - IntoElement, ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, - Subscription, Window, + div, px, AnyElement, AppContext as _, Context, Entity, EventEmitter, FontWeight, + InteractiveElement as _, IntoElement, ParentElement as _, Render, + StatefulInteractiveElement as _, Styled as _, Subscription, Window, }; use crate::theme::ThemeColors; -/// 扩展分类:Tauri `extensionsActiveTab` 的 Linux 子集(`all` 为过滤器, -/// 其余为归类目标;`icon-theme`/`database`/`skill`/`agent` 暂归 `integration`)。 +/// 扩展分类:与 Tauri `FILTER_TABS` 的 9 个 tab id 逐一对齐。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ExtensionCategory { #[default] All, Language, Theme, + IconTheme, + Database, Ai, Integration, + Skill, + Agent, } impl ExtensionCategory { - /// 分类下拉的固定顺序,与 Tauri `FILTER_TABS` 的主分组顺序一致。 - pub const ALL: [ExtensionCategory; 5] = [ + /// 分类签的固定顺序,与 Tauri `FILTER_TABS` 一致。 + pub const ALL: [ExtensionCategory; 9] = [ ExtensionCategory::All, ExtensionCategory::Language, ExtensionCategory::Theme, + ExtensionCategory::IconTheme, + ExtensionCategory::Database, ExtensionCategory::Ai, ExtensionCategory::Integration, + ExtensionCategory::Skill, + ExtensionCategory::Agent, ]; /// 稳定 id,与 Tauri 的 tab id 对齐。 @@ -53,19 +61,27 @@ impl ExtensionCategory { ExtensionCategory::All => "all", ExtensionCategory::Language => "language", ExtensionCategory::Theme => "theme", + ExtensionCategory::IconTheme => "icon-theme", + ExtensionCategory::Database => "database", ExtensionCategory::Ai => "ai", ExtensionCategory::Integration => "integration", + ExtensionCategory::Skill => "skill", + ExtensionCategory::Agent => "agent", } } - /// 下拉展示文案:`i18n.rs` 无对应键,按约定英文直写。 - pub fn label(self) -> &'static str { + /// 分类签图标:对齐 Tauri 各 tab 图标;`all` 无图标。 + pub fn icon(self) -> Option { match self { - ExtensionCategory::All => "All", - ExtensionCategory::Language => "Languages", - ExtensionCategory::Theme => "Themes", - ExtensionCategory::Ai => "AI", - ExtensionCategory::Integration => "Integrations", + ExtensionCategory::All => None, + ExtensionCategory::Language => Some(IconName::Languages), + ExtensionCategory::Theme => Some(IconName::Paintbrush), + ExtensionCategory::IconTheme => Some(IconName::Package), + ExtensionCategory::Database => Some(IconName::Database), + ExtensionCategory::Ai => Some(IconName::Sparkles), + ExtensionCategory::Integration => Some(IconName::Plug), + ExtensionCategory::Skill => Some(IconName::Brain), + ExtensionCategory::Agent => Some(IconName::Bot), } } @@ -74,14 +90,18 @@ impl ExtensionCategory { "all" => Some(ExtensionCategory::All), "language" => Some(ExtensionCategory::Language), "theme" => Some(ExtensionCategory::Theme), + "icon-theme" => Some(ExtensionCategory::IconTheme), + "database" => Some(ExtensionCategory::Database), "ai" => Some(ExtensionCategory::Ai), "integration" => Some(ExtensionCategory::Integration), + "skill" => Some(ExtensionCategory::Skill), + "agent" => Some(ExtensionCategory::Agent), _ => None, } } } -/// 单个扩展条目:仓库插件扫描结果或内置项。 +/// 单个扩展条目:仓库插件扫描结果或内置项(本地均视为已安装)。 #[derive(Debug, Clone)] pub struct ExtensionItem { /// 稳定 id:`package.json` 的 name、`plugin.json` 的 id 或内置 id。 @@ -90,6 +110,10 @@ pub struct ExtensionItem { pub version: String, pub description: String, pub category: ExtensionCategory, + /// 发布者:`package.json` 的 publisher/author,缺失为 `None`(不展示)。 + pub publisher: Option, + /// 是否随产品发布(对齐 Tauri `isBundled` 徽标)。 + pub is_bundled: bool, } /// 扩展面板派发的事件。 @@ -98,7 +122,7 @@ pub enum ExtensionsEvent { Close, } -/// 右侧扩展面板:搜索框 + 分类下拉 + 列表 + 启用开关。 +/// 右侧扩展面板:搜索框 + 分类签 + 列表点选 + 详情。 pub struct ExtensionsView { items: Vec, /// 启用状态 `{id: bool}`,缺省视为启用,落盘到 @@ -107,6 +131,9 @@ pub struct ExtensionsView { search_input: Option>, _search_subscription: Option, category: ExtensionCategory, + /// 选中的扩展 id(对齐 Tauri `selectedExtensionId`;过滤后不在列表 + /// 则展示回退到首项,不在此处改写)。 + selected: Option, } impl EventEmitter for ExtensionsView {} @@ -119,12 +146,13 @@ impl ExtensionsView { search_input: None, _search_subscription: None, category: ExtensionCategory::All, + selected: None, }; view.rescan(); view } - /// 重新扫描仓库插件(内置项常驻)。 + /// 重新扫描仓库插件(内置项常驻);选中项消失则清空选中。 pub fn rescan(&mut self) { let mut items = builtin_items(); items.extend(scan_plugins()); @@ -132,6 +160,11 @@ impl ExtensionsView { let mut seen = std::collections::HashSet::new(); items.retain(|item| seen.insert(item.id.clone())); items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + if let Some(selected) = self.selected.clone() { + if !items.iter().any(|item| item.id == selected) { + self.selected = None; + } + } self.items = items; } @@ -167,14 +200,11 @@ impl ExtensionsView { .unwrap_or_default() } - /// 搜索 + 分类双重过滤,对齐 Tauri 的 `filteredExtensions`。 - fn filtered_items(&self, query: &str) -> Vec<&ExtensionItem> { + /// 搜索过滤(先搜索后分签,对齐 Tauri `searchMatchedExtensions`)。 + fn search_matched(&self, query: &str) -> Vec<&ExtensionItem> { let q = query.trim().to_lowercase(); self.items .iter() - .filter(|item| { - self.category == ExtensionCategory::All || item.category == self.category - }) .filter(|item| { q.is_empty() || item.name.to_lowercase().contains(&q) @@ -184,99 +214,262 @@ impl ExtensionsView { .collect() } - /// 分类下拉:当前值按钮 + ChevronDown(复用 `settings_dialog.rs` - /// `render_dropdown` 思路:选中项打勾)。 - fn render_category_dropdown(&self, cx: &mut Context) -> impl IntoElement { - let view = cx.entity(); - let current = self.category.as_str(); - let current_label = self.category.label().to_string(); - let options: Vec<(&'static str, String)> = ExtensionCategory::ALL - .iter() - .map(|c| (c.as_str(), c.label().to_string())) - .collect(); - Button::new("extensions-category") - .small() - .ghost() - .rounded_md() - .border_1() - .border_color(ThemeColors::border()) - .bg(ThemeColors::background()) - .w(px(160.0)) - .child( - div() - .flex_1() - .text_xs() - .text_color(ThemeColors::foreground()) - .child(current_label), - ) - .child( - Icon::new(IconName::ChevronDown) - .size(px(13.0)) - .text_color(ThemeColors::subtle_foreground()), - ) - .dropdown_menu(move |menu, _window, _cx| { - let mut menu = menu; - for (value, label) in &options { - let v = view.clone(); - let value = *value; - let label = label.clone(); - let selected = value == current; - let item = gpui_kit::component::menu::PopupMenuItem::new(label); - let item = if selected { - item.icon(IconName::Check) - } else { - item - }; - menu = menu.item(item.on_click(move |_, _, cx| { - v.update(cx, |this, cx| { - if let Some(category) = ExtensionCategory::from_str(value) { - this.category = category; - cx.notify(); - } - }); - })); - } - menu - }) + /// 各分类签计数(搜索命中内部分类计数,对齐 Tauri `filterCounts`)。 + fn tab_counts(&self, matched: &[&ExtensionItem]) -> [usize; 9] { + let mut counts = [0usize; 9]; + for (ix, category) in ExtensionCategory::ALL.iter().enumerate() { + counts[ix] = if *category == ExtensionCategory::All { + matched.len() + } else { + matched + .iter() + .filter(|item| item.category == *category) + .count() + }; + } + counts } - /// 启用开关:可点击药丸,圆点指示状态(对齐 `settings_dialog.rs` - /// `render_toggle`,无文字)。 - fn render_toggle(&self, id: &str, value: bool, cx: &mut Context) -> impl IntoElement { - let id_owned = id.to_string(); + /// 分类签行:横滑签 + 计数角标,对齐 Tauri `FILTER_TABS` 行。 + fn render_tab_row(&self, counts: &[usize; 9], cx: &mut Context) -> impl IntoElement { h_flex() - .id(format!("ext-toggle-{id_owned}")) - .items_center() - .gap_1p5() - .px_2p5() + .w_full() + .gap_1() + .overflow_x_scrollbar() .py_1() + .children( + ExtensionCategory::ALL + .iter() + .enumerate() + .map(|(ix, category)| { + let count = counts[ix]; + let active = self.category == *category; + let id = category.as_str().to_string(); + h_flex() + .id(format!("ext-tab-{id}")) + .flex_shrink_0() + .items_center() + .gap_1p5() + .h(px(28.0)) + .px_2p5() + .rounded_sm() + .cursor_pointer() + .text_xs() + .when(active, |el| { + el.bg(ThemeColors::subtle_selection()) + .text_color(ThemeColors::text_primary()) + }) + .when(!active, |el| { + el.text_color(ThemeColors::text_muted()).hover(|h| { + h.bg(ThemeColors::bg_tab_hover()) + .text_color(ThemeColors::text_primary()) + }) + }) + .when_some(category.icon(), |el, icon| { + el.child(Icon::new(icon).size(px(13.0)).text_color(if active { + ThemeColors::text_primary() + } else { + ThemeColors::text_muted() + })) + }) + .child(category_label(*category, cx)) + .child( + div() + .px_1() + .rounded_sm() + .text_xs() + .bg(if active { + ThemeColors::accent_blue() + } else { + ThemeColors::bg_tab_hover() + }) + .text_color(if active { + ThemeColors::foreground() + } else { + ThemeColors::text_muted() + }) + .child(count.to_string()), + ) + .on_click(cx.listener(move |this, _event, _window, cx| { + if let Some(category) = ExtensionCategory::from_str(&id) { + this.category = category; + cx.notify(); + } + })) + }), + ) + } + + /// 状态徽标:分类 / 已安装 / 已禁用 / 内置,对齐 Tauri 详情徽标行。 + fn render_badge(text: String, highlighted: bool) -> AnyElement { + div() + .px_1p5() + .py_0p5() .rounded_sm() .border_1() - .cursor_pointer() .text_xs() - .when(value, |el| { - el.bg(ThemeColors::primary()) - .border_color(ThemeColors::primary()) - .text_color(ThemeColors::foreground()) - }) - .when(!value, |el| { - el.bg(ThemeColors::background()) - .border_color(ThemeColors::border()) - .text_color(ThemeColors::subtle_foreground()) - .hover(|h| { - h.bg(ThemeColors::accent()) - .text_color(ThemeColors::foreground()) - }) + .border_color(if highlighted { + ThemeColors::accent_blue() + } else { + ThemeColors::border() }) - .child(div().w(px(8.0)).h(px(8.0)).rounded_full().bg(if value { - ThemeColors::foreground() + .text_color(if highlighted { + ThemeColors::accent_blue() } else { - ThemeColors::subtle_foreground() - })) - .on_click(cx.listener(move |this, _event, _window, cx| { - this.toggle(&id_owned, cx); - })) + ThemeColors::text_muted() + }) + .child(text) + .into_any_element() } + + /// 详情区:图标 + 名 + 发布者/版本 + 徽标 + 描述 + 主操作 + 贡献项, + /// 对齐 Tauri 右侧详情栏(纵向堆叠适配窄面板)。 + fn render_detail(&self, item: &ExtensionItem, cx: &mut Context) -> AnyElement { + let enabled = self.is_enabled(&item.id); + let item_id = item.id.clone(); + let mut badges = vec![Self::render_badge(category_label(item.category, cx), false)]; + badges.push(Self::render_badge( + crate::i18n::menu_text(cx, "extensions.installed").to_string(), + true, + )); + if !enabled { + badges.push(Self::render_badge( + crate::i18n::menu_text(cx, "extensions.disabled").to_string(), + false, + )); + } + if item.is_bundled { + badges.push(Self::render_badge( + crate::i18n::menu_text(cx, "extensions.builtIn").to_string(), + true, + )); + } + let by_publisher = item.publisher.clone().map(|publisher| { + crate::i18n::menu_text(cx, "extensions.byPublisher").replace("{publisher}", &publisher) + }); + v_flex() + .w_full() + .gap_2() + .p_3() + .child( + h_flex() + .w_full() + .items_center() + .gap_2p5() + .child( + div() + .flex_shrink_0() + .size(px(40.0)) + .flex() + .items_center() + .justify_center() + .rounded_md() + .border_1() + .border_color(ThemeColors::border()) + .bg(ThemeColors::background()) + .child( + Icon::new(item.category.icon().unwrap_or(IconName::Puzzle)) + .size(px(18.0)) + .text_color(ThemeColors::text_muted()), + ), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .child( + div() + .truncate() + .text_sm() + .font_weight(FontWeight::BOLD) + .text_color(ThemeColors::text_primary()) + .child(item.name.clone()), + ) + .when_some(by_publisher, |el, text| { + el.child( + div() + .truncate() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(text), + ) + }) + .when(!item.version.is_empty(), |el| { + el.child( + div() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(format!("v{}", item.version)), + ) + }), + ), + ) + .child(h_flex().w_full().flex_wrap().gap_1p5().children(badges)) + .when(!item.description.is_empty(), |el| { + el.child( + div() + .w_full() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(item.description.clone()), + ) + }) + .child( + Button::new(format!("ext-detail-toggle-{}", item.id)) + .small() + .when(enabled, |b| b.ghost()) + .when(!enabled, |b| b.primary()) + .icon(if enabled { + IconName::Close + } else { + IconName::Check + }) + .label(if enabled { + crate::i18n::menu_text(cx, "extensions.deactivate").to_string() + } else { + crate::i18n::menu_text(cx, "extensions.activate").to_string() + }) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.toggle(&item_id, cx); + })), + ) + .child( + v_flex() + .w_full() + .gap_1p5() + .child( + div() + .text_xs() + .font_weight(FontWeight::BOLD) + .text_color(ThemeColors::text_primary()) + .child(crate::i18n::menu_text(cx, "extensions.contributions")), + ) + .child( + h_flex() + .w_full() + .flex_wrap() + .gap_1p5() + .child(Self::render_badge(category_label(item.category, cx), false)), + ), + ) + .into_any_element() + } +} + +/// 分类签文案:`extensions.*` 与 Tauri `locale.ts` 同键。 +fn category_label(category: ExtensionCategory, cx: &gpui_kit::App) -> String { + let key = match category { + ExtensionCategory::All => "extensions.all", + ExtensionCategory::Language => "extensions.languages", + ExtensionCategory::Theme => "extensions.themes", + ExtensionCategory::IconTheme => "extensions.iconThemes", + ExtensionCategory::Database => "extensions.databases", + ExtensionCategory::Ai => "extensions.ai", + ExtensionCategory::Integration => "extensions.integrations", + ExtensionCategory::Skill => "extensions.skills", + ExtensionCategory::Agent => "extensions.agents", + }; + crate::i18n::menu_text(cx, key).to_string() } impl Render for ExtensionsView { @@ -294,70 +487,130 @@ impl Render for ExtensionsView { )); } let query = self.search_query(cx); - let filtered = self.filtered_items(&query); - let enabled_snapshot = self.enabled.clone(); + let matched = self.search_matched(&query); + let counts = self.tab_counts(&matched); + let filtered: Vec<&ExtensionItem> = matched + .iter() + .filter(|item| { + self.category == ExtensionCategory::All || item.category == self.category + }) + .copied() + .collect(); + // 选中不在过滤结果内则回退首项(对齐 Tauri `selectedExtension` 回退)。 + let selected_id = self + .selected + .clone() + .filter(|id| filtered.iter().any(|item| item.id == *id)) + .or_else(|| filtered.first().map(|item| item.id.clone())); + let installed = self.items.len(); + let subtitle = format!( + "{} · {}", + crate::i18n::menu_text(cx, "extensions.availableCount") + .replace("{count}", &filtered.len().to_string()), + crate::i18n::menu_text(cx, "extensions.installedCount") + .replace("{count}", &installed.to_string()), + ); let mut rows = Vec::new(); for item in &filtered { - let enabled = enabled_snapshot.get(&item.id).copied().unwrap_or(true); - let name = item.name.clone(); - let version = item.version.clone(); - let description = item.description.clone(); + let enabled = self.is_enabled(&item.id); + let selected = selected_id.as_deref() == Some(item.id.as_str()); + let id_owned = item.id.clone(); rows.push( h_flex() .id(format!("ext-row-{}", item.id)) .w_full() .items_center() - .gap_2() - .px_2p5() + .gap_2p5() + .px_2() .py_1p5() - .rounded_sm() - .hover(|h| h.bg(ThemeColors::bg_tab_hover())) + .rounded_md() + .cursor_pointer() + .when(selected, |el| { + el.bg(ThemeColors::subtle_selection()) + .text_color(ThemeColors::text_primary()) + }) + .when(!selected, |el| { + el.hover(|h| h.bg(ThemeColors::bg_tab_hover())) + }) .child( - Icon::new(IconName::Puzzle) - .size(px(14.0)) - .text_color(ThemeColors::subtle_foreground()), + div() + .flex_shrink_0() + .size(px(36.0)) + .flex() + .items_center() + .justify_center() + .rounded_md() + .border_1() + .border_color(ThemeColors::border()) + .bg(ThemeColors::background()) + .child( + Icon::new(item.category.icon().unwrap_or(IconName::Puzzle)) + .size(px(16.0)) + .text_color(ThemeColors::text_muted()), + ), ) .child( v_flex() .flex_1() - .gap_0p5() - .overflow_hidden() + .min_w_0() .child( - h_flex() - .items_center() - .gap_1p5() - .child( - div() - .text_xs() - .truncate() - .text_color(if enabled { - ThemeColors::text_primary() - } else { - ThemeColors::text_muted() - }) - .child(name), - ) - .child( - div() - .text_xs() - .flex_shrink_0() - .text_color(ThemeColors::text_muted()) - .child(version), - ), + div() + .truncate() + .text_xs() + .font_weight(FontWeight::BOLD) + .text_color(ThemeColors::text_primary()) + .child(item.name.clone()), ) .child( div() .text_xs() .truncate() .text_color(ThemeColors::text_muted()) - .child(description), + .child(item.description.clone()), ), ) - .child(self.render_toggle(&item.id, enabled, cx).into_any_element()) + .child(if enabled { + div() + .flex_shrink_0() + .size(px(28.0)) + .flex() + .items_center() + .justify_center() + .text_color(ThemeColors::text_muted()) + .child( + Icon::new(IconName::Check) + .size(px(14.0)) + .text_color(ThemeColors::text_muted()), + ) + .into_any_element() + } else { + Button::new(format!("ext-install-{}", item.id)) + .small() + .primary() + .icon(IconName::Plus) + .tooltip(crate::i18n::menu_text(cx, "extensions.activate")) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.enabled.insert(id_owned.clone(), true); + save_enabled(&this.enabled); + cx.notify(); + })) + .into_any_element() + }) + .on_click(cx.listener({ + let id_owned = item.id.clone(); + move |this, _event, _window, cx| { + this.selected = Some(id_owned.clone()); + cx.notify(); + } + })) .into_any_element(), ); } + let detail = selected_id + .as_deref() + .and_then(|id| filtered.iter().find(|item| item.id == id)) + .map(|item| self.render_detail(item, cx)); v_flex() .size_full() @@ -375,11 +628,23 @@ impl Render for ExtensionsView { .justify_between() .px_3() .child( - div() - .text_xs() - .font_weight(FontWeight::BOLD) - .text_color(ThemeColors::text_muted()) - .child(crate::i18n::menu_text(cx, "extensions.title")), + v_flex() + .flex_1() + .min_w_0() + .child( + div() + .text_xs() + .font_weight(FontWeight::BOLD) + .text_color(ThemeColors::text_muted()) + .child(crate::i18n::menu_text(cx, "extensions.title")), + ) + .child( + div() + .truncate() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(subtitle), + ), ) .child( Button::new("extensions-close") @@ -395,16 +660,16 @@ impl Render for ExtensionsView { .child( v_flex() .w_full() - .gap_2() - .p_2() - .border_b_1() - .border_color(ThemeColors::border()) + .flex_shrink_0() + .gap_1() + .px_2() + .pt_2() .child( div() .w_full() .child(Input::new(&search_entity).cleanable(true)), ) - .child(self.render_category_dropdown(cx)), + .child(self.render_tab_row(&counts, cx)), ) .child(if rows.is_empty() { div() @@ -415,16 +680,31 @@ impl Render for ExtensionsView { .justify_center() .text_xs() .text_color(ThemeColors::text_muted()) - .child("No extensions found") + .child(crate::i18n::menu_text(cx, "extensions.noneFound")) .into_any_element() } else { div() .flex_1() .w_full() + .min_h_0() .overflow_y_scrollbar() + .px_1() .children(rows) .into_any_element() }) + .when_some(detail, |el, detail| { + el.child( + div() + .w_full() + .flex_shrink_0() + .max_h(px(340.0)) + .overflow_y_scrollbar() + .border_t_1() + .border_color(ThemeColors::border()) + .bg(ThemeColors::bg_sidebar()) + .child(detail), + ) + }) } } @@ -437,6 +717,8 @@ fn builtin_items() -> Vec { version: "0.1.0".to_string(), description: "Git source control integration".to_string(), category: ExtensionCategory::Integration, + publisher: None, + is_bundled: true, }, ExtensionItem { id: "maven".to_string(), @@ -444,6 +726,8 @@ fn builtin_items() -> Vec { version: "0.1.0".to_string(), description: "Maven project navigation and lifecycle".to_string(), category: ExtensionCategory::Integration, + publisher: None, + is_bundled: true, }, ExtensionItem { id: "terminal".to_string(), @@ -451,6 +735,8 @@ fn builtin_items() -> Vec { version: "0.1.0".to_string(), description: "Integrated terminal".to_string(), category: ExtensionCategory::Integration, + publisher: None, + is_bundled: true, }, ] } @@ -528,6 +814,8 @@ struct PackageJson { version: Option, description: Option, keywords: Option>, + publisher: Option, + author: Option, } #[derive(Debug, serde::Deserialize)] @@ -549,6 +837,10 @@ fn read_manifest(dir: &Path, dir_name: &str) -> Option { let text = std::fs::read_to_string(&package_path).ok()?; let manifest: PackageJson = serde_json::from_str(&text).ok()?; let keywords = manifest.keywords.clone().unwrap_or_default(); + let publisher = manifest + .publisher + .filter(|s| !s.trim().is_empty()) + .or_else(|| manifest.author.filter(|s| !s.trim().is_empty())); return Some(ExtensionItem { id: manifest .name @@ -558,6 +850,8 @@ fn read_manifest(dir: &Path, dir_name: &str) -> Option { version: manifest.version.unwrap_or_default(), description: manifest.description.unwrap_or_default(), category: classify_keywords(&keywords), + publisher, + is_bundled: false, }); } let plugin_path = dir.join("plugin.json"); @@ -581,6 +875,8 @@ fn read_manifest(dir: &Path, dir_name: &str) -> Option { version: manifest.version.unwrap_or_default(), description: manifest.description.unwrap_or_default(), category, + publisher: None, + is_bundled: false, }); } None @@ -591,16 +887,27 @@ fn classify_keywords(keywords: &[String]) -> ExtensionCategory { classify_text(&keywords.join(" ")) } -/// 关键词归类:theme → 主题,language/lang/lsp/syntax → 语言, -/// ai/llm/agent/chat/model → AI,其余 → integration。 +/// 关键词归类:theme/icon-theme → 主题系,language/lang/lsp/syntax → +/// 语言,database/sql → 数据库,ai/llm/agent/chat/model → AI, +/// skill → 技能,其余 → integration(对齐 Tauri 9 分类)。 fn classify_text(text: &str) -> ExtensionCategory { let lower = text.to_lowercase(); let has_any = |words: &[&str]| words.iter().any(|word| lower.contains(word)); - if has_any(&["theme", "icon", "color-scheme"]) { + if has_any(&["icon-theme", "icon-pack", "file-icon"]) { + ExtensionCategory::IconTheme + } else if has_any(&["theme", "icon", "color-scheme"]) { ExtensionCategory::Theme } else if has_any(&["language", "lang", "lsp", "syntax", "grammar"]) { ExtensionCategory::Language - } else if has_any(&["ai", "llm", "agent", "chat", "model"]) { + } else if has_any(&[ + "database", "sql", "sqlite", "postgres", "mysql", "mongo", "redis", + ]) { + ExtensionCategory::Database + } else if has_any(&["skill"]) { + ExtensionCategory::Skill + } else if has_any(&["agent", "robot"]) { + ExtensionCategory::Agent + } else if has_any(&["ai", "llm", "chat", "model"]) { ExtensionCategory::Ai } else { ExtensionCategory::Integration From 95cbf6041255d13d33bfaa9b63189af2ad9f7175 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 00:46:42 +0800 Subject: [PATCH 038/109] =?UTF-8?q?fix(linux):=20=E7=82=B9=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E6=97=B6=E5=8F=B3=E4=BE=A7=E9=BB=98=E8=AE=A4=E5=B1=95?= =?UTF-8?q?=E5=BC=80=E5=88=B0=20560?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- linux/src/workbench/view.rs | 40 +++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index 570e61f77..f616a4a63 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -905,14 +905,19 @@ impl WorkbenchView { cx.notify(); } - /// 切换到指定项目根路径:同步侧边栏、Maven 与欢迎页状态,并记录最近项目。 /// 右侧工具窗口 toggle(对齐 `resolveRightToolWindowUpdate`): - /// 同视图再点关闭,否则切换视图并展开。 + /// 同视图再点关闭,否则切换视图并展开。扩展页内容多(列表+详情), + /// 打开时宽度不足则默认撑到 560(用户拖过的更宽值保留)。 fn toggle_right_tool(&mut self, view: RightToolView, cx: &mut Context) { if self.right_tool == Some(view) { self.right_tool = None; } else { self.right_tool = Some(view); + if view == RightToolView::Extensions + && settings::get(cx).right_tool_window_width < 560.0 + { + settings::update(cx, |s| s.right_tool_window_width = 560.0); + } } cx.notify(); } @@ -2016,21 +2021,22 @@ impl WorkbenchView { ); let ratio_leaf = pane_first_leaf(first); let view = cx.entity(); - let on_resize = move |state: &Entity, _: &mut Window, cx: &mut App| { - let sizes = state.read(cx).sizes().clone(); - if sizes.len() < 2 { - return; - } - let total = sizes[0].as_f32() + sizes[1].as_f32(); - if total <= 0.0 { - return; - } - let ratio = sizes[0].as_f32() / total; - let _ = view.update(cx, |this, cx| { - this.pane_tree.set_ratio(ratio_leaf, ratio); - cx.notify(); - }); - }; + let on_resize = + move |state: &Entity, _: &mut Window, cx: &mut App| { + let sizes = state.read(cx).sizes().clone(); + if sizes.len() < 2 { + return; + } + let total = sizes[0].as_f32() + sizes[1].as_f32(); + if total <= 0.0 { + return; + } + let ratio = sizes[0].as_f32() / total; + let _ = view.update(cx, |this, cx| { + this.pane_tree.set_ratio(ratio_leaf, ratio); + cx.notify(); + }); + }; if *dir == SplitDir::Horizontal { h_resizable(group_id) .on_resize(on_resize) From 4910f1195801bdb9ba5204e0ae11151e65c649c6 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 00:50:19 +0800 Subject: [PATCH 039/109] =?UTF-8?q?fix(linux):=20=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E7=82=B9=E5=87=BB=E4=B8=8E=E6=96=87=E6=A1=88=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=20Tauri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 点击行标已读+展开详情(含删除),诊断行保留跳文件 - 类型筛选签行;头部加全部清除;空构造对齐空态 - 打开通知页即全标已读;全部文案走 notifications.* 键 --- linux/src/i18n.rs | 12 ++ linux/src/workbench/notifications.rs | 204 ++++++++++++++++++++++----- linux/src/workbench/view.rs | 9 +- 3 files changed, 192 insertions(+), 33 deletions(-) diff --git a/linux/src/i18n.rs b/linux/src/i18n.rs index 997877c87..5a8506675 100644 --- a/linux/src/i18n.rs +++ b/linux/src/i18n.rs @@ -202,6 +202,18 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { "extensions.activate" => ("启用", "Activate"), "extensions.deactivate" => ("停用", "Deactivate"), "notifications.title" => ("通知", "Notifications"), + "notifications.empty" => ("暂无通知。", "No notifications yet."), + "notifications.noMatch" => ("没有匹配的通知。", "No matching notifications."), + "notifications.markAllRead" => ("全部标为已读", "Mark all read"), + "notifications.delete" => ("删除", "Delete"), + "notifications.clearAll" => ("全部清除", "Clear All"), + "notifications.details" => ("通知详情", "Notification details"), + "notifications.filterAll" => ("全部", "All"), + "notifications.filterInfo" => ("信息", "Info"), + "notifications.filterSuccess" => ("成功", "Success"), + "notifications.filterWarnings" => ("警告", "Warnings"), + "notifications.filterErrors" => ("错误", "Errors"), + "notifications.projectOpened" => ("已打开项目", "Project opened"), // ---- Git ---- "git.commit" => ("提交", "Commit"), // ---- Git 分支管理器(与 Tauri locale.ts git.* 逐字对齐) ---- diff --git a/linux/src/workbench/notifications.rs b/linux/src/workbench/notifications.rs index bae2fe58e..7a3f9ec8d 100644 --- a/linux/src/workbench/notifications.rs +++ b/linux/src/workbench/notifications.rs @@ -29,7 +29,7 @@ pub enum NotificationsEvent { } /// 通知类型,对齐 Tauri `NotificationEntry["type"]`;`Diagnostic` 为 Linux 侧 -/// 由诊断快照投递的定位类通知,点击时额外发出 `OpenFile`。 +/// 由诊断快照投递的定位类通知,点击时额外发出 `OpenFile`,筛选时归入错误桶。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NotificationKind { Info, @@ -41,6 +41,50 @@ pub enum NotificationKind { Diagnostic, } +/// 类型筛选:对齐 Tauri 通知筛选菜单(全部/信息/成功/警告/错误)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum NotificationFilter { + #[default] + All, + Info, + Success, + Warning, + Error, +} + +impl NotificationFilter { + const ALL: [NotificationFilter; 5] = [ + NotificationFilter::All, + NotificationFilter::Info, + NotificationFilter::Success, + NotificationFilter::Warning, + NotificationFilter::Error, + ]; + + fn matches(self, kind: NotificationKind) -> bool { + match self { + NotificationFilter::All => true, + NotificationFilter::Info => kind == NotificationKind::Info, + NotificationFilter::Success => kind == NotificationKind::Success, + NotificationFilter::Warning => kind == NotificationKind::Warning, + // 诊断类通知按错误展示,归入错误桶。 + NotificationFilter::Error => { + kind == NotificationKind::Error || kind == NotificationKind::Diagnostic + } + } + } + + fn label_key(self) -> &'static str { + match self { + NotificationFilter::All => "notifications.filterAll", + NotificationFilter::Info => "notifications.filterInfo", + NotificationFilter::Success => "notifications.filterSuccess", + NotificationFilter::Warning => "notifications.filterWarnings", + NotificationFilter::Error => "notifications.filterErrors", + } + } +} + /// 单条通知:诊断类通过 `path` 携带定位信息,非诊断类为 `None`。 #[derive(Debug, Clone)] pub struct NotificationItem { @@ -58,38 +102,22 @@ pub struct NotificationsView { items: Vec, next_id: u64, search: Option>, + filter: NotificationFilter, + /// 展开详情的通知 id(对齐 Tauri 点击行打开详情)。 + selected: Option, } impl EventEmitter for NotificationsView {} impl NotificationsView { - /// 构造并预置 2 条未读提示,保证面板首次打开非空。 + /// 构造空列表(对齐 Tauri:无通知时展示空态,不预置文案)。 pub fn new(_cx: &mut Context) -> Self { Self { - items: vec![ - NotificationItem { - id: 0, - title: "Welcome to Lithe".to_string(), - body: "Notifications from the workspace and language services appear here." - .to_string(), - kind: NotificationKind::Info, - read: false, - path: None, - line: 1, - }, - NotificationItem { - id: 1, - title: "Tip: diagnostics are clickable".to_string(), - body: "Click a diagnostic notification to jump to its file location." - .to_string(), - kind: NotificationKind::Info, - read: false, - path: None, - line: 1, - }, - ], - next_id: 2, + items: Vec::new(), + next_id: 0, search: None, + filter: NotificationFilter::All, + selected: None, } } @@ -150,7 +178,7 @@ impl NotificationsView { .count() } - /// 全部标为已读。 + /// 全部标为已读(宿主在打开面板时调用,对齐 Tauri 打开即已读)。 pub fn mark_all_read(&mut self, cx: &mut Context) { for item in &mut self.items { item.read = true; @@ -158,9 +186,28 @@ impl NotificationsView { cx.notify(); } - fn toggle_read(&mut self, id: u64, cx: &mut Context) { + /// 删除一条通知;删的是展开项则收起详情。 + pub fn remove(&mut self, id: u64, cx: &mut Context) { + self.items.retain(|item| item.id != id); + if self.selected == Some(id) { + self.selected = None; + } + cx.notify(); + } + + /// 清空全部通知。 + pub fn clear_all(&mut self, cx: &mut Context) { + self.items.clear(); + self.selected = None; + cx.notify(); + } + + /// 点击行:标已读 + 展开详情(对齐 Tauri 打开详情;诊断类额外跳文件)。 + /// 行内删除按钮冒泡上来时条目已不存在,此时不留过期选中。 + fn open_details(&mut self, id: u64, cx: &mut Context) { if let Some(item) = self.items.iter_mut().find(|item| item.id == id) { - item.read = !item.read; + item.read = true; + self.selected = Some(id); } cx.notify(); } @@ -201,9 +248,12 @@ impl Render for NotificationsView { let search_entity = self.ensure_search(window, cx); let query = self.query(cx); let unread = self.unread_count(); + let filter = self.filter; + let selected = self.selected; let items: Vec = self .items .iter() + .filter(|item| filter.matches(item.kind)) .filter(|item| { query.is_empty() || item.title.to_lowercase().contains(&query) @@ -219,6 +269,7 @@ impl Render for NotificationsView { let path = item.path.clone(); let line = item.line; let is_diagnostic = item.kind == NotificationKind::Diagnostic; + let expanded = selected == Some(id); rows.push( v_flex() .id(format!("notification-{id}")) @@ -230,6 +281,7 @@ impl Render for NotificationsView { .gap_1() .hover(|row| row.bg(ThemeColors::bg_tab_hover())) .when(!item.read, |row| row.bg(ThemeColors::subtle_selection())) + .when(expanded, |row| row.bg(ThemeColors::subtle_selection())) .child( h_flex() .w_full() @@ -271,8 +323,41 @@ impl Render for NotificationsView { .text_color(ThemeColors::text_muted()) .child(item.body.clone()), ) + .when(expanded, |row| { + row.child( + v_flex() + .w_full() + .pl(px(20.0)) + .gap_1p5() + .py_1() + .child( + div() + .text_xs() + .font_weight(FontWeight::BOLD) + .text_color(ThemeColors::text_muted()) + .child(crate::i18n::menu_text(cx, "notifications.details")), + ) + .child( + div() + .w_full() + .text_xs() + .text_color(ThemeColors::text_primary()) + .child(item.body.clone()), + ) + .child( + Button::new(format!("notification-delete-{id}")) + .small() + .ghost() + .icon(IconName::Trash) + .label(crate::i18n::menu_text(cx, "notifications.delete")) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.remove(id, cx); + })), + ), + ) + }) .on_click(cx.listener(move |this, _event, _window, cx| { - this.toggle_read(id, cx); + this.open_details(id, cx); if is_diagnostic { if let Some(path) = path.clone() { cx.emit(NotificationsEvent::OpenFile(path, line)); @@ -327,12 +412,30 @@ impl Render for NotificationsView { Button::new("notifications-mark-read") .small() .ghost() - .label("Mark all read") + .label(crate::i18n::menu_text( + cx, + "notifications.markAllRead", + )) .on_click(cx.listener(|this, _event, _window, cx| { this.mark_all_read(cx); })), ) }) + .when(!is_empty, |actions| { + actions.child( + Button::new("notifications-clear-all") + .small() + .ghost() + .icon(IconName::Trash) + .tooltip(crate::i18n::menu_text( + cx, + "notifications.clearAll", + )) + .on_click(cx.listener(|this, _event, _window, cx| { + this.clear_all(cx); + })), + ) + }) .child( Button::new("notifications-close") .small() @@ -363,6 +466,43 @@ impl Render for NotificationsView { .child(Input::new(&search_entity).cleanable(true)), ), ) + .child( + h_flex() + .w_full() + .flex_shrink_0() + .gap_1() + .px_2() + .pb_1() + .overflow_x_scrollbar() + .children(NotificationFilter::ALL.iter().map(|kind| { + let active = filter == *kind; + let id = *kind; + h_flex() + .id(format!("notification-filter-{}", id.label_key())) + .flex_shrink_0() + .items_center() + .h(px(24.0)) + .px_2() + .rounded_sm() + .cursor_pointer() + .text_xs() + .when(active, |el| { + el.bg(ThemeColors::subtle_selection()) + .text_color(ThemeColors::text_primary()) + }) + .when(!active, |el| { + el.text_color(ThemeColors::text_muted()).hover(|h| { + h.bg(ThemeColors::bg_tab_hover()) + .text_color(ThemeColors::text_primary()) + }) + }) + .child(crate::i18n::menu_text(cx, id.label_key())) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.filter = id; + cx.notify(); + })) + })), + ) .child(if is_empty { div() .flex_1() @@ -372,7 +512,7 @@ impl Render for NotificationsView { .justify_center() .text_xs() .text_color(ThemeColors::text_muted()) - .child("No notifications") + .child(crate::i18n::menu_text(cx, "notifications.empty")) .into_any_element() } else if items.is_empty() { div() @@ -383,7 +523,7 @@ impl Render for NotificationsView { .justify_center() .text_xs() .text_color(ThemeColors::text_muted()) - .child("No matching notifications") + .child(crate::i18n::menu_text(cx, "notifications.noMatch")) .into_any_element() } else { div() diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index f616a4a63..f269da00d 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -908,6 +908,7 @@ impl WorkbenchView { /// 右侧工具窗口 toggle(对齐 `resolveRightToolWindowUpdate`): /// 同视图再点关闭,否则切换视图并展开。扩展页内容多(列表+详情), /// 打开时宽度不足则默认撑到 560(用户拖过的更宽值保留)。 + /// 通知页打开即全标已读(对齐 Tauri 打开工具窗即已读)。 fn toggle_right_tool(&mut self, view: RightToolView, cx: &mut Context) { if self.right_tool == Some(view) { self.right_tool = None; @@ -918,6 +919,11 @@ impl WorkbenchView { { settings::update(cx, |s| s.right_tool_window_width = 560.0); } + if view == RightToolView::Notifications { + let _ = self.notifications.update(cx, |n, cx| { + n.mark_all_read(cx); + }); + } } cx.notify(); } @@ -970,8 +976,9 @@ impl WorkbenchView { bp.set_working_dir(path.clone(), cx); }); // 右侧通知中心投递项目打开事件(对齐 Tauri 系统事件通知)。 + let opened = crate::i18n::menu_text(cx, "notifications.projectOpened").to_string(); let _ = self.notifications.update(cx, |n, cx| { - n.push("已打开项目", path.clone(), cx); + n.push(opened, path.clone(), cx); }); cx.notify(); } From 03df5d47330236f4e82e2d4c050764ad8c0e1e3d Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 00:55:16 +0800 Subject: [PATCH 040/109] =?UTF-8?q?fix(linux):=20=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E6=94=B9=E6=BC=8F=E6=96=97=E4=B8=8B=E6=8B=89?= =?UTF-8?q?=EF=BC=8C=E5=AF=B9=E9=BD=90=20Tauri=20=E7=89=88=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- linux/src/i18n.rs | 1 + linux/src/workbench/notifications.rs | 101 +++++++++++++++++---------- 2 files changed, 65 insertions(+), 37 deletions(-) diff --git a/linux/src/i18n.rs b/linux/src/i18n.rs index 5a8506675..970e760fe 100644 --- a/linux/src/i18n.rs +++ b/linux/src/i18n.rs @@ -202,6 +202,7 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { "extensions.activate" => ("启用", "Activate"), "extensions.deactivate" => ("停用", "Deactivate"), "notifications.title" => ("通知", "Notifications"), + "notifications.filter" => ("筛选通知", "Filter Notifications"), "notifications.empty" => ("暂无通知。", "No notifications yet."), "notifications.noMatch" => ("没有匹配的通知。", "No matching notifications."), "notifications.markAllRead" => ("全部标为已读", "Mark all read"), diff --git a/linux/src/workbench/notifications.rs b/linux/src/workbench/notifications.rs index 7a3f9ec8d..6f9d41047 100644 --- a/linux/src/workbench/notifications.rs +++ b/linux/src/workbench/notifications.rs @@ -10,6 +10,7 @@ use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::component::input::{Input, InputState}; +use gpui_kit::component::menu::DropdownMenu as _; use gpui_kit::component::scroll::ScrollableElement as _; use gpui_kit::component::{h_flex, v_flex, Icon, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; @@ -83,6 +84,27 @@ impl NotificationFilter { NotificationFilter::Error => "notifications.filterErrors", } } + + fn as_str(self) -> &'static str { + match self { + NotificationFilter::All => "all", + NotificationFilter::Info => "info", + NotificationFilter::Success => "success", + NotificationFilter::Warning => "warning", + NotificationFilter::Error => "error", + } + } + + fn from_str(id: &str) -> Option { + match id { + "all" => Some(NotificationFilter::All), + "info" => Some(NotificationFilter::Info), + "success" => Some(NotificationFilter::Success), + "warning" => Some(NotificationFilter::Warning), + "error" => Some(NotificationFilter::Error), + _ => None, + } + } } /// 单条通知:诊断类通过 `path` 携带定位信息,非诊断类为 `None`。 @@ -249,6 +271,16 @@ impl Render for NotificationsView { let query = self.query(cx); let unread = self.unread_count(); let filter = self.filter; + let view = cx.entity(); + let filter_options: Vec<(&'static str, String)> = NotificationFilter::ALL + .iter() + .map(|kind| { + ( + kind.as_str(), + crate::i18n::menu_text(cx, kind.label_key()).to_string(), + ) + }) + .collect(); let selected = self.selected; let items: Vec = self .items @@ -464,45 +496,40 @@ impl Render for NotificationsView { div() .flex_1() .child(Input::new(&search_entity).cleanable(true)), + ) + .child( + Button::new("notifications-filter") + .small() + .ghost() + .icon(IconName::Funnel) + .tooltip(crate::i18n::menu_text(cx, "notifications.filter")) + .dropdown_menu(move |menu, _window, _cx| { + let mut menu = menu; + for (value, label) in &filter_options { + let v = view.clone(); + let value = *value; + let label = label.clone(); + let selected = filter.as_str() == value; + let item = gpui_kit::component::menu::PopupMenuItem::new(label); + let item = if selected { + item.icon(IconName::Check) + } else { + item + }; + menu = menu.item(item.on_click(move |_, _, cx| { + v.update(cx, |this, cx| { + if let Some(kind) = NotificationFilter::from_str(value) + { + this.filter = kind; + cx.notify(); + } + }); + })); + } + menu + }), ), ) - .child( - h_flex() - .w_full() - .flex_shrink_0() - .gap_1() - .px_2() - .pb_1() - .overflow_x_scrollbar() - .children(NotificationFilter::ALL.iter().map(|kind| { - let active = filter == *kind; - let id = *kind; - h_flex() - .id(format!("notification-filter-{}", id.label_key())) - .flex_shrink_0() - .items_center() - .h(px(24.0)) - .px_2() - .rounded_sm() - .cursor_pointer() - .text_xs() - .when(active, |el| { - el.bg(ThemeColors::subtle_selection()) - .text_color(ThemeColors::text_primary()) - }) - .when(!active, |el| { - el.text_color(ThemeColors::text_muted()).hover(|h| { - h.bg(ThemeColors::bg_tab_hover()) - .text_color(ThemeColors::text_primary()) - }) - }) - .child(crate::i18n::menu_text(cx, id.label_key())) - .on_click(cx.listener(move |this, _event, _window, cx| { - this.filter = id; - cx.notify(); - })) - })), - ) .child(if is_empty { div() .flex_1() From 2a494e515ebe1f1ddef180b57c219bf4c2987ad5 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 01:08:13 +0800 Subject: [PATCH 041/109] =?UTF-8?q?fix(linux):=20Maven=20=E5=B1=95?= =?UTF-8?q?=E5=BC=80=E9=9D=A2=E6=9D=BF=E5=AF=B9=E9=BD=90=20Tauri=20?= =?UTF-8?q?=E6=A0=91=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 模块树(artifactId+packaging 副标题,嵌套 modules,引导线) - caret 与标签分离;phase 单击选中、双击运行 - 依赖嵌套树 + Tauri 同式副标题 + omitted 标记 + 重试/取消 - 工具栏:运行选中/目标文本/reload/跳过测试/全部折叠/设置 - Profiles 勾选与 skipTests 进 launchPlan;任务标题 phase·artifactId - 失败重试面板;全部文案走 maven.* 键 --- linux/src/i18n.rs | 20 + linux/src/workbench/bottom_panel.rs | 40 +- linux/src/workbench/maven.rs | 1701 ++++++++++++++++++++------- linux/src/workbench/view.rs | 19 +- 4 files changed, 1346 insertions(+), 434 deletions(-) diff --git a/linux/src/i18n.rs b/linux/src/i18n.rs index 970e760fe..3dcd560a8 100644 --- a/linux/src/i18n.rs +++ b/linux/src/i18n.rs @@ -173,6 +173,26 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { "maven.lifecycle" => ("生命周期", "Lifecycle"), "maven.reloadProjects" => ("重新加载 Maven 项目", "Reload Maven projects"), "maven.noProjects" => ("无 Maven 项目", "No Maven projects"), + "maven.runSelected" => ("运行选中的生命周期阶段", "Run selected lifecycle phase"), + "maven.stop" => ("停止 Maven 任务", "Stop Maven task"), + "maven.executeGoal" => ("执行 Maven Goal", "Execute Maven goal"), + "maven.skipTests" => ("跳过测试", "Skip tests"), + "maven.collapseAll" => ("全部折叠", "Collapse all"), + "maven.settings" => ("Maven 设置", "Maven Settings"), + "maven.profiles" => ("Profiles", "Profiles"), + "maven.dependencies" => ("依赖项", "Dependencies"), + "maven.sourceRoots" => ("源码根目录", "Source Roots"), + "maven.dependencyLoading" => ("正在解析依赖项...", "Resolving dependencies..."), + "maven.dependencyCancelled" => ("依赖项解析已取消", "Dependency resolution cancelled"), + "maven.noDependencies" => ("没有依赖项", "No dependencies"), + "maven.omittedConflict" => ("版本冲突,采用", "conflict with"), + "maven.omittedDuplicate" => ("已省略重复依赖", "duplicate omitted"), + "maven.project" => ("项目", "Project"), + "maven.loadFailed" => ("无法加载 Maven 项目", "Unable to load Maven project"), + "maven.scanning" => ("正在扫描 Maven 项目...", "Scanning Maven project..."), + "maven.notDetected" => ("未检测到 Maven 项目", "No Maven project detected"), + "ui.retry" => ("重试", "Retry"), + "ui.cancel" => ("取消", "Cancel"), // ---- 通用 UI ---- "ui.close" => ("关闭", "Close"), "ui.refresh" => ("刷新", "Refresh"), diff --git a/linux/src/workbench/bottom_panel.rs b/linux/src/workbench/bottom_panel.rs index 237b567f1..1d2aec2f4 100644 --- a/linux/src/workbench/bottom_panel.rs +++ b/linux/src/workbench/bottom_panel.rs @@ -385,10 +385,18 @@ impl BottomPanelView { } /// 运行 Maven 目标:切 Maven 页 + 经 core `maven.launchPlan` 拿确定性参数 - ///(payload `{root, context: {version: 1, reactorPath: "."}, module, goals}`), + ///(payload `{root, context: {version: 1, reactorPath: ".", profiles, skipTests}, module, goals}`), /// 起受管进程并把输出泵入 Maven 页(首行 `$ mvn …`,对齐 Tauri Maven 页)。 /// 已有任务在跑时先停掉(对齐 Tauri 停掉上一个 session)。 - pub fn run_maven_goal(&mut self, pom_path: &str, goal: &str, cx: &mut Context) { + pub fn run_maven_goal( + &mut self, + pom_path: &str, + goal: &str, + target: &str, + profiles: &[String], + skip_tests: bool, + cx: &mut Context, + ) { let goal = goal.trim().to_string(); if goal.is_empty() { return; @@ -402,10 +410,7 @@ impl BottomPanelView { } self.maven_seq += 1; let seq = self.maven_seq; - let title = format!( - "{goal} · {}", - maven_display_pom(&self.working_dir, pom_path) - ); + let title = format!("{goal} · {target}"); self.active_tab = BottomTab::Maven; self.is_collapsed = false; self.maven_title = Some(title.clone()); @@ -416,6 +421,7 @@ impl BottomPanelView { let client = self.client.clone(); let root = self.working_dir.clone(); let module = maven_module_for_pom(&self.working_dir, pom_path); + let profiles = profiles.to_vec(); let child_slot = self.maven_child.clone(); cx.spawn(async move |this, cx| { let plan = client @@ -424,7 +430,12 @@ impl BottomPanelView { "maven.launchPlan", serde_json::json!({ "root": root, - "context": { "version": 1, "reactorPath": "." }, + "context": { + "version": 1, + "reactorPath": ".", + "profiles": profiles, + "skipTests": skip_tests, + }, "module": module, "goals": [goal], }), @@ -1468,21 +1479,6 @@ fn plan_to_steps( fallback_run_steps(item, root) } -/// Maven 页展示用的 pom 路径:相对工作区,根 pom 显示 `pom.xml`。 -fn maven_display_pom(root: &str, pom: &str) -> String { - std::path::Path::new(pom) - .strip_prefix(root) - .map(|rel| { - let s = rel.to_string_lossy().into_owned(); - if s.is_empty() { - pom.to_string() - } else { - s - } - }) - .unwrap_or_else(|_| pom.to_string()) -} - /// pom 所在目录相对 root 的模块路径:根 pom 为 `.`,与 core 模块约定一致。 fn maven_module_for_pom(root: &str, pom: &str) -> String { let relative = std::path::Path::new(pom) diff --git a/linux/src/workbench/maven.rs b/linux/src/workbench/maven.rs index ce6ea2333..84673fc3f 100644 --- a/linux/src/workbench/maven.rs +++ b/linux/src/workbench/maven.rs @@ -8,7 +8,7 @@ //! `cx.spawn` 里异步调用 core 命令;本地 pom 发现([`find_pom_files`])先行, //! core 失败只落工程/依赖状态,不阻塞导航展示。 -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants as _}; @@ -17,9 +17,9 @@ use gpui_kit::component::scroll::ScrollableElement as _; use gpui_kit::component::{h_flex, v_flex, Disableable as _, Icon, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, AppContext as _, Context, Entity, EventEmitter, FontWeight, InteractiveElement as _, - IntoElement, ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, - Subscription, Window, + div, px, AppContext as _, ClickEvent, Context, Entity, EventEmitter, FontWeight, + InteractiveElement as _, IntoElement, ParentElement as _, Render, + StatefulInteractiveElement as _, Styled as _, Subscription, Window, }; use crate::core::CoreClient; @@ -84,25 +84,43 @@ fn walk_pom_dir(dir: &std::path::Path, depth: usize, out: &mut Vec) { } /// Lifecycle 组的展开键。 -fn lifecycle_key(pom: &str) -> String { - format!("{pom}#lifecycle") +fn lifecycle_key(owner: &str) -> String { + format!("{owner}:lifecycle") } /// 依赖组的展开键。 -fn deps_key(pom: &str) -> String { - format!("{pom}#deps") +fn deps_key(owner: &str) -> String { + format!("{owner}:dependencies") } -/// phase 行的稳定节点 id,同时用作选中键。 -fn phase_id(pom: &str, phase: &str) -> String { - format!("{pom}#lifecycle#{phase}") +/// 源码根目录组的展开键。 +fn source_roots_key(owner: &str) -> String { + format!("{owner}:source-roots") } -/// 从选中行 id 反推所属 pom:只有 phase 行可反推,其余返回空。 -fn selected_pom(selected: &Option) -> Option { - selected - .as_ref() - .and_then(|id| id.split_once("#lifecycle#").map(|(pom, _)| pom.to_string())) +/// 模块节点的展开键与 owner 键:根项目为 `project`,子模块为 +/// `module:{relativePath}`(与 `maven.scan` 的模块路径约定一致)。 +fn owner_key(relative_path: &str) -> String { + if relative_path == "." || relative_path.is_empty() { + "project".to_string() + } else { + format!("module:{relative_path}") + } +} + +/// owner 键反推模块相对路径:根项目为 `.`。 +fn relative_for_owner(owner: &str) -> String { + owner.strip_prefix("module:").unwrap_or(".").to_string() +} + +/// 模块相对路径对应的 pom 绝对路径。 +fn pom_for_relative(root: &str, relative_path: &str) -> String { + let mut path = std::path::PathBuf::from(root); + if !relative_path.is_empty() && relative_path != "." { + path.push(relative_path); + } + path.push("pom.xml"); + path.to_string_lossy().into_owned() } /// pom 行的显示路径:相对 root,根 pom 显示 `pom.xml`。 @@ -133,61 +151,189 @@ fn relative_pom_paths(root: &str, poms: &[String]) -> Vec { .collect() } -/// pom 所在目录相对 root 的模块路径:根 pom 为 `.`,与 core 的模块路径约定一致。 -fn module_path_for_pom(root: &str, pom: &str) -> String { - let relative = std::path::Path::new(pom) - .parent() - .and_then(|dir| dir.strip_prefix(root).ok()) - .map(|rel| rel.to_string_lossy().replace('\\', "/")) - .unwrap_or_default(); - let trimmed = relative.trim_matches('/').to_string(); - if trimmed.is_empty() { - ".".to_string() - } else { - trimmed - } -} - -/// 对齐 Tauri `projectStatus` 的工程状态:`idle` / `loading` / `ready` / `failed`。 +/// 对齐 Tauri `projectStatus` 的工程状态:`idle` / `loading` / `ready` / `failed`; +/// 依赖加载另有 `Cancelled`(用户主动取消,可重试)。 #[derive(Debug, Clone, PartialEq, Eq)] pub enum MavenStatus { Idle, Loading, Ready, Failed(String), + Cancelled, } -/// 依赖列表行的最小展示字段,来自 `maven.dependencies` 返回的 -/// `dependencies[]`(`groupId` / `artifactId` / `version` / `scope`)。 +/// 依赖列表行的完整字段,对齐 core `maven.dependencies` 返回的 +/// `dependencies[]`(含嵌套 `children` 与 `resolution` 标记)。 #[derive(Debug, Clone)] pub struct MavenDep { pub group: String, pub artifact: String, pub version: String, + pub dep_type: String, + pub classifier: Option, pub scope: String, + /// `resolved` / `omittedConflict` / `omittedDuplicate`…… + pub resolution: String, + pub selected_version: Option, + pub children: Vec, +} + +/// 从 `maven.dependencies` 的返回体递归解析依赖树,缺失三元组直接丢弃。 +fn parse_dep(item: &serde_json::Value) -> Option { + let children = item + .get("children") + .and_then(|value| value.as_array()) + .map(|items| items.iter().filter_map(parse_dep).collect()) + .unwrap_or_default(); + Some(MavenDep { + group: item.get("groupId")?.as_str()?.to_string(), + artifact: item.get("artifactId")?.as_str()?.to_string(), + version: item + .get("version") + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_string(), + dep_type: item + .get("type") + .and_then(|value| value.as_str()) + .unwrap_or("jar") + .to_string(), + classifier: item + .get("classifier") + .and_then(|value| value.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string), + scope: item + .get("scope") + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_string(), + resolution: item + .get("resolution") + .and_then(|value| value.as_str()) + .unwrap_or("resolved") + .to_string(), + selected_version: item + .get("selectedVersion") + .and_then(|value| value.as_str()) + .map(str::to_string), + children, + }) } -/// 从 `maven.dependencies` 的返回体解析依赖列表,缺失字段的行直接丢弃。 fn parse_deps(data: &serde_json::Value) -> Vec { data.get("dependencies") .and_then(|value| value.as_array()) + .map(|items| items.iter().filter_map(parse_dep).collect()) + .unwrap_or_default() +} + +/// 依赖副标题:`groupId:version:type[:classifier] [scope] [(标记)]`, +/// 与 Tauri `renderDependency` 的 subtitle 口径一致。 +fn dep_subtitle(dep: &MavenDep, cx: &gpui_kit::App) -> String { + let classifier = dep + .classifier + .as_deref() + .map(|c| format!(":{c}")) + .unwrap_or_default(); + let marker = if dep.resolution == "omittedConflict" { + let arrow = dep + .selected_version + .as_deref() + .map(|v| format!(" -> {v}")) + .unwrap_or_default(); + format!( + " ({}{arrow})", + crate::i18n::menu_text(cx, "maven.omittedConflict") + ) + } else if dep.resolution == "omittedDuplicate" { + format!( + " ({})", + crate::i18n::menu_text(cx, "maven.omittedDuplicate") + ) + } else { + String::new() + }; + format!( + "{}:{}:{}{} [{}]{marker}", + dep.group, dep.version, dep.dep_type, classifier, dep.scope + ) +} + +/// 扫描得到的模块节点:`maven.scan` 响应体的内存子集 +///(`relativePath` / `artifactId` / `packaging` / `sourceRoots` / `modules`)。 +#[derive(Debug, Clone, Default)] +pub struct MavenModuleNode { + pub relative_path: String, + pub artifact_id: String, + pub packaging: String, + pub source_roots: Vec<(String, String)>, + pub modules: Vec, +} + +/// 扫描得到的 profile:`profiles[]` 的 `id` 与默认激活态。 +#[derive(Debug, Clone)] +pub struct MavenProfileItem { + pub id: String, + pub active_by_default: bool, +} + +fn parse_module_node(value: &serde_json::Value) -> Option { + Some(MavenModuleNode { + relative_path: value + .get("relativePath") + .and_then(|v| v.as_str()) + .unwrap_or(".") + .to_string(), + artifact_id: value + .get("artifactId") + .and_then(|v| v.as_str())? + .to_string(), + packaging: value + .get("packaging") + .and_then(|v| v.as_str()) + .unwrap_or("jar") + .to_string(), + source_roots: value + .get("sourceRoots") + .and_then(|v| v.as_array()) + .map(|roots| { + roots + .iter() + .filter_map(|root| { + Some(( + root.get("path")?.as_str()?.to_string(), + root.get("kind") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + )) + }) + .collect() + }) + .unwrap_or_default(), + modules: value + .get("modules") + .and_then(|v| v.as_array()) + .map(|items| items.iter().filter_map(parse_module_node).collect()) + .unwrap_or_default(), + }) +} + +fn parse_profiles(value: &serde_json::Value) -> Vec { + value + .get("profiles") + .and_then(|v| v.as_array()) .map(|items| { items .iter() .filter_map(|item| { - Some(MavenDep { - group: item.get("groupId")?.as_str()?.to_string(), - artifact: item.get("artifactId")?.as_str()?.to_string(), - version: item - .get("version") - .and_then(|value| value.as_str()) - .unwrap_or_default() - .to_string(), - scope: item - .get("scope") - .and_then(|value| value.as_str()) - .unwrap_or_default() - .to_string(), + Some(MavenProfileItem { + id: item.get("id")?.as_str()?.to_string(), + active_by_default: item + .get("isActiveByDefault") + .and_then(|v| v.as_bool()) + .unwrap_or(false), }) }) .collect() @@ -195,31 +341,49 @@ fn parse_deps(data: &serde_json::Value) -> Vec { .unwrap_or_default() } -/// Maven 导航面板派发的事件:执行只发射,由外部经底部 Terminal 执行。 +/// Maven 导航面板派发的事件:执行只发射,由外部经底部 Maven 页执行; +/// 依赖行点击发射 `OpenFile`(打开模块 pom),设置按钮发射 `OpenSettings`。 #[derive(Debug, Clone)] pub enum MavenEvent { - RunGoal { pom_path: String, phase: String }, + RunGoal { + pom_path: String, + phase: String, + /// 展示用目标(`{phase} · {artifactId}`,对齐 Tauri 任务标题)。 + target: String, + profiles: Vec, + skip_tests: bool, + }, + OpenFile(String), + OpenSettings, Close, } -/// 右侧 Maven 导航工具窗口:项目树 + 生命周期阶段 + 依赖 + 自定义 goal。 +/// 右侧 Maven 导航工具窗口:模块树 + 生命周期 + 依赖 + Profiles + 自定义 goal, +/// 对齐 Tauri `MavenPane` 的树语义(caret 与标签分离、子节点引导线、 +/// 单击选中、phase 双击运行)。 pub struct MavenView { pub root: String, + /// 本地 pom 兜底列表:core 无工程时沿用旧平铺展示。 poms: Vec, + /// 扫描得到的反应堆根节点(`relativePath == "."`)。 + project: Option, + profiles: Vec, + selected_profiles: HashSet, + skip_tests: bool, expanded: HashSet, - selected: Option, + /// 选中的模块相对路径;`None` 为根项目。 + selected_module: Option, + selected_phase: String, project_status: MavenStatus, - project_name: Option, - dependencies: Vec, - dep_status: MavenStatus, - /// 一次只展开一个模块的依赖组,存 pom 绝对路径。 - expanded_deps: Option, + dependencies: HashMap>, + dep_status: HashMap, + dep_seq: HashMap, + dep_ops: HashMap, custom_goal: String, goal_input: Option>, _goal_subscription: Option, client: CoreClient, scan_seq: u64, - dep_seq: u64, } impl EventEmitter for MavenView {} @@ -229,19 +393,23 @@ impl MavenView { let mut view = Self { root, poms: Vec::new(), + project: None, + profiles: Vec::new(), + selected_profiles: HashSet::new(), + skip_tests: false, expanded: HashSet::new(), - selected: None, + selected_module: None, + selected_phase: "compile".to_string(), project_status: MavenStatus::Idle, - project_name: None, - dependencies: Vec::new(), - dep_status: MavenStatus::Idle, - expanded_deps: None, + dependencies: HashMap::new(), + dep_status: HashMap::new(), + dep_seq: HashMap::new(), + dep_ops: HashMap::new(), custom_goal: String::new(), goal_input: None, _goal_subscription: None, client: CoreClient::new(), scan_seq: 0, - dep_seq: 0, }; view.reload(cx); view @@ -249,24 +417,35 @@ impl MavenView { pub fn set_root(&mut self, root: String, cx: &mut Context) { self.root = root; - self.expanded_deps = None; + self.project = None; + self.profiles.clear(); + self.selected_profiles.clear(); + self.selected_module = None; self.dependencies.clear(); - self.dep_status = MavenStatus::Idle; + self.dep_status.clear(); self.reload(cx); } pub fn refresh(&mut self, cx: &mut Context) { - self.expanded_deps = None; + self.project = None; + self.profiles.clear(); + self.selected_profiles.clear(); self.dependencies.clear(); - self.dep_status = MavenStatus::Idle; + self.dep_status.clear(); self.reload(cx); } pub fn has_projects(&self) -> bool { - !self.poms.is_empty() + self.project.is_some() || !self.poms.is_empty() } - /// 重扫 pom 列表并默认展开全部 pom 节点与 Lifecycle 组。 + /// 全部折叠(对齐 Tauri `collapseAll`)。 + fn collapse_all(&mut self, cx: &mut Context) { + self.expanded.clear(); + cx.notify(); + } + + /// 重扫 pom 列表(core 无工程时的兜底展示),默认展开全部。 fn rescan(&mut self) { self.poms = find_pom_files(&self.root); self.expanded.clear(); @@ -277,9 +456,10 @@ impl MavenView { } /// 本地重扫 + core `maven.scan` 拿模块结构: - /// payload `{root, paths}`(相对 pom 路径),返回 `MavenScanResponse` - ///(`relativePath` / `artifactId` / `modules` / `profiles` …)或 `null` - ///(未检出工程)。失败只落 `project_status`,导航照常展示本地 pom。 + /// payload `{root, paths}`(相对 pom 路径)。成功解析模块树、 + /// Profiles 与默认选中(`isActiveByDefault`),初始展开根项目与 + /// Profiles(对齐 Tauri 初次展开);失败只落 `project_status`, + /// 导航照常展示本地 pom 兜底。 fn reload(&mut self, cx: &mut Context) { self.rescan(); self.project_status = MavenStatus::Loading; @@ -301,14 +481,27 @@ impl MavenView { } match result { Ok(data) => { - view.project_name = data - .get("artifactId") - .and_then(|value| value.as_str()) - .map(str::to_string); + view.project = parse_module_node(&data); + view.profiles = parse_profiles(&data); + view.selected_profiles = view + .profiles + .iter() + .filter(|p| p.active_by_default) + .map(|p| p.id.clone()) + .collect(); + view.selected_module = None; + view.selected_phase = "compile".to_string(); + view.expanded.clear(); + view.expanded.insert("project".to_string()); + if !view.profiles.is_empty() { + view.expanded.insert("profiles".to_string()); + } view.project_status = MavenStatus::Ready; } Err(err) => { - view.project_name = None; + view.project = None; + view.profiles.clear(); + view.selected_profiles.clear(); view.project_status = MavenStatus::Failed(err); } } @@ -325,43 +518,127 @@ impl MavenView { cx.notify(); } - /// 依赖组一次只展开一个:收起直接关,展开则触发依赖加载。 - fn toggle_deps(&mut self, pom: &str, cx: &mut Context) { - if self.expanded_deps.as_deref() == Some(pom) { - self.expanded_deps = None; + /// 依赖组展开即加载、收起即保留缓存(对齐 Tauri 展开加载语义)。 + fn toggle_deps(&mut self, relative_path: &str, cx: &mut Context) { + let key = deps_key(&owner_key(relative_path)); + if !self.expanded.remove(&key) { + self.expanded.insert(key); + self.load_deps(relative_path, cx); + } else { cx.notify(); + } + } + + /// 取消指定模块的依赖加载(序号递增丢弃迟到结果,对齐 Tauri 取消)。 + fn cancel_deps(&mut self, relative_path: &str, cx: &mut Context) { + let seq = self.dep_seq.get(relative_path).copied().unwrap_or(0) + 1; + self.dep_seq.insert(relative_path.to_string(), seq); + if let Some(op) = self.dep_ops.remove(relative_path) { + self.client.cancel(&op); + } + self.dep_status + .insert(relative_path.to_string(), MavenStatus::Cancelled); + cx.notify(); + } + + /// 选中 phase(单击语义,不执行)。 + fn select_phase(&mut self, relative_path: &str, phase: &str, cx: &mut Context) { + self.selected_module = if relative_path == "." { + None } else { - self.expanded_deps = Some(pom.to_string()); - self.load_deps(pom, cx); + Some(relative_path.to_string()) + }; + self.selected_phase = phase.to_string(); + cx.notify(); + } + + /// 发射执行事件:标题 `{phase} · {artifactId}`(对齐 Tauri 任务标题)。 + fn emit_run_goal(&mut self, relative_path: &str, phase: &str, cx: &mut Context) { + self.select_phase(relative_path, phase, cx); + let artifact = self + .find_module(relative_path) + .map(|m| m.artifact_id.clone()) + .or_else(|| self.project.as_ref().map(|p| p.artifact_id.clone())) + .unwrap_or_else(|| crate::i18n::menu_text(cx, "maven.project").to_string()); + let mut profiles: Vec = self.selected_profiles.iter().cloned().collect(); + profiles.sort(); + cx.emit(MavenEvent::RunGoal { + pom_path: pom_for_relative(&self.root, relative_path), + phase: phase.to_string(), + target: artifact, + profiles, + skip_tests: self.skip_tests, + }); + } + + /// 运行当前选中 scope(头部运行按钮,对齐 Tauri `runSelected`)。 + fn run_selected(&mut self, cx: &mut Context) { + if self.project.is_none() { + return; } + let relative = self + .selected_module + .clone() + .unwrap_or_else(|| ".".to_string()); + let phase = self.selected_phase.clone(); + self.emit_run_goal(&relative, &phase, cx); + } + + /// 在模块树中按相对路径查找节点(含根)。 + fn find_module(&self, relative_path: &str) -> Option<&MavenModuleNode> { + if relative_path == "." || relative_path.is_empty() { + return self.project.as_ref(); + } + fn walk<'a>(node: &'a MavenModuleNode, rel: &str) -> Option<&'a MavenModuleNode> { + if node.relative_path == rel { + return Some(node); + } + node.modules.iter().find_map(|child| walk(child, rel)) + } + self.project + .as_ref() + .and_then(|root| walk(root, relative_path)) } /// 先 `maven.dependencyPlan` 校验模块并拿出确定的离线调用计划 - /// (payload `{root, context: {version: 1, reactorPath: "."}, module}`, + /// (payload `{root, context: {version: 1, reactorPath: ".", profiles, skipTests}, module}`, /// 返回 `MavenLaunchPlanResponse`),再 `maven.dependencies` /// (payload `{modulePath, output}`,返回 `{modulePath, dependencies[]}`) - /// 解析依赖列表。Linux 侧无 Maven 进程宿主,`output` 为空即返回空列表; + /// 递归解析依赖树。Linux 侧无 Maven 进程宿主,`output` 为空即返回空列表; /// 真实树需在终端运行 `dependency:tree`,失败文案直接展示。 - fn load_deps(&mut self, pom: &str, cx: &mut Context) { - self.dep_seq += 1; - let seq = self.dep_seq; - self.dep_status = MavenStatus::Loading; - self.dependencies.clear(); + /// 每个模块独立序号与 operationId,支持取消(对齐 Tauri 各模块独立加载)。 + fn load_deps(&mut self, relative_path: &str, cx: &mut Context) { + let seq = self.dep_seq.get(relative_path).copied().unwrap_or(0) + 1; + self.dep_seq.insert(relative_path.to_string(), seq); + self.dep_status + .insert(relative_path.to_string(), MavenStatus::Loading); + self.dependencies.remove(relative_path); cx.notify(); let client = self.client.clone(); let root = self.root.clone(); - let module = module_path_for_pom(&self.root, pom); + let module = relative_path.to_string(); + let mut profiles: Vec = self.selected_profiles.iter().cloned().collect(); + profiles.sort(); + let skip_tests = self.skip_tests; + let op = uuid::Uuid::new_v4().to_string(); + self.dep_ops.insert(relative_path.to_string(), op.clone()); cx.spawn(async move |this, cx| { let plan = client - .execute::( + .execute_with_operation_id::( &cx, "maven.dependencyPlan", serde_json::json!({ "root": root, - "context": { "version": 1, "reactorPath": "." }, + "context": { + "version": 1, + "reactorPath": ".", + "profiles": profiles, + "skipTests": skip_tests, + }, "module": module, }), + Some(op), ) .await; let outcome: Result, String> = match plan { @@ -379,16 +656,18 @@ impl MavenView { Err(err) => Err(err), }; let _ = this.update(cx, |view, cx| { - if view.dep_seq != seq { + if view.dep_seq.get(&module).copied().unwrap_or(0) != seq { return; } + view.dep_ops.remove(&module); match outcome { Ok(deps) => { - view.dependencies = deps; - view.dep_status = MavenStatus::Ready; + view.dependencies.insert(module.clone(), deps); + view.dep_status.insert(module.clone(), MavenStatus::Ready); } Err(err) => { - view.dep_status = MavenStatus::Failed(err); + view.dep_status + .insert(module.clone(), MavenStatus::Failed(err)); } } cx.notify(); @@ -412,6 +691,44 @@ impl MavenView { } } +/// pom 所在目录相对 root 的模块路径:根 pom 为 `.`(兜底展示用)。 +fn relative_for_pom(root: &str, pom: &str) -> String { + let relative = std::path::Path::new(pom) + .parent() + .and_then(|dir| dir.strip_prefix(root).ok()) + .map(|rel| rel.to_string_lossy().replace('\\', "/")) + .unwrap_or_default(); + let trimmed = relative.trim_matches('/').to_string(); + if trimmed.is_empty() { + ".".to_string() + } else { + trimmed + } +} + +/// 子节点容器:左缩进 + 引导线,对齐 Tauri `ml-4 border-l pl-1`。 +fn child_container(children: Vec) -> gpui_kit::AnyElement { + div() + .w_full() + .ml(px(16.0)) + .pl(px(4.0)) + .border_l_1() + .border_color(ThemeColors::border()) + .children(children) + .into_any_element() +} + +impl MavenView { + /// 分发 caret/标签的折叠动作:普通节点切 `expanded`,依赖组附带加载。 + fn dispatch_toggle(&mut self, key: &str, cx: &mut Context) { + if let Some(owner) = key.strip_suffix(":dependencies") { + self.toggle_deps(&relative_for_owner(owner), cx); + } else { + self.toggle_node(key, cx); + } + } +} + impl Render for MavenView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { // 自定义 goal 输入框懒创建 + 一次性订阅 Change 事件,值同步到 custom_goal。 @@ -428,280 +745,183 @@ impl Render for MavenView { )); } - // 快照后构建行,避免在 `.children` 闭包里同时借用 self 与 cx。 - let root = self.root.clone(); - let poms = self.poms.clone(); + // 快照后构建行,避免在闭包里同时借用 self 与 cx。 let expanded = self.expanded.clone(); - let selected = self.selected.clone(); + let project = self.project.clone(); + let profiles = self.profiles.clone(); + let selected_profiles = self.selected_profiles.clone(); + let skip_tests = self.skip_tests; + let selected_module = self.selected_module.clone(); + let selected_phase = self.selected_phase.clone(); let project_status = self.project_status.clone(); - let project_name = self.project_name.clone(); - let expanded_deps = self.expanded_deps.clone(); let dep_status = self.dep_status.clone(); let dependencies = self.dependencies.clone(); let custom_goal = self.custom_goal.clone(); - let has_projects = !poms.is_empty(); + let poms = self.poms.clone(); - let title_text = match &project_name { - Some(name) => format!("{} · {name}", crate::i18n::menu_text(cx, "maven.title")), + let title_text = match project.as_ref() { + Some(node) => format!( + "{} · {}", + crate::i18n::menu_text(cx, "maven.title"), + node.artifact_id + ), None => crate::i18n::menu_text(cx, "maven.title").to_string(), }; let status_banner: Option<(bool, String)> = match &project_status { - MavenStatus::Loading => Some((false, "正在扫描 Maven 项目…".to_string())), + MavenStatus::Loading => Some(( + false, + crate::i18n::menu_text(cx, "maven.scanning").to_string(), + )), MavenStatus::Failed(err) => Some((true, err.clone())), - MavenStatus::Idle | MavenStatus::Ready => None, + MavenStatus::Idle | MavenStatus::Ready | MavenStatus::Cancelled => None, }; - // 自定义 goal 的执行目标:选中 phase 行归属的 pom,否则首个 pom。 - let goal_target = selected_pom(&selected).or_else(|| poms.first().cloned()); - - let mut rows = Vec::new(); - for pom in &poms { - let pom_expanded = expanded.contains(pom); - let chevron = if pom_expanded { - IconName::ChevronDown - } else { - IconName::ChevronRight - }; - let display = display_pom_path(&root, pom); - rows.push( - h_flex() - .id(pom.clone()) - .h(px(24.0)) - .w_full() - .items_center() - .cursor_pointer() - .rounded_sm() - .pl(px(6.0)) - .pr_2() - .gap_1p5() - .text_xs() - .text_color(ThemeColors::text_primary()) - .hover(|h| h.bg(ThemeColors::bg_tab_hover())) - .child( - Icon::new(chevron) - .size(px(12.0)) - .text_color(ThemeColors::text_muted()), - ) - .child( - Icon::new(IconName::Box) - .size(px(14.0)) - .text_color(ThemeColors::accent_blue()), - ) - .child(div().flex_1().truncate().child(display)) - .on_click(cx.listener({ - let pom = pom.clone(); - move |this, _event, _window, cx| { - this.toggle_node(&pom, cx); - } - })) - .into_any_element(), - ); - if !pom_expanded { - continue; - } + let has_project = project.is_some(); - let group_key = lifecycle_key(pom); - let group_expanded = expanded.contains(&group_key); - let group_chevron = if group_expanded { - IconName::ChevronDown - } else { - IconName::ChevronRight - }; - rows.push( - h_flex() - .id(group_key.clone()) - .h(px(24.0)) - .w_full() - .items_center() - .cursor_pointer() - .rounded_sm() - .pl(px(18.0)) - .pr_2() - .gap_1p5() - .text_xs() - .text_color(ThemeColors::text_primary()) - .hover(|h| h.bg(ThemeColors::bg_tab_hover())) - .child( - Icon::new(group_chevron) - .size(px(12.0)) - .text_color(ThemeColors::text_muted()), - ) - .child( - Icon::new(IconName::Package) - .size(px(14.0)) - .text_color(ThemeColors::accent_blue()), - ) - .child( - div() - .flex_1() - .truncate() - .child(crate::i18n::menu_text(cx, "maven.lifecycle")), - ) - .on_click(cx.listener({ - let group_key = group_key.clone(); - move |this, _event, _window, cx| { - this.toggle_node(&group_key, cx); - } - })) - .into_any_element(), - ); - if group_expanded { - for phase in MAVEN_LIFECYCLE_PHASES { - let row_id = phase_id(pom, phase); - let is_selected = selected.as_deref() == Some(row_id.as_str()); - rows.push( - h_flex() - .id(row_id.clone()) - .h(px(24.0)) - .w_full() - .items_center() - .cursor_pointer() - .rounded_sm() - .pl(px(30.0)) - .pr_2() - .gap_1p5() - .text_xs() - .when(is_selected, |row| { - row.bg(ThemeColors::subtle_selection()) - .text_color(ThemeColors::text_primary()) - }) - .when(!is_selected, |row| { - row.text_color(ThemeColors::text_primary()) - .hover(|h| h.bg(ThemeColors::bg_tab_hover())) - }) - .child( - Icon::new(IconName::Play) - .size(px(12.0)) - .text_color(ThemeColors::text_muted()), - ) - .child(div().flex_1().truncate().child(phase)) - .on_click(cx.listener({ - let pom = pom.clone(); - let phase = phase.to_string(); - move |this, _event, _window, cx| { - this.selected = Some(phase_id(&pom, &phase)); - cx.emit(MavenEvent::RunGoal { - pom_path: pom.clone(), - phase: phase.clone(), - }); - cx.notify(); - } - })) - .into_any_element(), - ); + // 当前选中 scope 的执行信息(头部运行按钮与自定义 goal 共用)。 + let scope_rel = selected_module.clone().unwrap_or_else(|| ".".to_string()); + let scope_pom = pom_for_relative(&self.root, &scope_rel); + let scope_target = project + .as_ref() + .and_then(|root| { + if scope_rel == "." { + Some(root.artifact_id.clone()) + } else { + find_module_in(root, &scope_rel).map(|m| m.artifact_id.clone()) } - } + }) + .unwrap_or_else(|| display_pom_path(&self.root, &scope_pom)); + let scope_profiles: Vec = { + let mut ids: Vec = selected_profiles.iter().cloned().collect(); + ids.sort(); + ids + }; - // 依赖组:一次只展开一个模块,点击触发 dependencyPlan + dependencies。 - let deps_open = expanded_deps.as_deref() == Some(pom.as_str()); - let deps_chevron = if deps_open { - IconName::ChevronDown - } else { - IconName::ChevronRight - }; - rows.push( - h_flex() - .id(deps_key(pom)) - .h(px(24.0)) - .w_full() - .items_center() - .cursor_pointer() - .rounded_sm() - .pl(px(18.0)) - .pr_2() - .gap_1p5() - .text_xs() - .text_color(ThemeColors::text_primary()) - .hover(|h| h.bg(ThemeColors::bg_tab_hover())) - .child( - Icon::new(deps_chevron) - .size(px(12.0)) - .text_color(ThemeColors::text_muted()), - ) - .child( - Icon::new(IconName::Package) - .size(px(14.0)) - .text_color(ThemeColors::accent_blue()), - ) - .child(div().flex_1().truncate().child("依赖")) - .on_click(cx.listener({ - let pom = pom.clone(); - move |this, _event, _window, cx| { - this.toggle_deps(&pom, cx); - } - })) - .into_any_element(), - ); - if deps_open { - match &dep_status { - MavenStatus::Loading => { - rows.push( - div() - .w_full() - .pl(px(30.0)) - .pr_2() - .py_1() - .text_xs() - .text_color(ThemeColors::text_muted()) - .child("正在加载依赖…") - .into_any_element(), - ); - } - MavenStatus::Failed(err) => { - rows.push( - div() - .w_full() - .pl(px(30.0)) - .pr_2() - .py_1() - .text_xs() - .text_color(ThemeColors::accent_red()) - .child(err.clone()) - .into_any_element(), - ); - } - MavenStatus::Idle | MavenStatus::Ready if dependencies.is_empty() => { - rows.push( - div() - .w_full() - .pl(px(30.0)) - .pr_2() - .py_1() - .text_xs() - .text_color(ThemeColors::text_muted()) - .child("暂无依赖数据") - .into_any_element(), - ); - } - MavenStatus::Idle | MavenStatus::Ready => { - for (index, dep) in dependencies.iter().enumerate() { - let label = format!( - "{}:{}:{} [{}]", - dep.group, dep.artifact, dep.version, dep.scope - ); - rows.push( - h_flex() - .id(format!("{pom}#dep#{index}")) - .w_full() - .items_center() - .rounded_sm() - .pl(px(30.0)) - .pr_2() - .py_1() - .gap_1p5() - .text_xs() - .text_color(ThemeColors::text_primary()) - .hover(|h| h.bg(ThemeColors::bg_tab_hover())) - .child(div().flex_1().truncate().child(label)) - .into_any_element(), - ); - } - } + let mut body = Vec::new(); + if let Some(root_node) = project.clone() { + if !profiles.is_empty() { + body.push(self.render_profiles_node(&profiles, &selected_profiles, &expanded, cx)); + } + body.push(self.render_module_node( + &root_node, + None, + &expanded, + &selected_module, + &selected_phase, + &dep_status, + &dependencies, + cx, + )); + } else if !poms.is_empty() { + // core 无工程时的 pom 兜底:按 pom 所在目录合成 owner 行。 + for pom in &poms { + let rel = relative_for_pom(&self.root, pom); + let owner = owner_key(&rel); + let selected = if rel == "." { + selected_module.is_none() + } else { + selected_module.as_deref() == Some(rel.as_str()) + }; + body.push(self.render_owner_row( + format!("legacy-{owner}"), + OwnerToggle::Key(owner.clone()), + IconName::Package, + display_pom_path(&self.root, pom), + None, + selected, + OwnerSelect::Module(if rel == "." { None } else { Some(rel.clone()) }), + cx, + )); + if expanded.contains(&owner) { + let mut children = Vec::new(); + children.push(self.render_lifecycle_node( + &owner, + &rel, + &expanded, + &selected_module, + &selected_phase, + cx, + )); + children.push(self.render_deps_node( + &owner, + &rel, + &self.root, + &expanded, + &dep_status, + &dependencies, + cx, + )); + body.push(child_container(children)); } } } + let body_element = match (&project, &project_status) { + (None, MavenStatus::Failed(err)) if poms.is_empty() => v_flex() + .flex_1() + .w_full() + .items_center() + .justify_center() + .gap_2() + .p_4() + .child( + Icon::new(IconName::TriangleAlert) + .size(px(28.0)) + .text_color(ThemeColors::destructive()), + ) + .child( + div() + .text_xs() + .font_weight(FontWeight::BOLD) + .text_color(ThemeColors::text_primary()) + .child(crate::i18n::menu_text(cx, "maven.loadFailed")), + ) + .child( + div() + .w_full() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(err.clone()), + ) + .child( + Button::new("maven-retry") + .small() + .primary() + .label(crate::i18n::menu_text(cx, "ui.retry")) + .on_click(cx.listener(|this, _event, _window, cx| { + this.refresh(cx); + })), + ) + .into_any_element(), + (None, _) if body.is_empty() => div() + .flex_1() + .w_full() + .flex() + .items_center() + .justify_center() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(crate::i18n::menu_text( + cx, + match project_status { + MavenStatus::Loading => "maven.scanning", + _ => "maven.notDetected", + }, + )) + .into_any_element(), + _ => div() + .flex_1() + .w_full() + .min_h_0() + .overflow_y_scrollbar() + .px_2() + .py_1() + .children(body) + .into_any_element(), + }; + let goal_entity_for_run = goal_entity.clone(); - let goal_target_for_run = goal_target.clone(); + let run_scope = (scope_pom.clone(), scope_target.clone()); + let run_profiles = scope_profiles.clone(); v_flex() .size_full() .bg(ThemeColors::bg_sidebar()) @@ -719,35 +939,111 @@ impl Render for MavenView { .px_3() .child( div() + .flex_1() + .truncate() .text_xs() .font_weight(FontWeight::BOLD) .text_color(ThemeColors::text_muted()) .child(title_text), ) .child( - h_flex() - .items_center() - .gap_1() - .child( - Button::new("maven-refresh") - .small() - .ghost() - .icon(IconName::RotateCw) - .tooltip(crate::i18n::menu_text(cx, "ui.refresh")) - .on_click(cx.listener(|this, _event, _window, cx| { - this.refresh(cx); - })), - ) - .child( - Button::new("maven-close") - .small() - .ghost() - .icon(IconName::Close) - .tooltip(crate::i18n::menu_text(cx, "ui.close")) - .on_click(cx.listener(|_this, _event, _window, cx| { - cx.emit(MavenEvent::Close); - })), - ), + Button::new("maven-close") + .small() + .ghost() + .icon(IconName::Close) + .tooltip(crate::i18n::menu_text(cx, "ui.close")) + .on_click(cx.listener(|_this, _event, _window, cx| { + cx.emit(MavenEvent::Close); + })), + ), + ) + .child( + // 工具栏:运行选中 / 执行目标文本 / reload / 跳过测试 / + // 全部折叠 / 设置,对齐 Tauri 第二行。 + h_flex() + .h(px(32.0)) + .w_full() + .flex_shrink_0() + .items_center() + .gap_1() + .px_2() + .border_b_1() + .border_color(ThemeColors::border()) + .child( + Button::new("maven-run-selected") + .small() + .ghost() + .icon(IconName::Play) + .tooltip(crate::i18n::menu_text(cx, "maven.runSelected")) + .disabled(!has_project) + .on_click(cx.listener(|this, _event, _window, cx| { + this.run_selected(cx); + })), + ) + .child( + Button::new("maven-exec-goal") + .small() + .ghost() + .icon(IconName::Terminal) + .tooltip(crate::i18n::menu_text(cx, "maven.executeGoal")) + .disabled(custom_goal.trim().is_empty() || run_scope.0.is_empty()) + .on_click(cx.listener(move |this, _event, _window, cx| { + let goal = goal_entity_for_run.read(cx).value().to_string(); + let goal = goal.trim().to_string(); + if goal.is_empty() { + return; + } + this.emit_text_goal( + &run_scope.0, + &goal, + &run_scope.1, + &run_profiles, + cx, + ); + })), + ) + .child( + Button::new("maven-reload") + .small() + .ghost() + .icon(IconName::RotateCw) + .tooltip(crate::i18n::menu_text(cx, "maven.reloadProjects")) + .on_click(cx.listener(|this, _event, _window, cx| { + this.refresh(cx); + })), + ) + .child( + Button::new("maven-skip-tests") + .small() + .when(skip_tests, |b| b.primary()) + .when(!skip_tests, |b| b.ghost()) + .icon(IconName::Check) + .tooltip(crate::i18n::menu_text(cx, "maven.skipTests")) + .on_click(cx.listener(|this, _event, _window, cx| { + this.skip_tests = !this.skip_tests; + cx.notify(); + })), + ) + .child( + Button::new("maven-collapse-all") + .small() + .ghost() + .icon(IconName::FoldVertical) + .tooltip(crate::i18n::menu_text(cx, "maven.collapseAll")) + .on_click(cx.listener(|this, _event, _window, cx| { + this.collapse_all(cx); + })), + ) + .child( + Button::new("maven-settings") + .small() + .ghost() + .icon(IconName::Settings) + .tooltip(crate::i18n::menu_text(cx, "maven.settings")) + .disabled(!has_project) + .on_click(cx.listener(|_this, _event, _window, cx| { + cx.emit(MavenEvent::OpenSettings); + })), ), ) .when_some(status_banner, |this, (is_error, text)| { @@ -767,25 +1063,7 @@ impl Render for MavenView { .child(text), ) }) - .child(if has_projects { - div() - .flex_1() - .w_full() - .overflow_y_scrollbar() - .children(rows) - .into_any_element() - } else { - div() - .flex_1() - .w_full() - .flex() - .items_center() - .justify_center() - .text_xs() - .text_color(ThemeColors::text_muted()) - .child(crate::i18n::menu_text(cx, "maven.noProjects")) - .into_any_element() - }) + .child(body_element) .child( h_flex() .w_full() @@ -804,24 +1082,631 @@ impl Render for MavenView { .small() .primary() .icon(IconName::Play) - .tooltip("执行自定义 goal") - .disabled( - custom_goal.trim().is_empty() || goal_target_for_run.is_none(), - ) - .on_click(cx.listener(move |_this, _event, _window, cx| { - let goal = goal_entity_for_run.read(cx).value().to_string(); - let goal = goal.trim().to_string(); + .tooltip(crate::i18n::menu_text(cx, "maven.executeGoal")) + .disabled(custom_goal.trim().is_empty() || scope_pom.is_empty()) + .on_click(cx.listener(move |this, _event, _window, cx| { + let goal = this.custom_goal.trim().to_string(); if goal.is_empty() { return; } - if let Some(pom) = goal_target_for_run.clone() { - cx.emit(MavenEvent::RunGoal { - pom_path: pom, - phase: goal, - }); - } + this.emit_text_goal( + &scope_pom, + &goal, + &scope_target, + &scope_profiles, + cx, + ); })), ), ) } } + +/// owner 行标签点击行为:选中模块,或仅折叠(依赖组等由 caret 覆盖时)。 +#[derive(Clone)] +enum OwnerSelect { + Module(Option), + Toggle, +} + +/// owner 行折叠行为:普通节点切展开态,依赖组附带加载。 +#[derive(Clone)] +enum OwnerToggle { + Key(String), + Deps(String), +} + +/// 在模块树中按相对路径查找节点(含根)。 +fn find_module_in<'a>( + root: &'a MavenModuleNode, + relative_path: &str, +) -> Option<&'a MavenModuleNode> { + if relative_path == "." || relative_path.is_empty() { + return Some(root); + } + fn walk<'a>(node: &'a MavenModuleNode, rel: &str) -> Option<&'a MavenModuleNode> { + if node.relative_path == rel { + return Some(node); + } + node.modules.iter().find_map(|child| walk(child, rel)) + } + walk(root, relative_path) +} + +impl MavenView { + /// 树父节点行:caret 与标签分离,选中高亮,对齐 Tauri `TreeNode` 头部。 + #[allow(clippy::too_many_arguments)] + fn render_owner_row( + &self, + id: String, + toggle: OwnerToggle, + icon: IconName, + title: String, + subtitle: Option, + selected: bool, + select: OwnerSelect, + cx: &mut Context, + ) -> gpui_kit::AnyElement { + let caret_key = match &toggle { + OwnerToggle::Key(key) => key.clone(), + OwnerToggle::Deps(owner) => format!("{owner}:dependencies"), + }; + let expanded = self.expanded.contains(&caret_key); + let chevron = if expanded { + IconName::ChevronDown + } else { + IconName::ChevronRight + }; + let label_base_id = format!("{id}-label"); + let label = h_flex() + .id(label_base_id) + .flex_1() + .min_w_0() + .items_center() + .gap_1p5() + .py_1() + .pr_2() + .cursor_pointer() + .child( + Icon::new(icon) + .size(px(14.0)) + .text_color(ThemeColors::text_primary()), + ) + .child( + div() + .truncate() + .text_xs() + .text_color(ThemeColors::text_primary()) + .child(title), + ) + .when_some(subtitle, |el, text| { + el.child( + div() + .truncate() + .font_family("monospace") + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(text), + ) + }); + let label = match select { + OwnerSelect::Module(rel) => { + label.on_click(cx.listener(move |this, _event, _window, cx| { + this.selected_module = rel.clone(); + cx.notify(); + })) + } + OwnerSelect::Toggle => { + let toggle = toggle.clone(); + label.on_click(cx.listener(move |this, _event, _window, cx| { + let key = match &toggle { + OwnerToggle::Key(key) => key.clone(), + OwnerToggle::Deps(owner) => format!("{owner}:dependencies"), + }; + this.dispatch_toggle(&key, cx); + })) + } + }; + h_flex() + .id(id.clone()) + .min_h(px(28.0)) + .w_full() + .items_center() + .rounded_sm() + .when(selected, |row| row.bg(ThemeColors::subtle_selection())) + .when(!selected, |row| { + row.hover(|h| h.bg(ThemeColors::bg_tab_hover())) + }) + .child( + div() + .id(format!("{id}-caret")) + .flex_shrink_0() + .size(px(24.0)) + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .child( + Icon::new(chevron) + .size(px(12.0)) + .text_color(ThemeColors::text_muted()), + ) + .on_click(cx.listener(move |this, _event, _window, cx| { + let key = match &toggle { + OwnerToggle::Key(key) => key.clone(), + OwnerToggle::Deps(owner) => format!("{owner}:dependencies"), + }; + this.dispatch_toggle(&key, cx); + })), + ) + .child(label) + .into_any_element() + } + + /// Profiles 节点:勾选进 `launchPlan` 上下文。 + fn render_profiles_node( + &self, + profiles: &[MavenProfileItem], + selected: &HashSet, + expanded: &HashSet, + cx: &mut Context, + ) -> gpui_kit::AnyElement { + let mut out = vec![self.render_owner_row( + "maven-profiles".to_string(), + OwnerToggle::Key("profiles".to_string()), + IconName::Folder, + crate::i18n::menu_text(cx, "maven.profiles").to_string(), + None, + false, + OwnerSelect::Toggle, + cx, + )]; + if expanded.contains("profiles") { + let mut children = Vec::new(); + for profile in profiles { + let checked = selected.contains(&profile.id); + let id = profile.id.clone(); + children.push( + h_flex() + .id(format!("maven-profile-{id}")) + .h(px(28.0)) + .w_full() + .items_center() + .gap_2() + .px_2() + .rounded_sm() + .cursor_pointer() + .text_xs() + .text_color(ThemeColors::text_primary()) + .hover(|h| h.bg(ThemeColors::bg_tab_hover())) + .child( + div() + .flex_shrink_0() + .size(px(14.0)) + .flex() + .items_center() + .justify_center() + .rounded_sm() + .border_1() + .border_color(ThemeColors::border()) + .when(checked, |el| { + el.bg(ThemeColors::accent_blue()) + .border_color(ThemeColors::accent_blue()) + }) + .child(Icon::new(IconName::Check).size(px(10.0)).text_color( + if checked { + ThemeColors::foreground() + } else { + ThemeColors::background() + }, + )), + ) + .child(div().flex_1().truncate().child(profile.id.clone())) + .on_click(cx.listener(move |this, _event, _window, cx| { + if !this.selected_profiles.remove(&id) { + this.selected_profiles.insert(id.clone()); + } + cx.notify(); + })) + .into_any_element(), + ); + } + out.push(child_container(children)); + } + div().w_full().children(out).into_any_element() + } + + /// 模块节点(含根项目):sourceRoots + lifecycle + dependencies + 嵌套模块。 + #[allow(clippy::too_many_arguments)] + fn render_module_node( + &self, + node: &MavenModuleNode, + _parent: Option, + expanded: &HashSet, + selected_module: &Option, + selected_phase: &str, + dep_status: &HashMap, + dependencies: &HashMap>, + cx: &mut Context, + ) -> gpui_kit::AnyElement { + let owner = owner_key(&node.relative_path); + let selected = if node.relative_path == "." { + selected_module.is_none() + } else { + selected_module.as_deref() == Some(node.relative_path.as_str()) + }; + let select_rel = if node.relative_path == "." { + None + } else { + Some(node.relative_path.clone()) + }; + let mut out = vec![self.render_owner_row( + format!("maven-{owner}"), + OwnerToggle::Key(owner.clone()), + IconName::Package, + node.artifact_id.clone(), + Some(node.packaging.clone()), + selected, + OwnerSelect::Module(select_rel), + cx, + )]; + if expanded.contains(&owner) { + let mut children = Vec::new(); + if !node.source_roots.is_empty() { + let key = source_roots_key(&owner); + children.push(self.render_owner_row( + format!("maven-{key}"), + OwnerToggle::Key(key.clone()), + IconName::Folder, + crate::i18n::menu_text(cx, "maven.sourceRoots").to_string(), + None, + false, + OwnerSelect::Toggle, + cx, + )); + if expanded.contains(&key) { + let mut root_rows = Vec::new(); + for (path, kind) in &node.source_roots { + root_rows.push( + h_flex() + .w_full() + .items_center() + .gap_1p5() + .h(px(24.0)) + .px_2() + .text_xs() + .child( + Icon::new(IconName::FileText) + .size(px(12.0)) + .text_color(ThemeColors::text_muted()), + ) + .child(div().flex_1().truncate().child(path.clone())) + .when(!kind.is_empty(), |el| { + el.child( + div() + .font_family("monospace") + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(kind.clone()), + ) + }) + .into_any_element(), + ); + } + children.push(child_container(root_rows)); + } + } + children.push(self.render_lifecycle_node( + &owner, + &node.relative_path, + expanded, + selected_module, + selected_phase, + cx, + )); + children.push(self.render_deps_node( + &owner, + &node.relative_path, + &self.root, + expanded, + dep_status, + dependencies, + cx, + )); + for child in &node.modules { + children.push(self.render_module_node( + child, + Some(owner.clone()), + expanded, + selected_module, + selected_phase, + dep_status, + dependencies, + cx, + )); + } + out.push(child_container(children)); + } + div().w_full().children(out).into_any_element() + } + + /// Lifecycle 节点:phase 单击选中、双击运行(对齐 Tauri 单击/双击语义)。 + fn render_lifecycle_node( + &self, + owner: &str, + relative_path: &str, + expanded: &HashSet, + selected_module: &Option, + selected_phase: &str, + cx: &mut Context, + ) -> gpui_kit::AnyElement { + let key = lifecycle_key(owner); + let mut out = vec![self.render_owner_row( + format!("maven-{key}"), + OwnerToggle::Key(key.clone()), + IconName::Cog, + crate::i18n::menu_text(cx, "maven.lifecycle").to_string(), + None, + false, + OwnerSelect::Toggle, + cx, + )]; + if expanded.contains(&key) { + let mut rows = Vec::new(); + for phase in MAVEN_LIFECYCLE_PHASES { + let is_selected = selected_phase == phase + && (if relative_path == "." { + selected_module.is_none() + } else { + selected_module.as_deref() == Some(relative_path) + }); + let rel = relative_path.to_string(); + let phase_owned = phase.to_string(); + rows.push( + h_flex() + .id(format!("maven-{owner}-phase-{phase}")) + .h(px(28.0)) + .w_full() + .items_center() + .gap_1p5() + .px_2() + .rounded_sm() + .cursor_pointer() + .text_xs() + .when(is_selected, |row| { + row.bg(ThemeColors::subtle_selection()) + .text_color(ThemeColors::text_primary()) + }) + .when(!is_selected, |row| { + row.text_color(ThemeColors::text_primary()) + .hover(|h| h.bg(ThemeColors::bg_tab_hover())) + }) + .child(Icon::new(IconName::Play).size(px(12.0)).text_color( + if is_selected { + ThemeColors::accent_green() + } else { + ThemeColors::text_muted() + }, + )) + .child(div().flex_1().truncate().child(phase)) + .on_click(cx.listener(move |this, event: &ClickEvent, _window, cx| { + let double = matches!( + event, + ClickEvent::Mouse(m) if m.down.click_count >= 2 + ); + if double { + this.emit_run_goal(&rel, &phase_owned, cx); + } else { + this.select_phase(&rel, &phase_owned, cx); + } + })) + .into_any_element(), + ); + } + out.push(child_container(rows)); + } + div().w_full().children(out).into_any_element() + } + + /// 依赖节点:嵌套树 + Tauri 同式副标题 + 加载/失败重试/取消/空态。 + #[allow(clippy::too_many_arguments)] + fn render_deps_node( + &self, + owner: &str, + relative_path: &str, + root: &str, + expanded: &HashSet, + dep_status: &HashMap, + dependencies: &HashMap>, + cx: &mut Context, + ) -> gpui_kit::AnyElement { + let key = deps_key(owner); + let mut out = vec![self.render_owner_row( + format!("maven-{key}"), + OwnerToggle::Deps(owner.to_string()), + IconName::Package, + crate::i18n::menu_text(cx, "maven.dependencies").to_string(), + None, + false, + OwnerSelect::Toggle, + cx, + )]; + if expanded.contains(&key) { + let status = dep_status + .get(relative_path) + .cloned() + .unwrap_or(MavenStatus::Idle); + let pom = pom_for_relative(root, relative_path); + let rel = relative_path.to_string(); + let content = match &status { + MavenStatus::Loading => div() + .w_full() + .h(px(32.0)) + .flex() + .items_center() + .gap_2() + .px_2() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child( + div() + .flex_1() + .truncate() + .child(crate::i18n::menu_text(cx, "maven.dependencyLoading")), + ) + .child( + Button::new(format!("maven-deps-cancel-{owner}")) + .small() + .ghost() + .label(crate::i18n::menu_text(cx, "ui.cancel")) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.cancel_deps(&rel, cx); + })), + ) + .into_any_element(), + MavenStatus::Failed(err) => v_flex() + .w_full() + .gap_1() + .px_2() + .py_1p5() + .child( + div() + .w_full() + .text_xs() + .text_color(ThemeColors::accent_red()) + .child(err.clone()), + ) + .child( + Button::new(format!("maven-deps-retry-{owner}")) + .small() + .ghost() + .label(crate::i18n::menu_text(cx, "ui.retry")) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.load_deps(&rel, cx); + })), + ) + .into_any_element(), + MavenStatus::Cancelled => div() + .w_full() + .flex() + .items_center() + .gap_2() + .px_2() + .py_1() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child( + div() + .flex_1() + .truncate() + .child(crate::i18n::menu_text(cx, "maven.dependencyCancelled")), + ) + .child( + Button::new(format!("maven-deps-retry-{owner}")) + .small() + .ghost() + .label(crate::i18n::menu_text(cx, "ui.retry")) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.load_deps(&rel, cx); + })), + ) + .into_any_element(), + MavenStatus::Idle | MavenStatus::Ready => match dependencies.get(relative_path) { + Some(deps) if !deps.is_empty() => div() + .w_full() + .children(Self::render_dep_rows(deps, &pom, owner, cx)) + .into_any_element(), + _ => div() + .w_full() + .px_2() + .py_1() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(crate::i18n::menu_text(cx, "maven.noDependencies")) + .into_any_element(), + }, + }; + out.push(child_container(vec![content])); + } + div().w_full().children(out).into_any_element() + } + + /// 依赖行递归渲染:标题 artifactId + Tauri 同式副标题;omitted 警告色; + /// 点击打开所属模块 pom(对齐 Tauri 点击开 pom)。 + fn render_dep_rows( + deps: &[MavenDep], + pom: &str, + owner: &str, + cx: &mut Context, + ) -> Vec { + deps.iter() + .enumerate() + .map(|(index, dep)| { + let omitted = dep.resolution != "resolved"; + let pom_owned = pom.to_string(); + let mut out = vec![h_flex() + .id(format!("maven-{owner}-dep-{index}-{}", dep.artifact)) + .min_h(px(28.0)) + .w_full() + .items_center() + .gap_1p5() + .px_2() + .rounded_sm() + .cursor_pointer() + .text_xs() + .text_color(ThemeColors::text_primary()) + .hover(|h| h.bg(ThemeColors::bg_tab_hover())) + .child( + Icon::new(IconName::Package) + .size(px(12.0)) + .text_color(if omitted { + ThemeColors::warning() + } else { + ThemeColors::text_primary() + }), + ) + .child(div().truncate().child(dep.artifact.clone())) + .child( + div() + .flex_1() + .truncate() + .font_family("monospace") + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(dep_subtitle(dep, cx)), + ) + .on_click(cx.listener(move |_this, _event, _window, cx| { + cx.emit(MavenEvent::OpenFile(pom_owned.clone())); + })) + .into_any_element()]; + if !dep.children.is_empty() { + out.push(child_container(Self::render_dep_rows( + &dep.children, + pom, + owner, + cx, + ))); + } + div().w_full().children(out).into_any_element() + }) + .collect() + } + + /// 自定义 goal 文本执行:标题 `{goal} · {target}`(对齐 Tauri 任务标题)。 + fn emit_text_goal( + &mut self, + pom_path: &str, + goal: &str, + target: &str, + profiles: &[String], + cx: &mut Context, + ) { + cx.emit(MavenEvent::RunGoal { + pom_path: pom_path.to_string(), + phase: goal.to_string(), + target: target.to_string(), + profiles: profiles.to_vec(), + skip_tests: self.skip_tests, + }); + } +} diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index f269da00d..b53505ebf 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -362,12 +362,17 @@ impl WorkbenchView { }, ); - // 4b. 订阅 Maven 视图事件(执行目标、关闭工具窗口) - // 对齐 Tauri:目标走底部 Maven 页受管进程,不进交互终端。 + // 4b. 订阅 Maven 视图事件(执行目标走底部 Maven 页、依赖开 pom、设置对话框、关闭工具窗口) let sub_maven = cx.subscribe(&maven, |this, _maven, event: &MavenEvent, cx| match event { - MavenEvent::RunGoal { pom_path, phase } => { + MavenEvent::RunGoal { + pom_path, + phase, + target, + profiles, + skip_tests, + } => { let _ = this.bottom_panel.update(cx, |bp, cx| { - bp.run_maven_goal(pom_path, phase, cx); + bp.run_maven_goal(pom_path, phase, target, profiles, *skip_tests, cx); }); let has_run = this.bottom_panel.read(cx).has_maven_run(); let _ = this.activity_rail.update(cx, |r, cx| { @@ -375,6 +380,12 @@ impl WorkbenchView { }); cx.notify(); } + MavenEvent::OpenFile(path) => { + this.open_file(path, cx); + } + MavenEvent::OpenSettings => { + this.open_settings(cx); + } MavenEvent::Close => { this.right_tool = None; cx.notify(); From f8011570a904d712271be0e1bb721759adb85f3c Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 01:11:35 +0800 Subject: [PATCH 042/109] =?UTF-8?q?fix(linux):=20Maven=20=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E6=8C=89=E9=92=AE=E6=89=93=E5=BC=80=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=20JDK=20=E4=B8=8E=20Maven=20=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- linux/src/workbench/view.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index b53505ebf..c1bd5cc96 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -384,7 +384,12 @@ impl WorkbenchView { this.open_file(path, cx); } MavenEvent::OpenSettings => { - this.open_settings(cx); + // 对齐 Tauri `openSettingsDialog("project")`:定位到项目 JDK 与 Maven 页。 + this.show_settings_dialog = true; + let _ = this.settings_dialog.update(cx, |d, cx| { + d.set_category(SettingsCategory::Project, cx); + }); + cx.notify(); } MavenEvent::Close => { this.right_tool = None; From 1f5d3682dcbec16334b0e3bc487f81bd52814815 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 01:13:34 +0800 Subject: [PATCH 043/109] =?UTF-8?q?fix(linux):=20=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E6=8C=89=E9=92=AE=E5=AF=B9=E9=BD=90=20Tauri?= =?UTF-8?q?=20=E6=98=BE=E7=A4=BA=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- linux/src/workbench/editor.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/linux/src/workbench/editor.rs b/linux/src/workbench/editor.rs index 32b542776..282778855 100644 --- a/linux/src/workbench/editor.rs +++ b/linux/src/workbench/editor.rs @@ -1088,9 +1088,18 @@ impl Render for EditorView { let is_active = self.active_tab_index == Some(idx); let title = tab.title.clone(); let is_dirty = tab.is_dirty; + // 对齐 Tauri `shouldShowTabCloseButton`:固定常显, + // `always` 常显,`active` 仅当前显,其余悬停显。 + let visibility = + crate::settings::get(cx).tab_close_button_visibility.clone(); + let show_close = tab.is_pinned + || visibility == "always" + || (visibility == "active" && is_active); + let group_name = format!("tab-close-group-{idx}"); h_flex() .id(idx) + .group(group_name.clone()) .h(px(34.0)) .items_center() .gap_2() @@ -1158,6 +1167,10 @@ impl Render for EditorView { .p(px(2.0)) .rounded_sm() .cursor_pointer() + .when(!show_close, |d| { + d.opacity(0.0) + .group_hover(group_name, |style| style.opacity(1.0)) + }) .hover(|h| { h.bg(ThemeColors::bg_tab_hover()) .text_color(ThemeColors::accent_red()) From eff87ee97badfbc6a0d46f996088d9af9df4910a Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 01:21:37 +0800 Subject: [PATCH 044/109] =?UTF-8?q?fix(linux):=20=E5=BA=95=E9=83=A8?= =?UTF-8?q?=E7=AA=97=E6=A0=BC=E6=94=B9=E5=90=84=E7=AA=97=E6=A0=BC=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E5=A4=B4=EF=BC=8C=E5=AF=B9=E9=BD=90=20Tauri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删共享标签条,切换只走左侧活动栏 - Run 头:运行/停止+重扫+清空+最小化;Maven 头:停止+重跑+清空+最小化 - 终端/诊断/GitLog 头:标题+最小化,保留清空/刷新 - 进程 framing 行与清空语义全部走 i18n 键 --- linux/src/i18n.rs | 20 + linux/src/workbench/bottom_panel.rs | 714 ++++++++++++++-------------- 2 files changed, 387 insertions(+), 347 deletions(-) diff --git a/linux/src/i18n.rs b/linux/src/i18n.rs index 3dcd560a8..91804dc11 100644 --- a/linux/src/i18n.rs +++ b/linux/src/i18n.rs @@ -137,6 +137,26 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { "workbench.changes" => ("更改", "Changes"), "workbench.search" => ("搜索", "Search"), "workbench.run" => ("运行", "Run"), + "run.title" => ("运行", "Run"), + "run.running" => ("运行中", "Running"), + "run.run" => ("运行配置", "Run configuration"), + "run.stop" => ("停止运行", "Stop run"), + "run.rescan" => ("重新扫描服务", "Rescan services"), + "run.clearOutput" => ("清除运行输出", "Clear run output"), + "run.minimize" => ("最小化", "Minimize"), + "run.emptyOutput" => ( + "运行配置后将在这里显示进程输出。", + "Run a configuration to see process output.", + ), + "run.starting" => ("开始运行", "Starting"), + "run.noSteps" => ("无可执行步骤", "No executable steps"), + "run.startFailed" => ("启动失败", "Failed to start"), + "run.exitCode" => ("退出码", "Exit code"), + "run.exited" => ("进程已退出", "Process exited"), + "run.finished" => ("运行已结束", "Run finished"), + "run.stopping" => ("已发送停止信号…", "Stop signal sent…"), + "maven.clearOutput" => ("清除构建输出", "Clear build output"), + "maven.rerunTest" => ("重新运行测试", "Rerun test"), "workbench.terminal" => ("终端", "Terminal"), "workbench.diagnostics" => ("诊断", "Diagnostics"), "workbench.gitLog" => ("提交记录", "Git Log"), diff --git a/linux/src/workbench/bottom_panel.rs b/linux/src/workbench/bottom_panel.rs index 1d2aec2f4..b49843ac1 100644 --- a/linux/src/workbench/bottom_panel.rs +++ b/linux/src/workbench/bottom_panel.rs @@ -6,8 +6,8 @@ use gpui_kit::component::scroll::ScrollableElement as _; use gpui_kit::component::{h_flex, v_flex, Disableable as _, Icon, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, AnyElement, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, - ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, Window, + div, px, AnyElement, AppContext as _, Context, Entity, FontWeight, InteractiveElement as _, + IntoElement, ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, Window, }; use crate::core::CoreClient; @@ -59,6 +59,36 @@ struct RunStep { cwd: String, } +/// 进程 framing 行文案(`run_steps_blocking` 无 `cx`,调用方预取传入)。 +#[derive(Debug, Clone)] +struct StepText { + start_failed: String, + exit_code: String, + exited: String, + finished: String, +} + +impl StepText { + fn load(cx: &gpui_kit::App) -> Self { + Self { + start_failed: crate::i18n::menu_text(cx, "run.startFailed").to_string(), + exit_code: crate::i18n::menu_text(cx, "run.exitCode").to_string(), + exited: crate::i18n::menu_text(cx, "run.exited").to_string(), + finished: crate::i18n::menu_text(cx, "run.finished").to_string(), + } + } +} + +/// 上次 Maven 目标参数(头部重跑按钮回放,对齐 Tauri `rerunLastTest` 语义的子集)。 +#[derive(Debug, Clone)] +struct MavenGoalParams { + pom_path: String, + goal: String, + target: String, + profiles: Vec, + skip_tests: bool, +} + /// Git 提交记录行数上限。 const MAX_GIT_LOG_ENTRIES: usize = 50; @@ -115,6 +145,8 @@ pub struct BottomPanelView { maven_child: Arc>>, /// Maven 启动序号,丢弃过期 `launchPlan` 结果。 maven_seq: u64, + /// 上次 Maven 目标参数(头部重跑按钮回放)。 + last_maven_goal: Option, /// reload 序号,丢弃过期探测结果。 run_seq: u64, /// 最近一次加载的 Git 提交记录。 @@ -146,6 +178,7 @@ impl BottomPanelView { maven_running: false, maven_child: Arc::new(Mutex::new(None)), maven_seq: 0, + last_maven_goal: None, client: CoreClient::new(), run_child: Arc::new(Mutex::new(None)), run_seq: 0, @@ -280,10 +313,18 @@ impl BottomPanelView { return; }; self.run_running = true; - push_run_line(&mut self.run_output, format!("开始运行:{}", item.name)); + push_run_line( + &mut self.run_output, + format!( + "{}:{}", + crate::i18n::menu_text(cx, "run.starting"), + item.name + ), + ); cx.notify(); let (tx, rx) = mpsc::channel::(); let child_slot = self.run_child.clone(); + let text = StepText::load(cx); let client = self.client.clone(); let root = self.working_dir.clone(); cx.spawn(async move |this, cx| { @@ -302,7 +343,10 @@ impl BottomPanelView { let started = this.update(cx, |view, cx| { if steps.is_empty() { view.run_running = false; - push_run_line(&mut view.run_output, "无可执行步骤".to_string()); + push_run_line( + &mut view.run_output, + crate::i18n::menu_text(cx, "run.noSteps").to_string(), + ); cx.notify(); return false; } @@ -315,7 +359,7 @@ impl BottomPanelView { if !matches!(started, Ok(true)) { return; } - std::thread::spawn(move || run_steps_blocking(steps, child_slot, tx)); + std::thread::spawn(move || run_steps_blocking(steps, child_slot, text, tx)); // 输出泵:每行经 background 线程阻塞收,再回到主线程落盘展示。 let rx = Arc::new(Mutex::new(rx)); loop { @@ -366,11 +410,17 @@ impl BottomPanelView { } } if self.run_running { - push_run_line(&mut self.run_output, "已发送停止信号…".to_string()); + push_run_line( + &mut self.run_output, + crate::i18n::menu_text(cx, "run.stopping").to_string(), + ); stopped = true; } if self.maven_running { - push_run_line(&mut self.maven_output, "已发送停止信号…".to_string()); + push_run_line( + &mut self.maven_output, + crate::i18n::menu_text(cx, "run.stopping").to_string(), + ); stopped = true; } if stopped { @@ -411,6 +461,13 @@ impl BottomPanelView { self.maven_seq += 1; let seq = self.maven_seq; let title = format!("{goal} · {target}"); + self.last_maven_goal = Some(MavenGoalParams { + pom_path: pom_path.to_string(), + goal: goal.clone(), + target: target.to_string(), + profiles: profiles.to_vec(), + skip_tests, + }); self.active_tab = BottomTab::Maven; self.is_collapsed = false; self.maven_title = Some(title.clone()); @@ -422,6 +479,7 @@ impl BottomPanelView { let root = self.working_dir.clone(); let module = maven_module_for_pom(&self.working_dir, pom_path); let profiles = profiles.to_vec(); + let text = StepText::load(cx); let child_slot = self.maven_child.clone(); cx.spawn(async move |this, cx| { let plan = client @@ -458,7 +516,7 @@ impl BottomPanelView { } let steps = vec![RunStep { program, args, cwd }]; let (tx, rx) = mpsc::channel::(); - std::thread::spawn(move || run_steps_blocking(steps, child_slot, tx)); + std::thread::spawn(move || run_steps_blocking(steps, child_slot, text, tx)); let rx = Arc::new(Mutex::new(rx)); loop { let slot = rx.clone(); @@ -501,6 +559,24 @@ impl BottomPanelView { .detach(); } + /// 重跑上次 Maven 目标(头部重跑按钮,对齐 Tauri `rerunLastTest` 语义的子集: + /// Linux 无测试会话跟踪,重跑即以上次参数再跑一次)。 + pub fn rerun_maven(&mut self, cx: &mut Context) { + if self.maven_running { + return; + } + if let Some(last) = self.last_maven_goal.clone() { + self.run_maven_goal( + &last.pom_path, + &last.goal, + &last.target, + &last.profiles, + last.skip_tests, + cx, + ); + } + } + /// 当前选中项(无选中或已失效时回退首项)。 fn selected_run_item(&self) -> Option { self.selected_run_config @@ -520,51 +596,6 @@ impl BottomPanelView { } } - /// Run 面板状态行文案。 - fn run_state_text(&self, cx: &gpui_kit::App) -> String { - let zh = crate::i18n::is_zh(cx); - match &self.run_state { - RunProjectState::Missing => { - if zh { - "无可运行配置".to_string() - } else { - "No runnable configurations".to_string() - } - } - RunProjectState::Loading => { - if zh { - "加载中…".to_string() - } else { - "Loading…".to_string() - } - } - RunProjectState::Ready => { - if self.run_running { - if zh { - "运行中".to_string() - } else { - "Running".to_string() - } - } else if zh { - format!("就绪 · {} 个配置", self.run_configs.len()) - } else { - format!("Ready · {} configurations", self.run_configs.len()) - } - } - RunProjectState::Failed(err) => { - if err.is_empty() { - if zh { - "加载失败".to_string() - } else { - "Failed to load".to_string() - } - } else { - err.clone() - } - } - } - } - /// 重新加载 Git 提交记录。core 的 `git.historyPage` 需经宿主异步接线, /// 这里用 `git -C log --oneline -50` 同步退化实现, /// 解析 hash + 首行 message 只读展示。 @@ -586,44 +617,192 @@ impl BottomPanelView { /// 当前仅保留调用点可编译,不存储不展示。 pub fn append_log(&mut self, _log: String, _cx: &mut Context) {} - fn render_tab_button( + /// 窗格通用头部:图标 + 标题 + 状态区 + 操作按钮 + 最小化, + /// 对齐 Tauri 各窗格自带按钮头(底部无标签切换条,切换只走左侧活动栏)。 + fn render_pane_header( &self, - id: &'static str, icon: IconName, - label: String, - is_active: bool, - tab: BottomTab, + title: String, + status: Option, + buttons: Vec, cx: &mut Context, - ) -> impl IntoElement { + ) -> AnyElement { h_flex() - .id(id) - .items_center() - .gap_1p5() - .px_3() .h(px(30.0)) - .cursor_pointer() - .text_xs() - .when(is_active, |btn| { - btn.bg(ThemeColors::bg_bottom_panel()) + .w_full() + .flex_shrink_0() + .bg(ThemeColors::bg_tab_bar()) + .border_b_1() + .border_color(ThemeColors::border()) + .items_center() + .gap_1() + .px_2() + .child( + Icon::new(icon) + .size(px(14.0)) + .text_color(ThemeColors::text_muted()), + ) + .child( + div() + .flex_1() + .truncate() + .text_xs() + .font_weight(FontWeight::BOLD) .text_color(ThemeColors::text_primary()) - .border_b_2() - .border_color(ThemeColors::accent_blue()) - }) - .when(!is_active, |btn| { - btn.text_color(ThemeColors::text_muted()).hover(|h| { - h.bg(ThemeColors::bg_tab_hover()) - .text_color(ThemeColors::text_primary()) - }) - }) - .child(Icon::new(icon).size(px(13.0)).text_color(if is_active { - ThemeColors::accent_blue() - } else { - ThemeColors::text_muted() - })) - .child(label) - .on_click(cx.listener(move |this, _event, _window, cx| { - this.set_tab(tab, cx); + .child(title), + ) + .when_some(status, |el, status| el.child(status)) + .children(buttons) + .child( + Button::new("pane-minimize") + .small() + .ghost() + .icon(IconName::Minus) + .tooltip(crate::i18n::menu_text(cx, "run.minimize")) + .on_click(cx.listener(|this, _event, _window, cx| { + this.toggle_collapsed(cx); + })), + ) + .into_any_element() + } + + /// 最小化之外的头部操作按钮(小幽灵图标按钮 + tooltip)。 + fn header_button( + id: String, + icon: IconName, + tooltip: String, + disabled: bool, + cx: &mut Context, + on_click: impl Fn(&mut Self, &mut Window, &mut Context) + 'static, + ) -> AnyElement { + Button::new(id) + .small() + .ghost() + .icon(icon) + .tooltip(tooltip) + .disabled(disabled) + .on_click(cx.listener(move |this, _event, window, cx| { + on_click(this, window, cx); })) + .into_any_element() + } + + /// Run 窗格头部:运行/停止 + 重扫 + 清空 + 最小化(对齐 Tauri RunPane 头)。 + fn render_run_header(&self, cx: &mut Context) -> AnyElement { + let running = self.run_running; + let status = running.then(|| { + div() + .text_xs() + .text_color(ThemeColors::accent_green()) + .child(crate::i18n::menu_text(cx, "run.running")) + .into_any_element() + }); + let title = format!( + "{} {}", + crate::i18n::menu_text(cx, "run.title"), + self.run_project_name + ); + let can_run = !matches!(self.run_state, RunProjectState::Loading); + self.render_pane_header( + IconName::Play, + title, + status, + vec![ + Self::header_button( + "run-toggle".to_string(), + if running { + IconName::Square + } else { + IconName::Play + }, + crate::i18n::menu_text(cx, if running { "run.stop" } else { "run.run" }) + .to_string(), + !can_run, + cx, + |this, _window, cx| this.run_selected_config(cx), + ), + Self::header_button( + "run-rescan".to_string(), + IconName::RotateCw, + crate::i18n::menu_text(cx, "run.rescan").to_string(), + running, + cx, + |this, _window, cx| this.reload_run_project(cx), + ), + Self::header_button( + "run-clear-output".to_string(), + IconName::Trash, + crate::i18n::menu_text(cx, "run.clearOutput").to_string(), + self.run_output.is_empty(), + cx, + |this, _window, cx| { + this.run_output.clear(); + cx.notify(); + }, + ), + ], + cx, + ) + } + + /// Maven 窗格头部:停止 + 重跑 + 清空 + 最小化(对齐 Tauri MavenRunPane 头)。 + fn render_maven_header(&self, cx: &mut Context) -> AnyElement { + let title = match self.maven_title.clone() { + Some(task) => format!( + "{} - {} - {task}", + crate::i18n::menu_text(cx, "run.title"), + crate::i18n::menu_text(cx, "maven.title") + ), + None => format!( + "{} - {}", + crate::i18n::menu_text(cx, "run.title"), + crate::i18n::menu_text(cx, "maven.title") + ), + }; + let status = self.maven_running.then(|| { + div() + .text_xs() + .text_color(ThemeColors::accent_green()) + .child(crate::i18n::menu_text(cx, "run.running")) + .into_any_element() + }); + let can_rerun = self.last_maven_goal.is_some() && !self.maven_running; + let can_clear = !self.maven_output.is_empty() || self.maven_title.is_some(); + self.render_pane_header( + IconName::Box, + title, + status, + vec![ + Self::header_button( + "maven-stop".to_string(), + IconName::Square, + crate::i18n::menu_text(cx, "maven.stop").to_string(), + !self.maven_running, + cx, + |this, _window, cx| this.stop_running(cx), + ), + Self::header_button( + "maven-rerun".to_string(), + IconName::RefreshCw, + crate::i18n::menu_text(cx, "maven.rerunTest").to_string(), + !can_rerun, + cx, + |this, _window, cx| this.rerun_maven(cx), + ), + Self::header_button( + "maven-clear-output".to_string(), + IconName::Trash, + crate::i18n::menu_text(cx, "maven.clearOutput").to_string(), + !can_clear, + cx, + |this, _window, cx| { + this.maven_output.clear(); + cx.notify(); + }, + ), + ], + cx, + ) } /// Run 面板:Tauri RunPane 子集——顶部状态行(工程名+状态+运行/停止+ @@ -689,15 +868,6 @@ impl BottomPanelView { } } - let running = self.run_running; - let run_label = if running { - run_ui_text(cx, "停止", "Stop") - } else { - run_ui_text(cx, "运行", "Run") - }; - let can_run = !matches!(self.run_state, RunProjectState::Loading); - let status = format!("{} · {}", self.run_project_name, self.run_state_text(cx)); - let rows: Vec = self .run_configs .iter() @@ -761,7 +931,7 @@ impl BottomPanelView { let output: Vec = if self.run_output.is_empty() { vec![div() .text_color(ThemeColors::text_muted()) - .child(run_ui_text(cx, "暂无输出", "No output")) + .child(crate::i18n::menu_text(cx, "run.emptyOutput")) .into_any_element()] } else { self.run_output[start..] @@ -772,55 +942,6 @@ impl BottomPanelView { v_flex() .size_full() - .child( - h_flex() - .flex_shrink_0() - .h(px(30.0)) - .w_full() - .items_center() - .gap_2() - .px_3() - .border_b_1() - .border_color(ThemeColors::border()) - .child( - div() - .flex_1() - .truncate() - .text_xs() - .text_color(ThemeColors::text_primary()) - .child(status), - ) - .child( - Button::new("run-toggle") - .small() - .primary() - .disabled(!can_run) - .label(run_label) - .on_click(cx.listener(|this, _event, _window, cx| { - this.run_selected_config(cx); - })), - ) - .child( - Button::new("run-rescan") - .small() - .ghost() - .disabled(running) - .label(run_ui_text(cx, "重扫", "Rescan")) - .on_click(cx.listener(|this, _event, _window, cx| { - this.reload_run_project(cx); - })), - ) - .child( - Button::new("run-clear-output") - .small() - .ghost() - .label(run_ui_text(cx, "清空输出", "Clear")) - .on_click(cx.listener(|this, _event, _window, cx| { - this.run_output.clear(); - cx.notify(); - })), - ), - ) .child( h_flex() .flex_1() @@ -852,22 +973,14 @@ impl BottomPanelView { .into_any_element() } - /// Maven 面板:对齐 Tauri Maven 页——顶部任务标题+运行状态+停止/清空, - /// 输出区首行 `$ mvn …`,流式追加进程输出。 + /// Maven 面板体:输出区(首行 `$ mvn …`,流式追加);头部由 `render_maven_header` 负责。 fn render_maven_panel(&self, cx: &mut Context) -> AnyElement { - let status = if self.maven_running { - run_ui_text(cx, "运行中…", "Running…") - } else if let Some(title) = self.maven_title.clone() { - title - } else { - run_ui_text(cx, "尚未运行 Maven 目标", "No Maven goal has run yet") - }; let total = self.maven_output.len(); let start = total.saturating_sub(800); let output: Vec = if self.maven_output.is_empty() { vec![div() .text_color(ThemeColors::text_muted()) - .child(run_ui_text(cx, "暂无输出", "No output")) + .child(crate::i18n::menu_text(cx, "run.emptyOutput")) .into_any_element()] } else { self.maven_output[start..] @@ -875,59 +988,16 @@ impl BottomPanelView { .map(|line| div().child(line.clone()).into_any_element()) .collect() }; - v_flex() - .size_full() - .child( - h_flex() - .flex_shrink_0() - .h(px(30.0)) - .w_full() - .items_center() - .gap_2() - .px_3() - .border_b_1() - .border_color(ThemeColors::border()) - .child( - div() - .flex_1() - .truncate() - .text_xs() - .text_color(ThemeColors::text_primary()) - .child(status), - ) - .child( - Button::new("maven-stop") - .small() - .primary() - .disabled(!self.maven_running) - .label(run_ui_text(cx, "停止", "Stop")) - .on_click(cx.listener(|this, _event, _window, cx| { - this.stop_running(cx); - })), - ) - .child( - Button::new("maven-clear-output") - .small() - .ghost() - .label(run_ui_text(cx, "清空输出", "Clear")) - .on_click(cx.listener(|this, _event, _window, cx| { - this.maven_output.clear(); - cx.notify(); - })), - ), - ) - .child( - div() - .flex_1() - .w_full() - .min_h_0() - .overflow_y_scrollbar() - .p_2() - .font_family("monospace") - .text_xs() - .text_color(ThemeColors::text_primary()) - .children(output), - ) + div() + .flex_1() + .w_full() + .min_h_0() + .overflow_y_scrollbar() + .p_2() + .font_family("monospace") + .text_xs() + .text_color(ThemeColors::text_primary()) + .children(output) .into_any_element() } @@ -1007,149 +1077,98 @@ impl Render for BottomPanelView { return div().h(px(0.0)); } + // 各窗格自带按钮头(对齐 Tauri:底部无标签切换条,切换只走左侧活动栏)。 + let (header, body) = match self.active_tab { + BottomTab::Terminal => ( + self.render_pane_header( + IconName::Terminal, + crate::i18n::menu_text(cx, "workbench.terminal").to_string(), + None, + vec![Self::header_button( + "terminal-clear".to_string(), + IconName::Trash, + crate::i18n::menu_text(cx, "ui.clear").to_string(), + false, + cx, + |this, _window, cx| { + let _ = this.terminal.update(cx, |t, cx| t.clear(cx)); + }, + )], + cx, + ), + div() + .size_full() + .child(self.terminal.clone()) + .into_any_element(), + ), + BottomTab::Run => (self.render_run_header(cx), self.render_run_panel(cx)), + BottomTab::Maven => (self.render_maven_header(cx), self.render_maven_panel(cx)), + BottomTab::Diagnostics => ( + self.render_pane_header( + IconName::TriangleAlert, + format!( + "{} ({})", + crate::i18n::menu_text(cx, "workbench.diagnostics"), + self.diagnostics.len() + ), + None, + vec![Self::header_button( + "diagnostics-clear".to_string(), + IconName::Trash, + crate::i18n::menu_text(cx, "ui.clear").to_string(), + self.diagnostics.is_empty(), + cx, + |this, _window, cx| { + this.diagnostics.clear(); + cx.notify(); + }, + )], + cx, + ), + div() + .size_full() + .p_3() + .overflow_y_scrollbar() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(if self.diagnostics.is_empty() { + crate::i18n::menu_text(cx, "diagnostics.empty").to_string() + } else { + format!( + "{} ({})", + crate::i18n::menu_text(cx, "workbench.diagnostics"), + self.diagnostics.len() + ) + }) + .into_any_element(), + ), + BottomTab::GitLog => ( + self.render_pane_header( + IconName::GitGraph, + crate::i18n::menu_text(cx, "workbench.gitLog").to_string(), + None, + vec![Self::header_button( + "gitlog-refresh".to_string(), + IconName::RotateCw, + crate::i18n::menu_text(cx, "ui.refresh").to_string(), + false, + cx, + |this, _window, cx| this.refresh_git_log(cx), + )], + cx, + ), + self.render_git_log_panel(cx), + ), + }; + v_flex() .h(px(self.height)) .w_full() .bg(ThemeColors::bg_bottom_panel()) .border_t_1() .border_color(ThemeColors::border()) - .child( - // 顶部 Tab 切换栏(高 30px):仅保留 Tauri 存在的 Terminal / Diagnostics, - // 自创的 Output 页签已去掉。 - h_flex() - .h(px(30.0)) - .w_full() - .bg(ThemeColors::bg_tab_bar()) - .border_b_1() - .border_color(ThemeColors::border()) - .items_center() - .justify_between() - .px_2() - .child( - h_flex() - .items_center() - .gap_1() - .child(self.render_tab_button( - "tab-terminal", - IconName::Terminal, - crate::i18n::menu_text(cx, "workbench.terminal").to_string(), - self.active_tab == BottomTab::Terminal, - BottomTab::Terminal, - cx, - )) - .child(self.render_tab_button( - "tab-run", - IconName::Play, - crate::i18n::menu_text(cx, "workbench.run").to_string(), - self.active_tab == BottomTab::Run, - BottomTab::Run, - cx, - )) - .child(self.render_tab_button( - "tab-maven", - IconName::Box, - crate::i18n::menu_text(cx, "maven.title").to_string(), - self.active_tab == BottomTab::Maven, - BottomTab::Maven, - cx, - )) - .child(self.render_tab_button( - "tab-diagnostics", - IconName::TriangleAlert, - format!( - "{} ({})", - crate::i18n::menu_text(cx, "workbench.diagnostics"), - self.diagnostics.len() - ), - self.active_tab == BottomTab::Diagnostics, - BottomTab::Diagnostics, - cx, - )) - .child(self.render_tab_button( - "tab-gitlog", - IconName::GitGraph, - crate::i18n::menu_text(cx, "workbench.gitLog").to_string(), - self.active_tab == BottomTab::GitLog, - BottomTab::GitLog, - cx, - )), - ) - .child( - h_flex() - .items_center() - .gap_1() - .child( - Button::new("clear-panel") - .small() - .ghost() - .icon(IconName::Trash) - .tooltip(crate::i18n::menu_text(cx, "ui.clear")) - .on_click(cx.listener(|this, _event, _window, cx| { - match this.active_tab { - BottomTab::Terminal => { - let _ = - this.terminal.update(cx, |t, cx| t.clear(cx)); - } - BottomTab::Run => { - this.run_history.clear(); - this.run_output.clear(); - cx.notify(); - } - BottomTab::Maven => { - this.maven_output.clear(); - cx.notify(); - } - BottomTab::Diagnostics => { - this.diagnostics.clear(); - cx.notify(); - } - // GitLog 无可清空的输出,该按钮退化为手动刷新。 - BottomTab::GitLog => { - this.refresh_git_log(cx); - } - } - })), - ) - .child( - Button::new("collapse-panel") - .small() - .ghost() - .icon(IconName::ChevronDown) - .tooltip(crate::i18n::menu_text(cx, "ui.close")) - .on_click(cx.listener(|this, _event, _window, cx| { - this.toggle_collapsed(cx); - })), - ), - ), - ) - .child( - // 内容区域根据 Tab 切换 - div().flex_1().w_full().child(match self.active_tab { - BottomTab::Terminal => div() - .size_full() - .child(self.terminal.clone()) - .into_any_element(), - BottomTab::Run => self.render_run_panel(cx), - BottomTab::Maven => self.render_maven_panel(cx), - BottomTab::Diagnostics => div() - .size_full() - .p_3() - .overflow_y_scrollbar() - .text_xs() - .text_color(ThemeColors::text_muted()) - .child(if self.diagnostics.is_empty() { - crate::i18n::menu_text(cx, "diagnostics.empty").to_string() - } else { - format!( - "{} ({})", - crate::i18n::menu_text(cx, "workbench.diagnostics"), - self.diagnostics.len() - ) - }) - .into_any_element(), - BottomTab::GitLog => self.render_git_log_panel(cx), - }), - ) + .child(header) + .child(div().flex_1().w_full().min_h_0().child(body)) } } @@ -1634,6 +1653,7 @@ fn pick_npm_script(root: &str) -> String { fn run_steps_blocking( steps: Vec, child_slot: Arc>>, + text: StepText, tx: mpsc::Sender, ) { use std::io::BufRead as _; @@ -1648,7 +1668,7 @@ fn run_steps_blocking( { Ok(child) => child, Err(err) => { - let _ = tx.send(format!("启动失败:{err}")); + let _ = tx.send(format!("{}:{err}", text.start_failed)); return; } }; @@ -1701,14 +1721,14 @@ fn run_steps_blocking( } match status { Some(status) if status.success() => { - let _ = tx.send("退出码:0".to_string()); + let _ = tx.send(format!("{}:0", text.exit_code)); } Some(status) => { - let _ = tx.send(format!("进程已退出:{status}")); + let _ = tx.send(format!("{}:{status}", text.exited)); return; } None => { - let _ = tx.send("运行已结束".to_string()); + let _ = tx.send(text.finished.clone()); return; } } From 2f8e47ae8166e17e126c235b95d1de480d43fbe9 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 01:30:10 +0800 Subject: [PATCH 045/109] =?UTF-8?q?fix(linux):=20=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E4=B8=8E=E8=BE=93=E5=85=A5=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E5=8F=AF=E7=94=A8=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删自带控制栏(与窗格头重复),输出区占满 - 输出改 channel 主线程泵,实时出现;ANSI 剥离 + 回车覆盖 - 输入条换真实单行输入,回车/按钮发送整行 - 文案全部走 terminal.* 键 --- linux/src/i18n.rs | 7 + linux/src/workbench/terminal.rs | 361 +++++++++++++++++++++++--------- 2 files changed, 270 insertions(+), 98 deletions(-) diff --git a/linux/src/i18n.rs b/linux/src/i18n.rs index 91804dc11..086a2f227 100644 --- a/linux/src/i18n.rs +++ b/linux/src/i18n.rs @@ -160,6 +160,13 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { "workbench.terminal" => ("终端", "Terminal"), "workbench.diagnostics" => ("诊断", "Diagnostics"), "workbench.gitLog" => ("提交记录", "Git Log"), + "terminal.session" => ( + "Lithe 终端(PTY 会话:{shell})", + "Lithe Terminal (PTY Session: {shell})", + ), + "terminal.cleared" => ("终端已清空。", "Terminal cleared."), + "terminal.unavailable" => ("PTY 不可用", "PTY unavailable"), + "terminal.execute" => ("执行", "Execute"), "workbench.settings" => ("设置", "Settings"), "workbench.sourceControl" => ("源代码管理", "Source Control"), "fileExplorer.searchFiles" => ("搜索文件", "Search files"), diff --git a/linux/src/workbench/terminal.rs b/linux/src/workbench/terminal.rs index f5ae3271e..3f13c63e6 100644 --- a/linux/src/workbench/terminal.rs +++ b/linux/src/workbench/terminal.rs @@ -1,18 +1,78 @@ use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::input::{Input, InputEvent, InputState}; use gpui_kit::component::scroll::ScrollableElement as _; use gpui_kit::component::{h_flex, v_flex, Sizable as _}; use gpui_kit::{ - div, px, Context, InteractiveElement as _, IntoElement, ParentElement as _, Render, - Styled as _, Window, + div, px, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, + ParentElement as _, Render, Styled as _, Subscription, Window, }; use portable_pty::{native_pty_system, CommandBuilder, PtySize}; use std::io::{Read, Write}; -use std::sync::{Arc, Mutex}; +use std::sync::{mpsc, Arc, Mutex}; use std::thread; use crate::settings; use crate::theme::ThemeColors; +/// 输出行数上限。 +const MAX_TERMINAL_LINES: usize = 1000; + +/// 未换行缓冲上限(字符),超出保留末尾,避免无换物流撑爆内存。 +const MAX_PENDING_CHARS: usize = 8192; +const PENDING_KEEP_CHARS: usize = 4096; + +/// 剥离 ANSI 转义序列与无用控制字符(保留 `\n` `\r` `\t` 交由换行逻辑处理)。 +/// 无 xterm 网格,按纯文本展示是既定适配(右键菜单/复制等同理缺失)。 +fn strip_ansi(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\x1b' { + match chars.peek() { + // CSI:`ESC [` … 最终字节 `@`..=`~` + Some('[') => { + chars.next(); + loop { + match chars.next() { + None => break, + Some(c) if ('@'..='~').contains(&c) => break, + Some(_) => {} + } + } + } + // OSC:`ESC ]` … 以 BEL 或 `ESC \` 结束 + Some(']') => { + chars.next(); + let mut prev_esc = false; + loop { + match chars.next() { + None => break, + Some('\x07') => break, + Some('\x1b') => prev_esc = true, + Some('\\') if prev_esc => break, + Some(_) => prev_esc = false, + } + } + } + // 字符集选择等两字符序列 + Some('(') | Some(')') | Some('#') => { + chars.next(); + chars.next(); + } + Some(_) => { + chars.next(); + } + None => {} + } + } else if ch.is_control() && ch != '\n' && ch != '\r' && ch != '\t' { + // 丢弃 BEL 等无用控制字符 + } else { + out.push(ch); + } + } + out +} + /// Linux 原生 PTY 会话 pub struct TerminalSession { writer: Arc>>, @@ -22,7 +82,16 @@ pub struct TerminalSession { } impl TerminalSession { - pub fn new(cols: u16, rows: u16, working_dir: &str, shell: &str) -> anyhow::Result { + /// 打开 PTY 会话并起输出线程:线程只透传原始字节块,主线程泵负责 + /// 解析换行/回车并 `notify`(输出实时出现,对齐 xterm 的流式展示)。 + /// 返回会话与输出 channel;`banner` 为首行提示(调用方按语言构造)。 + pub fn new( + cols: u16, + rows: u16, + working_dir: &str, + shell: &str, + banner: Option, + ) -> anyhow::Result<(Self, mpsc::Receiver)> { let pty_system = native_pty_system(); let pair = pty_system.openpty(PtySize { rows, @@ -43,48 +112,34 @@ impl TerminalSession { let mut reader = pair.master.try_clone_reader()?; let writer = pair.master.take_writer()?; - let output_lines = Arc::new(Mutex::new(vec![format!( - "Lithe Terminal (PTY Session: {shell})" - )])); - let output_lines_clone = Arc::clone(&output_lines); + let output_lines = Arc::new(Mutex::new( + banner.map(|line| vec![line]).unwrap_or_default(), + )); + let (tx, rx) = mpsc::channel::(); thread::spawn(move || { - let mut buf = [0u8; 1024]; - let mut line_accumulator = String::new(); - - while let Ok(n) = reader.read(&mut buf) { - if n == 0 { - break; - } - let chunk = String::from_utf8_lossy(&buf[..n]); - for ch in chunk.chars() { - if ch == '\n' { - let mut lines = output_lines_clone.lock().unwrap(); - lines.push(line_accumulator.clone()); - if lines.len() > 1000 { - lines.remove(0); + let mut buf = [0u8; 4096]; + loop { + match reader.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + let chunk = String::from_utf8_lossy(&buf[..n]).into_owned(); + if tx.send(chunk).is_err() { + break; } - line_accumulator.clear(); - } else if ch == '\r' { - // ignore carriage return - } else { - line_accumulator.push(ch); - } - } - if !line_accumulator.is_empty() { - let mut lines = output_lines_clone.lock().unwrap(); - if let Some(last) = lines.last_mut() { - *last = line_accumulator.clone(); } } } }); - Ok(Self { - writer: Arc::new(Mutex::new(writer)), - output_lines, - input_buffer: Arc::new(Mutex::new(String::new())), - }) + Ok(( + Self { + writer: Arc::new(Mutex::new(writer)), + output_lines, + input_buffer: Arc::new(Mutex::new(String::new())), + }, + rx, + )) } pub fn write_input(&self, input: &str) -> anyhow::Result<()> { @@ -99,21 +154,113 @@ impl TerminalSession { } } -/// 终端视图组件 +/// 终端视图组件:输出区 + 可输入的命令行(回车/按钮发送整行)。 pub struct TerminalView { pub session: Option, - pub current_input: String, pub working_dir: String, + /// 未换行缓冲:跨字节块累积,`\r` 覆盖当前行内容。 + pending: String, + input: Option>, + _input_subscription: Option, + /// 回车发送后待清空输入框(render 内有 `window` 才可执行)。 + clear_pending: bool, } impl TerminalView { pub fn new(working_dir: String, cx: &mut Context) -> Self { - let session = TerminalSession::new(120, 30, &working_dir, &Self::resolve_shell(cx)).ok(); - - Self { - session, - current_input: String::new(), + let mut view = Self { + session: None, working_dir, + pending: String::new(), + input: None, + _input_subscription: None, + clear_pending: false, + }; + view.attach_session(cx); + view + } + + /// 打开 PTY 会话并起主线程输出泵(channel 收字节块 → 解析落行 → notify)。 + fn attach_session(&mut self, cx: &mut Context) { + let banner = crate::i18n::menu_text(cx, "terminal.session") + .replace("{shell}", &Self::resolve_shell(cx)); + match TerminalSession::new( + 120, + 30, + &self.working_dir, + &Self::resolve_shell(cx), + Some(banner), + ) { + Ok((session, rx)) => { + self.session = Some(session); + self.pending.clear(); + Self::spawn_pump(rx, cx); + } + Err(_) => { + self.session = None; + } + } + cx.notify(); + } + + /// 主线程输出泵:后台收字节块,回主线程解析。 + fn spawn_pump(rx: mpsc::Receiver, cx: &mut Context) { + let rx = Arc::new(Mutex::new(rx)); + cx.spawn(async move |this, cx| loop { + let slot = rx.clone(); + let next = cx + .background_executor() + .spawn(async move { + let guard = slot.lock().ok()?; + guard.recv().ok() + }) + .await; + match next { + Some(chunk) => { + if this + .update(cx, |view, cx| { + view.push_chunk(&chunk); + cx.notify(); + }) + .is_err() + { + break; + } + } + None => break, + } + }) + .detach(); + } + + /// 解析输出块:ANSI 剥离后按换行落行、`\r` 覆盖当前行。 + fn push_chunk(&mut self, chunk: &str) { + let text = strip_ansi(chunk); + let Some(session) = self.session.as_ref() else { + return; + }; + let Ok(mut lines) = session.output_lines.lock() else { + return; + }; + for piece in text.split_inclusive('\n') { + let (content, newline) = match piece.strip_suffix('\n') { + Some(content) => (content, true), + None => (piece, false), + }; + // `\r` 语义为回车覆盖:只保留最后一个 `\r` 之后的内容。 + let visible = content.rsplit('\r').next().unwrap_or(""); + self.pending.push_str(visible); + let count = self.pending.chars().count(); + if count > MAX_PENDING_CHARS { + let skip = count - PENDING_KEEP_CHARS; + self.pending = self.pending.chars().skip(skip).collect(); + } + if newline { + lines.push(std::mem::take(&mut self.pending)); + if lines.len() > MAX_TERMINAL_LINES { + lines.remove(0); + } + } } } @@ -151,7 +298,6 @@ impl TerminalView { full.push('\n'); let _ = session.write_input(&full); } - self.current_input.clear(); cx.notify(); } @@ -159,68 +305,85 @@ impl TerminalView { if let Some(session) = &self.session { if let Ok(mut lines) = session.output_lines.lock() { lines.clear(); - lines.push("Terminal cleared.".to_string()); + lines.push(crate::i18n::menu_text(cx, "terminal.cleared").to_string()); } } + self.pending.clear(); cx.notify(); } /// 用现有工作目录重建 PTY 会话(失败则置空并通知)。 pub fn respawn(&mut self, cx: &mut Context) { - self.session = - TerminalSession::new(120, 30, &self.working_dir, &Self::resolve_shell(cx)).ok(); - cx.notify(); + self.session = None; + self.attach_session(cx); + } + + /// 懒创建命令行输入框。 + fn ensure_input( + slot: &mut Option>, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + if let Some(entity) = slot.clone() { + return entity; + } + let entity = cx.new(|cx| InputState::new(window, cx)); + *slot = Some(entity.clone()); + entity } } impl Render for TerminalView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let lines = self + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // 命令行输入框懒创建;回车发送整行(`PressEnter`),发送后在本次 + // render 内清空(`set_value` 需 `window`,订阅回调里拿不到)。 + let input_entity = Self::ensure_input(&mut self.input, window, cx); + if self._input_subscription.is_none() { + let entity = input_entity.clone(); + self._input_subscription = Some(cx.subscribe( + &input_entity, + move |this: &mut Self, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::PressEnter { .. }) { + let cmd = entity.read(cx).value().to_string(); + if !cmd.trim().is_empty() { + this.send_command(&cmd, cx); + this.clear_pending = true; + cx.notify(); + } + } + }, + )); + } + if self.clear_pending { + self.clear_pending = false; + let entity = input_entity.clone(); + entity.update(cx, |state, cx| { + state.set_value("", window, cx); + }); + } + + let mut lines = self .session .as_ref() .map(|s| s.get_lines()) - .unwrap_or_else(|| vec!["PTY unavailable".to_string()]); + .unwrap_or_else( + || vec![crate::i18n::menu_text(cx, "terminal.unavailable").to_string()], + ); + if self.session.is_some() && !self.pending.is_empty() { + lines.push(self.pending.clone()); + } v_flex() .size_full() // 终端区背景跟随主题(对齐 Tauri xterm 的 `--background`), // 浅色主题下不再残留深色底。 .bg(ThemeColors::background()) - .p_2() .child( - // 终端顶部控制栏 - h_flex() - .h(px(28.0)) - .w_full() - .justify_between() - .items_center() - .border_b_1() - .border_color(ThemeColors::border()) - .pb_1() - .child( - h_flex() - .items_center() - .gap_2() - .text_xs() - .text_color(ThemeColors::subtle_foreground()) - .child("Linux PTY Shell") - .child(format!("({})", self.working_dir)), - ) - .child( - Button::new("clear-term") - .small() - .ghost() - .label(crate::i18n::menu_text(cx, "ui.clear")) - .on_click(cx.listener(|this, _event, _window, cx| { - this.clear(cx); - })), - ), - ) - .child( - // 终端输出行展示区 + // 终端输出行展示区(控制栏由底部窗格头负责,不再自带)。 div() .flex_1() .w_full() + .min_h_0() .overflow_y_scrollbar() .p_2() .text_xs() @@ -236,10 +399,11 @@ impl Render for TerminalView { })), ) .child( - // 命令行输入条 + // 命令行输入条:可输入单行命令,回车或按钮发送。 h_flex() .h(px(32.0)) .w_full() + .flex_shrink_0() .bg(ThemeColors::surface()) .border_t_1() .border_color(ThemeColors::border()) @@ -256,24 +420,25 @@ impl Render for TerminalView { .child( div() .flex_1() - .text_xs() - .text_color(ThemeColors::foreground()) - .font_family("monospace") - .child(if self.current_input.is_empty() { - "Type command here...".to_string() - } else { - self.current_input.clone() - }), + .child(Input::new(&input_entity).cleanable(true)), ) .child( Button::new("run-cmd") .small() .primary() - .label("Execute") - .on_click(cx.listener(|this, _event, _window, cx| { - let cmd = this.current_input.clone(); - if !cmd.is_empty() { - this.send_command(&cmd, cx); + .label(crate::i18n::menu_text(cx, "terminal.execute")) + .on_click(cx.listener(move |_this, _event, window, cx| { + let cmd = input_entity.read(cx).value().to_string(); + if !cmd.trim().is_empty() { + let _ = _this.session.as_ref().map(|session| { + let mut full = cmd.clone(); + full.push('\n'); + let _ = session.write_input(&full); + }); + input_entity.update(cx, |state, cx| { + state.set_value("", window, cx); + }); + cx.notify(); } })), ), From 28ca208ede0ec4bed9fe266cd6c58a012975ee8a Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 01:39:54 +0800 Subject: [PATCH 046/109] =?UTF-8?q?feat(linux):=20=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E6=94=B9=20IDEA=20=E5=BC=8F=E5=AD=97=E7=AC=A6=E7=9B=B4?= =?UTF-8?q?=E8=BE=93=EF=BC=88vte=20=E7=9C=9F=E8=A7=A3=E6=9E=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 vte 依赖做转义解析:颜色/加粗/擦除/光标移动 - 点击聚焦后键盘直输 PTY:历史补全方向键中断均可用 - 聚焦显示反色光标;输出实时流式;中文直输 - 删底部输入条与自带控制栏;文案走 terminal.* 键 --- linux/Cargo.lock | 11 + linux/Cargo.toml | 1 + linux/src/i18n.rs | 1 - linux/src/workbench/terminal.rs | 869 +++++++++++++++++++++++--------- 4 files changed, 646 insertions(+), 236 deletions(-) diff --git a/linux/Cargo.lock b/linux/Cargo.lock index 600338172..fdd5114ed 100644 --- a/linux/Cargo.lock +++ b/linux/Cargo.lock @@ -3923,6 +3923,7 @@ dependencies = [ "serde_json", "tokio", "uuid", + "vte", ] [[package]] @@ -8098,6 +8099,16 @@ dependencies = [ "libc", ] +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "memchr", +] + [[package]] name = "waker-fn" version = "1.2.0" diff --git a/linux/Cargo.toml b/linux/Cargo.toml index 497950b15..2f0975aa7 100644 --- a/linux/Cargo.toml +++ b/linux/Cargo.toml @@ -20,6 +20,7 @@ gpui-kit = { version = "0.6", features = [ ] } lithe-core = { path = "../rust/lithe-core" } portable-pty = "0.9" +vte = "0.15" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["sync", "macros", "rt"] } diff --git a/linux/src/i18n.rs b/linux/src/i18n.rs index 086a2f227..1750e59b3 100644 --- a/linux/src/i18n.rs +++ b/linux/src/i18n.rs @@ -166,7 +166,6 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { ), "terminal.cleared" => ("终端已清空。", "Terminal cleared."), "terminal.unavailable" => ("PTY 不可用", "PTY unavailable"), - "terminal.execute" => ("执行", "Execute"), "workbench.settings" => ("设置", "Settings"), "workbench.sourceControl" => ("源代码管理", "Source Control"), "fileExplorer.searchFiles" => ("搜索文件", "Search files"), diff --git a/linux/src/workbench/terminal.rs b/linux/src/workbench/terminal.rs index 3f13c63e6..d431084da 100644 --- a/linux/src/workbench/terminal.rs +++ b/linux/src/workbench/terminal.rs @@ -1,97 +1,406 @@ -use gpui_kit::component::button::{Button, ButtonVariants as _}; -use gpui_kit::component::input::{Input, InputEvent, InputState}; +//! Linux 原生 PTY 终端:`portable-pty` 跑 shell,`vte`(Alacritty 解析器) +//! 做真机解析,键盘字符模式直输(对齐 IDEA 内嵌终端的交互:历史、 +//! 补全、方向键、中断均由 PTY 行规程处理)。 +//! +//! 显示模型为行式 + 样式段(非全网格):处理打印 / 换行 / 回车覆盖 / +//! 退格 / SGR 颜色 / 擦除 / 光标移动;输出按行累积(上限丢弃最旧)。 +//! 适配声明:多标签、新终端、终端内搜索、复制粘贴、全屏、滚到末尾跟随、 +//! PTY 随窗缩放暂不支持(缺 xterm 级组件与滚动控制,采固定 120x30)。 + use gpui_kit::component::scroll::ScrollableElement as _; -use gpui_kit::component::{h_flex, v_flex, Sizable as _}; +use gpui_kit::component::{h_flex, v_flex}; +use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, - ParentElement as _, Render, Styled as _, Subscription, Window, + div, px, Context, FocusHandle, FontWeight, InteractiveElement as _, IntoElement, KeyDownEvent, + ParentElement as _, Render, Rgba, StatefulInteractiveElement as _, Styled as _, Window, }; use portable_pty::{native_pty_system, CommandBuilder, PtySize}; use std::io::{Read, Write}; use std::sync::{mpsc, Arc, Mutex}; use std::thread; +use vte::{Parser, Perform}; use crate::settings; use crate::theme::ThemeColors; -/// 输出行数上限。 +/// PTY 固定尺寸(随窗缩放暂不支持)。 +const TERMINAL_COLS: usize = 120; +const TERMINAL_ROWS: u16 = 30; + +/// 输出行数上限,超出丢弃最旧。 const MAX_TERMINAL_LINES: usize = 1000; -/// 未换行缓冲上限(字符),超出保留末尾,避免无换物流撑爆内存。 -const MAX_PENDING_CHARS: usize = 8192; -const PENDING_KEEP_CHARS: usize = 4096; - -/// 剥离 ANSI 转义序列与无用控制字符(保留 `\n` `\r` `\t` 交由换行逻辑处理)。 -/// 无 xterm 网格,按纯文本展示是既定适配(右键菜单/复制等同理缺失)。 -fn strip_ansi(input: &str) -> String { - let mut out = String::with_capacity(input.len()); - let mut chars = input.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '\x1b' { - match chars.peek() { - // CSI:`ESC [` … 最终字节 `@`..=`~` - Some('[') => { - chars.next(); - loop { - match chars.next() { - None => break, - Some(c) if ('@'..='~').contains(&c) => break, - Some(_) => {} - } - } +/// 渲染行数上限(样式段已合并,超出只保留末尾)。 +const MAX_RENDER_LINES: usize = 600; + +/// xterm 基础 16 色(加粗 0-7 自动取 8-15,标准终端行为)。 +const ANSI_PALETTE: [(u8, u8, u8); 16] = [ + (0, 0, 0), + (205, 0, 0), + (0, 205, 0), + (205, 205, 0), + (0, 0, 238), + (205, 0, 205), + (0, 205, 205), + (229, 229, 229), + (127, 127, 127), + (255, 0, 0), + (0, 255, 0), + (255, 255, 0), + (92, 92, 255), + (255, 0, 255), + (0, 255, 255), + (255, 255, 255), +]; + +/// 256 色:0-15 基础色,16-231 立方体,232-255 灰阶。 +fn palette_256(index: u8) -> (u8, u8, u8) { + match index { + 0..=15 => ANSI_PALETTE[index as usize], + 16..=231 => { + let n = index - 16; + let levels = [0u8, 95, 135, 175, 215, 255]; + ( + levels[(n / 36) as usize], + levels[((n % 36) / 6) as usize], + levels[(n % 6) as usize], + ) + } + _ => { + let v = 8 + (index - 232) * 10; + (v, v, v) + } + } +} + +fn rgba_of(rgb: (u8, u8, u8)) -> Rgba { + Rgba { + r: rgb.0 as f32 / 255.0, + g: rgb.1 as f32 / 255.0, + b: rgb.2 as f32 / 255.0, + a: 1.0, + } +} + +/// 语义颜色:默认色渲染时取主题,索引/RGB 取调色板。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum TermColor { + #[default] + Default, + Indexed(u8), + Rgb(u8, u8, u8), +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct SpanStyle { + fg: TermColor, + bg: TermColor, + bold: bool, +} + +#[derive(Debug, Clone)] +struct StyledChar { + c: char, + style: SpanStyle, +} + +/// 终端屏幕:行缓冲(末尾即当前行)+ 光标 + 当前样式 + 保存位。 +struct TermScreen { + lines: Vec>, + row: usize, + col: usize, + style: SpanStyle, + saved: Option<(usize, usize)>, +} + +impl TermScreen { + fn new() -> Self { + Self { + lines: vec![Vec::new()], + row: 0, + col: 0, + style: SpanStyle::default(), + saved: None, + } + } + + fn clear_all(&mut self) { + self.lines = vec![Vec::new()]; + self.row = 0; + self.col = 0; + self.style = SpanStyle::default(); + } + + /// 保证 `row` 行存在,返回其可变引用(行号超限自动补空行)。 + fn row_mut(&mut self, row: usize) -> &mut Vec { + while self.lines.len() <= row { + self.lines.push(Vec::new()); + } + &mut self.lines[row] + } + + /// 换行:光标移到下一行行首,超限丢弃最旧行。 + fn newline(&mut self) { + self.row += 1; + self.col = 0; + self.row_mut(self.row); + if self.lines.len() > MAX_TERMINAL_LINES { + let overflow = self.lines.len() - MAX_TERMINAL_LINES; + self.lines.drain(..overflow); + self.row = self.row.saturating_sub(overflow); + } + } + + /// 在光标处打印字符(覆盖写,超宽自动换行)。 + fn put_char(&mut self, c: char) { + if self.col >= TERMINAL_COLS { + self.newline(); + } + let style = self.style; + let col = self.col; + let row = self.row; + let line = self.row_mut(row); + if col < line.len() { + line[col] = StyledChar { c, style }; + } else { + while line.len() < col { + line.push(StyledChar { + c: ' ', + style: SpanStyle::default(), + }); + } + line.push(StyledChar { c, style }); + } + self.col += 1; + } +} + +/// 取第 `index` 个参数(无参回退 `default`)。 +fn csi_param(params: &vte::Params, index: usize, default: u16) -> u16 { + params + .iter() + .flat_map(|group| group.iter().copied()) + .nth(index) + .unwrap_or(default) +} + +impl Perform for TermScreen { + fn print(&mut self, c: char) { + self.put_char(c); + } + + fn execute(&mut self, byte: u8) { + match byte { + b'\n' | b'\x0b' | b'\x0c' => self.newline(), + b'\r' => self.col = 0, + // 退格:只回退不删除(覆盖写模型)。 + 0x08 => self.col = self.col.saturating_sub(1), + // 制表:到下一个 8 列停靠位。 + b'\t' => self.col = (self.col + 8) & !7, + // BEL 等其余控制字符忽略。 + _ => {} + } + } + + fn csi_dispatch( + &mut self, + params: &vte::Params, + _intermediates: &[u8], + _ignore: bool, + action: char, + ) { + match action { + // 光标移动 + 'A' => self.row = self.row.saturating_sub(csi_param(params, 0, 1) as usize), + 'B' => { + let row = self.row + csi_param(params, 0, 1) as usize; + self.row = row; + self.row_mut(row); + } + 'C' => self.col += csi_param(params, 0, 1) as usize, + 'D' => self.col = self.col.saturating_sub(csi_param(params, 0, 1) as usize), + 'E' => { + self.row += csi_param(params, 0, 1) as usize; + self.col = 0; + let row = self.row; + self.row_mut(row); + } + 'F' => { + self.row = self.row.saturating_sub(csi_param(params, 0, 1) as usize); + self.col = 0; + } + 'G' => self.col = csi_param(params, 0, 1).saturating_sub(1) as usize, + 'H' | 'f' => { + self.row = csi_param(params, 0, 1).saturating_sub(1) as usize; + self.col = csi_param(params, 1, 1).saturating_sub(1) as usize; + let row = self.row; + self.row_mut(row); + } + // 擦除显示 + 'J' => match csi_param(params, 0, 0) { + 1 => { + self.lines.drain(..self.row.min(self.lines.len())); + self.row = 0; + let row = self.row; + self.row_mut(row).clear(); } - // OSC:`ESC ]` … 以 BEL 或 `ESC \` 结束 - Some(']') => { - chars.next(); - let mut prev_esc = false; - loop { - match chars.next() { - None => break, - Some('\x07') => break, - Some('\x1b') => prev_esc = true, - Some('\\') if prev_esc => break, - Some(_) => prev_esc = false, + 2 | 3 => self.clear_all(), + // 0:光标到末尾清掉 + _ => { + let row = self.row; + let col = self.col; + let line = self.row_mut(row); + line.truncate(col.min(line.len())); + self.lines.truncate(row + 1); + } + }, + // 擦除行 + 'K' => match csi_param(params, 0, 0) { + 1 => { + let row = self.row; + let col = self.col; + let line = self.row_mut(row); + line.drain(..col.min(line.len())); + self.col = 0; + } + 2 => { + let row = self.row; + self.row_mut(row).clear(); + self.col = 0; + } + // 0:光标到行尾清掉 + _ => { + let row = self.row; + let col = self.col; + let len = self.row_mut(row).len(); + self.row_mut(row).truncate(col.min(len)); + } + }, + // 上卷:丢弃顶部 n 行 + 'S' => { + let n = csi_param(params, 0, 1) as usize; + let drop = n.min(self.lines.len().saturating_sub(1)); + self.lines.drain(..drop); + self.row = self.row.saturating_sub(drop); + } + // 光标存取 + 's' => self.saved = Some((self.row, self.col)), + 'u' => { + if let Some((row, col)) = self.saved { + self.row = row; + self.col = col; + self.row_mut(row); + } + } + // SGR 颜色与属性 + 'm' => { + let values: Vec = params.iter().flat_map(|g| g.iter().copied()).collect(); + let values = if values.is_empty() { vec![0] } else { values }; + let mut ix = 0; + while ix < values.len() { + match values[ix] { + 0 => self.style = SpanStyle::default(), + 1 => self.style.bold = true, + 22 => self.style.bold = false, + 30..=37 => { + self.style.fg = TermColor::Indexed((values[ix] - 30) as u8); } + 39 => self.style.fg = TermColor::Default, + 40..=47 => { + self.style.bg = TermColor::Indexed((values[ix] - 40) as u8); + } + 49 => self.style.bg = TermColor::Default, + 90..=97 => { + self.style.fg = TermColor::Indexed((values[ix] - 90 + 8) as u8); + } + 100..=107 => { + self.style.bg = TermColor::Indexed((values[ix] - 100 + 8) as u8); + } + 38 | 48 => { + let is_fg = values[ix] == 38; + let color = match values.get(ix + 1).copied() { + Some(5) => values + .get(ix + 2) + .copied() + .map(|v| TermColor::Indexed(v.min(255) as u8)), + Some(2) => match ( + values.get(ix + 2).copied(), + values.get(ix + 3).copied(), + values.get(ix + 4).copied(), + ) { + (Some(r), Some(g), Some(b)) => Some(TermColor::Rgb( + r.min(255) as u8, + g.min(255) as u8, + b.min(255) as u8, + )), + _ => None, + }, + _ => None, + }; + if let Some(color) = color { + if is_fg { + self.style.fg = color; + } else { + self.style.bg = color; + } + } + ix += 4; + } + _ => {} } + ix += 1; } - // 字符集选择等两字符序列 - Some('(') | Some(')') | Some('#') => { - chars.next(); - chars.next(); + } + _ => {} + } + } + + fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, byte: u8) { + match byte { + // RI:反向换行 + b'M' => { + if self.row == 0 { + self.lines.insert(0, Vec::new()); + if self.lines.len() > MAX_TERMINAL_LINES { + self.lines.pop(); + } + } else { + self.row -= 1; } - Some(_) => { - chars.next(); + } + // DECSC / DECRC:光标存取 + b'7' => self.saved = Some((self.row, self.col)), + b'8' => { + if let Some((row, col)) = self.saved { + self.row = row; + self.col = col; + self.row_mut(row); } - None => {} } - } else if ch.is_control() && ch != '\n' && ch != '\r' && ch != '\t' { - // 丢弃 BEL 等无用控制字符 - } else { - out.push(ch); + // RIS:全复位 + b'c' => self.clear_all(), + _ => {} } } - out } /// Linux 原生 PTY 会话 pub struct TerminalSession { writer: Arc>>, - pub output_lines: Arc>>, + /// 主端句柄:必须活着,丢弃即关闭 PTY(shell 收 SIGHUP 退出)。 #[allow(dead_code)] - input_buffer: Arc>, + master: Arc>>, } impl TerminalSession { - /// 打开 PTY 会话并起输出线程:线程只透传原始字节块,主线程泵负责 - /// 解析换行/回车并 `notify`(输出实时出现,对齐 xterm 的流式展示)。 - /// 返回会话与输出 channel;`banner` 为首行提示(调用方按语言构造)。 + /// 打开 PTY 会话并起输出线程:线程只透传原始字节块,主线程泵喂给 + /// `vte` 解析器(输出实时出现,对齐 xterm 的流式展示)。 pub fn new( cols: u16, rows: u16, working_dir: &str, shell: &str, - banner: Option, - ) -> anyhow::Result<(Self, mpsc::Receiver)> { + ) -> anyhow::Result<(Self, mpsc::Receiver>, String)> { let pty_system = native_pty_system(); let pair = pty_system.openpty(PtySize { rows, @@ -111,20 +420,16 @@ impl TerminalSession { let _child = pair.slave.spawn_command(cmd)?; let mut reader = pair.master.try_clone_reader()?; let writer = pair.master.take_writer()?; + let displayed_shell = shell.clone(); - let output_lines = Arc::new(Mutex::new( - banner.map(|line| vec![line]).unwrap_or_default(), - )); - let (tx, rx) = mpsc::channel::(); - + let (tx, rx) = mpsc::channel::>(); thread::spawn(move || { let mut buf = [0u8; 4096]; loop { match reader.read(&mut buf) { Ok(0) | Err(_) => break, Ok(n) => { - let chunk = String::from_utf8_lossy(&buf[..n]).into_owned(); - if tx.send(chunk).is_err() { + if tx.send(buf[..n].to_vec()).is_err() { break; } } @@ -135,35 +440,45 @@ impl TerminalSession { Ok(( Self { writer: Arc::new(Mutex::new(writer)), - output_lines, - input_buffer: Arc::new(Mutex::new(String::new())), + master: Arc::new(Mutex::new(pair.master)), }, rx, + displayed_shell, )) } pub fn write_input(&self, input: &str) -> anyhow::Result<()> { + self.write_bytes(input.as_bytes()) + } + + pub fn write_bytes(&self, bytes: &[u8]) -> anyhow::Result<()> { let mut writer = self.writer.lock().unwrap(); - writer.write_all(input.as_bytes())?; + writer.write_all(bytes)?; writer.flush()?; Ok(()) } - pub fn get_lines(&self) -> Vec { - self.output_lines.lock().unwrap().clone() + /// PTY 缩放(随窗缩放接入后调用,当前固定尺寸保留接口)。 + #[allow(dead_code)] + pub fn resize(&self, cols: u16, rows: u16) { + if let Ok(master) = self.master.lock() { + let _ = master.resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }); + } } } -/// 终端视图组件:输出区 + 可输入的命令行(回车/按钮发送整行)。 +/// 终端视图组件:点击聚焦后键盘字符模式直输(IDEA 式交互)。 pub struct TerminalView { pub session: Option, pub working_dir: String, - /// 未换行缓冲:跨字节块累积,`\r` 覆盖当前行内容。 - pending: String, - input: Option>, - _input_subscription: Option, - /// 回车发送后待清空输入框(render 内有 `window` 才可执行)。 - clear_pending: bool, + screen: TermScreen, + parser: Parser, + focus_handle: FocusHandle, } impl TerminalView { @@ -171,29 +486,32 @@ impl TerminalView { let mut view = Self { session: None, working_dir, - pending: String::new(), - input: None, - _input_subscription: None, - clear_pending: false, + screen: TermScreen::new(), + parser: Parser::new(), + focus_handle: cx.focus_handle(), }; view.attach_session(cx); view } - /// 打开 PTY 会话并起主线程输出泵(channel 收字节块 → 解析落行 → notify)。 + /// 打开 PTY 会话并起主线程输出泵(channel 收字节块 → `vte` 解析 → notify)。 fn attach_session(&mut self, cx: &mut Context) { - let banner = crate::i18n::menu_text(cx, "terminal.session") - .replace("{shell}", &Self::resolve_shell(cx)); + let shell = Self::resolve_shell(cx); match TerminalSession::new( - 120, - 30, + TERMINAL_COLS as u16, + TERMINAL_ROWS, &self.working_dir, - &Self::resolve_shell(cx), - Some(banner), + &shell, ) { - Ok((session, rx)) => { + Ok((session, rx, displayed)) => { self.session = Some(session); - self.pending.clear(); + self.screen.clear_all(); + let banner = + crate::i18n::menu_text(cx, "terminal.session").replace("{shell}", &displayed); + for c in banner.chars() { + self.screen.put_char(c); + } + self.screen.newline(); Self::spawn_pump(rx, cx); } Err(_) => { @@ -203,8 +521,8 @@ impl TerminalView { cx.notify(); } - /// 主线程输出泵:后台收字节块,回主线程解析。 - fn spawn_pump(rx: mpsc::Receiver, cx: &mut Context) { + /// 主线程输出泵:后台收字节块,回主线程喂解析器。 + fn spawn_pump(rx: mpsc::Receiver>, cx: &mut Context) { let rx = Arc::new(Mutex::new(rx)); cx.spawn(async move |this, cx| loop { let slot = rx.clone(); @@ -216,10 +534,15 @@ impl TerminalView { }) .await; match next { - Some(chunk) => { + Some(bytes) => { if this .update(cx, |view, cx| { - view.push_chunk(&chunk); + view.parser.advance(&mut view.screen, &bytes); + if view.screen.lines.len() > MAX_TERMINAL_LINES { + let overflow = view.screen.lines.len() - MAX_TERMINAL_LINES; + view.screen.lines.drain(..overflow); + view.screen.row = view.screen.row.saturating_sub(overflow); + } cx.notify(); }) .is_err() @@ -233,37 +556,6 @@ impl TerminalView { .detach(); } - /// 解析输出块:ANSI 剥离后按换行落行、`\r` 覆盖当前行。 - fn push_chunk(&mut self, chunk: &str) { - let text = strip_ansi(chunk); - let Some(session) = self.session.as_ref() else { - return; - }; - let Ok(mut lines) = session.output_lines.lock() else { - return; - }; - for piece in text.split_inclusive('\n') { - let (content, newline) = match piece.strip_suffix('\n') { - Some(content) => (content, true), - None => (piece, false), - }; - // `\r` 语义为回车覆盖:只保留最后一个 `\r` 之后的内容。 - let visible = content.rsplit('\r').next().unwrap_or(""); - self.pending.push_str(visible); - let count = self.pending.chars().count(); - if count > MAX_PENDING_CHARS { - let skip = count - PENDING_KEEP_CHARS; - self.pending = self.pending.chars().skip(skip).collect(); - } - if newline { - lines.push(std::mem::take(&mut self.pending)); - if lines.len() > MAX_TERMINAL_LINES { - lines.remove(0); - } - } - } - } - /// 解析生效 shell:设置 `terminalDefaultShellId` 非空即用(名称经 /// `PATH` 或 `/bin` 解析为路径),为空回退 `$SHELL`。 fn resolve_shell(cx: &gpui_kit::App) -> String { @@ -292,6 +584,7 @@ impl TerminalView { id } + /// 程序化发送命令(宿主调用,如打开目录):原文 + 换行。 pub fn send_command(&mut self, cmd: &str, cx: &mut Context) { if let Some(session) = &self.session { let mut full = cmd.to_string(); @@ -302,13 +595,10 @@ impl TerminalView { } pub fn clear(&mut self, cx: &mut Context) { - if let Some(session) = &self.session { - if let Ok(mut lines) = session.output_lines.lock() { - lines.clear(); - lines.push(crate::i18n::menu_text(cx, "terminal.cleared").to_string()); - } - } - self.pending.clear(); + self.screen.clear_all(); + self.screen + .put_str(&crate::i18n::menu_text(cx, "terminal.cleared").to_string()); + self.screen.newline(); cx.notify(); } @@ -318,68 +608,116 @@ impl TerminalView { self.attach_session(cx); } - /// 懒创建命令行输入框。 - fn ensure_input( - slot: &mut Option>, - window: &mut Window, - cx: &mut Context, - ) -> Entity { - if let Some(entity) = slot.clone() { - return entity; + /// 键盘直输:可打印字符原样写 PTY,控制组合与功能键转义序列, + /// 未知键放行(IDEA 快捷键不断)。返回是否消费。 + fn handle_key(&mut self, event: &KeyDownEvent) -> bool { + let key = event.keystroke.key.as_str(); + let mods = &event.keystroke.modifiers; + let bytes: Vec = if mods.control && !mods.alt && !mods.platform { + // Ctrl+字母 → 控制字符(Ctrl+C 中断等)。 + let lower = key.to_lowercase(); + let mut chars = lower.chars(); + match (chars.next(), chars.next()) { + (Some(c @ 'a'..='z'), None) => vec![(c as u8) - b'a' + 1], + (Some(' '), None) => vec![0], + (Some('['), None) => vec![0x1b], + (Some('\\'), None) => vec![0x1c], + (Some(']'), None) => vec![0x1d], + (Some('^'), None) => vec![0x1e], + (Some('_'), None) => vec![0x1f], + _ => return false, + } + } else if mods.alt && !mods.control && !mods.platform { + // Alt+字符 → ESC 前缀(readline Meta 键)。 + match &event.keystroke.key_char { + Some(text) if text.chars().count() == 1 => { + let mut out = vec![0x1b]; + out.extend_from_slice(text.as_bytes()); + out + } + _ => return false, + } + } else if mods.control || mods.platform { + return false; + } else { + match key { + "enter" => b"\r".to_vec(), + "backspace" => vec![0x7f], + "tab" => b"\t".to_vec(), + "escape" => vec![0x1b], + "up" => b"\x1b[A".to_vec(), + "down" => b"\x1b[B".to_vec(), + "right" => b"\x1b[C".to_vec(), + "left" => b"\x1b[D".to_vec(), + "home" => b"\x1b[H".to_vec(), + "end" => b"\x1b[F".to_vec(), + "delete" => b"\x1b[3~".to_vec(), + "pageup" => b"\x1b[5~".to_vec(), + "pagedown" => b"\x1b[6~".to_vec(), + "f1" => b"\x1bOP".to_vec(), + "f2" => b"\x1bOQ".to_vec(), + "f3" => b"\x1bOR".to_vec(), + "f4" => b"\x1bOS".to_vec(), + "f5" => b"\x1b[15~".to_vec(), + "f6" => b"\x1b[17~".to_vec(), + "f7" => b"\x1b[18~".to_vec(), + "f8" => b"\x1b[19~".to_vec(), + "f9" => b"\x1b[20~".to_vec(), + "f10" => b"\x1b[21~".to_vec(), + "f11" => b"\x1b[23~".to_vec(), + "f12" => b"\x1b[24~".to_vec(), + _ => match &event.keystroke.key_char { + // 可打印字符(含中文)原样直输。 + Some(text) => text.as_bytes().to_vec(), + None => return false, + }, + } + }; + if let Some(session) = &self.session { + let _ = session.write_bytes(&bytes); } - let entity = cx.new(|cx| InputState::new(window, cx)); - *slot = Some(entity.clone()); - entity + true } } -impl Render for TerminalView { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - // 命令行输入框懒创建;回车发送整行(`PressEnter`),发送后在本次 - // render 内清空(`set_value` 需 `window`,订阅回调里拿不到)。 - let input_entity = Self::ensure_input(&mut self.input, window, cx); - if self._input_subscription.is_none() { - let entity = input_entity.clone(); - self._input_subscription = Some(cx.subscribe( - &input_entity, - move |this: &mut Self, _, event: &InputEvent, cx| { - if matches!(event, InputEvent::PressEnter { .. }) { - let cmd = entity.read(cx).value().to_string(); - if !cmd.trim().is_empty() { - this.send_command(&cmd, cx); - this.clear_pending = true; - cx.notify(); - } - } - }, - )); - } - if self.clear_pending { - self.clear_pending = false; - let entity = input_entity.clone(); - entity.update(cx, |state, cx| { - state.set_value("", window, cx); - }); +impl TermScreen { + /// 纯文本写入(banner/清空提示用当前默认样式)。 + fn put_str(&mut self, text: &str) { + for c in text.chars() { + if c == '\n' { + self.newline(); + } else { + self.put_char(c); + } } + } +} - let mut lines = self - .session - .as_ref() - .map(|s| s.get_lines()) - .unwrap_or_else( - || vec![crate::i18n::menu_text(cx, "terminal.unavailable").to_string()], - ); - if self.session.is_some() && !self.pending.is_empty() { - lines.push(self.pending.clone()); - } +impl Render for TerminalView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let focused = self.focus_handle.is_focused(window); + let total = self.screen.lines.len(); + let start = total.saturating_sub(MAX_RENDER_LINES); + let default_fg = ThemeColors::foreground(); + let default_bg = ThemeColors::background(); v_flex() .size_full() - // 终端区背景跟随主题(对齐 Tauri xterm 的 `--background`), - // 浅色主题下不再残留深色底。 .bg(ThemeColors::background()) + .id("terminal-grid") + .track_focus(&self.focus_handle) + .cursor_text() + .on_click(cx.listener(|this, _event, window, cx| { + window.focus(&this.focus_handle, cx); + })) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { + if this.handle_key(event) { + cx.stop_propagation(); + cx.notify(); + } + })) .child( - // 终端输出行展示区(控制栏由底部窗格头负责,不再自带)。 + // 输出网格(控制栏由底部窗格头负责,不再自带)。 div() .flex_1() .w_full() @@ -388,60 +726,121 @@ impl Render for TerminalView { .p_2() .text_xs() .text_size(px(crate::settings::get(cx).terminal_font_size)) - .text_color(ThemeColors::foreground()) .font_family("monospace") - .children(lines.into_iter().enumerate().map(|(idx, line)| { - div().id(idx).child(if line.is_empty() { - " ".to_string() - } else { - line - }) - })), - ) - .child( - // 命令行输入条:可输入单行命令,回车或按钮发送。 - h_flex() - .h(px(32.0)) - .w_full() - .flex_shrink_0() - .bg(ThemeColors::surface()) - .border_t_1() - .border_color(ThemeColors::border()) - .items_center() - .px_2() - .gap_2() - .child( - div() - .text_xs() - .text_color(ThemeColors::success()) - .font_family("monospace") - .child("$"), - ) - .child( - div() - .flex_1() - .child(Input::new(&input_entity).cleanable(true)), - ) - .child( - Button::new("run-cmd") - .small() - .primary() - .label(crate::i18n::menu_text(cx, "terminal.execute")) - .on_click(cx.listener(move |_this, _event, window, cx| { - let cmd = input_entity.read(cx).value().to_string(); - if !cmd.trim().is_empty() { - let _ = _this.session.as_ref().map(|session| { - let mut full = cmd.clone(); - full.push('\n'); - let _ = session.write_input(&full); - }); - input_entity.update(cx, |state, cx| { - state.set_value("", window, cx); - }); - cx.notify(); - } - })), + .children( + self.screen.lines[start..] + .iter() + .enumerate() + .map(|(ix, line)| { + render_term_line( + start + ix, + line, + focused && start + ix == self.screen.row, + self.screen.col, + default_fg, + default_bg, + ) + }) + .collect::>(), ), ) } } + +/// 语义颜色解算(加粗配 0-7 自动取高亮 variant,标准终端行为)。 +fn resolve_color(color: TermColor, bold: bool, default: Rgba) -> Rgba { + match color { + TermColor::Default => default, + TermColor::Indexed(i) => { + let i = if bold && i < 8 { i + 8 } else { i }; + rgba_of(palette_256(i)) + } + TermColor::Rgb(r, g, b) => rgba_of((r, g, b)), + } +} + +/// 渲染一行:同样式字符合并为一段;聚焦时在光标处画反色块。 +fn render_term_line( + row: usize, + line: &[StyledChar], + cursor_here: bool, + cursor_col: usize, + default_fg: Rgba, + default_bg: Rgba, +) -> gpui_kit::AnyElement { + // 合并同样式段。 + let mut spans: Vec<(SpanStyle, String)> = Vec::new(); + for ch in line { + if let Some((style, text)) = spans.last_mut() { + if *style == ch.style { + text.push(ch.c); + continue; + } + } + spans.push((ch.style, ch.c.to_string())); + } + let total: usize = spans.iter().map(|(_, text)| text.chars().count()).sum(); + // 光标超出文本末尾时在行尾补空格块。 + let trailing = cursor_here && cursor_col >= total; + let mut row_el = h_flex() + .id(row) + .w_full() + .items_center() + .text_xs() + .font_family("monospace"); + if spans.is_empty() && !trailing { + return row_el + .child(div().child(" ".to_string())) + .into_any_element(); + } + let mut col = 0; + for (style, text) in &spans { + let fg = resolve_color(style.fg, style.bold, default_fg); + let bg = resolve_color(style.bg, false, default_bg); + let chunk = |text: String| { + div() + .text_color(fg) + .when(style.bg != TermColor::Default, |el| el.bg(bg)) + .when(style.bold, |el| el.font_weight(FontWeight::BOLD)) + .child(text) + }; + if trailing { + row_el = row_el.child(chunk(text.clone())); + continue; + } + let mut before = String::new(); + let mut cursor_char: Option = None; + let mut after = String::new(); + for c in text.chars() { + if col < cursor_col { + before.push(c); + } else if cursor_char.is_none() { + cursor_char = Some(c); + } else { + after.push(c); + } + col += 1; + } + if !before.is_empty() { + row_el = row_el.child(chunk(before)); + } + row_el = row_el.child( + div() + .text_color(default_bg) + .bg(fg) + .child(cursor_char.unwrap_or(' ').to_string()), + ); + if !after.is_empty() { + row_el = row_el.child(chunk(after)); + } + } + if trailing { + row_el = row_el.child( + div() + .text_color(default_bg) + .bg(default_fg) + .child(" ".to_string()), + ); + } + row_el.into_any_element() +} From 64a90ad4465ba2fe10439b65206db76e8d9d8649 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 11:17:20 +0800 Subject: [PATCH 047/109] =?UTF-8?q?feat(linux):=20=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E6=8D=A2=20alacritty=5Fterminal=20=E7=BD=91=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 行式自研解析换 Alacritty 网格状态机(颜色/光标/擦除/滚屏全真) - 渲染可见区网格,同样式合并;聚焦反色光标块 - 应用光标键模式;清屏走 2J;删 vte 直依赖 --- linux/Cargo.lock | 79 ++++- linux/Cargo.toml | 2 +- linux/src/workbench/terminal.rs | 575 ++++++++++---------------------- 3 files changed, 249 insertions(+), 407 deletions(-) diff --git a/linux/Cargo.lock b/linux/Cargo.lock index fdd5114ed..0b98ccd21 100644 --- a/linux/Cargo.lock +++ b/linux/Cargo.lock @@ -131,6 +131,31 @@ dependencies = [ "memchr", ] +[[package]] +name = "alacritty_terminal" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59" +dependencies = [ + "base64 0.22.1", + "bitflags 2.13.2", + "home", + "libc", + "log", + "miow", + "parking_lot", + "piper", + "polling", + "regex-automata", + "rustix", + "rustix-openpty", + "serde", + "signal-hook", + "unicode-width", + "vte", + "windows-sys 0.59.0", +] + [[package]] name = "aligned" version = "0.4.3" @@ -664,6 +689,9 @@ name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] [[package]] name = "bitstream-io" @@ -1417,6 +1445,12 @@ dependencies = [ "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" @@ -3179,6 +3213,15 @@ dependencies = [ "digest 0.10.7", ] +[[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 = "html5ever" version = "0.27.0" @@ -3914,6 +3957,7 @@ dependencies = [ name = "lithe-linux" version = "0.1.0" dependencies = [ + "alacritty_terminal", "anyhow", "gpui-kit", "lithe-core", @@ -3923,7 +3967,6 @@ dependencies = [ "serde_json", "tokio", "uuid", - "vte", ] [[package]] @@ -4235,6 +4278,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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" @@ -6248,6 +6300,17 @@ dependencies = [ "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", +] + [[package]] name = "rustls" version = "0.23.45" @@ -6699,6 +6762,16 @@ 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" @@ -8106,7 +8179,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" dependencies = [ "arrayvec", + "bitflags 2.13.2", + "cursor-icon", + "log", "memchr", + "serde", ] [[package]] diff --git a/linux/Cargo.toml b/linux/Cargo.toml index 2f0975aa7..d14c4ac61 100644 --- a/linux/Cargo.toml +++ b/linux/Cargo.toml @@ -20,7 +20,7 @@ gpui-kit = { version = "0.6", features = [ ] } lithe-core = { path = "../rust/lithe-core" } portable-pty = "0.9" -vte = "0.15" +alacritty_terminal = "0.26" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["sync", "macros", "rt"] } diff --git a/linux/src/workbench/terminal.rs b/linux/src/workbench/terminal.rs index d431084da..48163bc62 100644 --- a/linux/src/workbench/terminal.rs +++ b/linux/src/workbench/terminal.rs @@ -1,12 +1,16 @@ -//! Linux 原生 PTY 终端:`portable-pty` 跑 shell,`vte`(Alacritty 解析器) -//! 做真机解析,键盘字符模式直输(对齐 IDEA 内嵌终端的交互:历史、 -//! 补全、方向键、中断均由 PTY 行规程处理)。 +//! Linux 原生 PTY 终端:`portable-pty` 跑 shell,`alacritty_terminal` +//!(Alacritty 网格)做真机解析与状态,键盘字符模式直输(对齐 IDEA +//! 内嵌终端的交互:历史、补全、方向键、中断均由 PTY 行规程处理)。 //! -//! 显示模型为行式 + 样式段(非全网格):处理打印 / 换行 / 回车覆盖 / -//! 退格 / SGR 颜色 / 擦除 / 光标移动;输出按行累积(上限丢弃最旧)。 -//! 适配声明:多标签、新终端、终端内搜索、复制粘贴、全屏、滚到末尾跟随、 -//! PTY 随窗缩放暂不支持(缺 xterm 级组件与滚动控制,采固定 120x30)。 - +//! 渲染取网格可见区,同样式字符合并展示;聚焦时在光标处画反色块。 +//! 适配声明:多标签、新终端、终端内搜索、复制粘贴、滚到末尾跟随、 +//! PTY 随窗缩放暂不支持(缺选择/滚动控制,采固定 120x30 跟随显示)。 + +use alacritty_terminal::event::VoidListener; +use alacritty_terminal::grid::Dimensions; +use alacritty_terminal::term::cell::Flags; +use alacritty_terminal::term::{Config, Term, TermMode}; +use alacritty_terminal::vte::ansi::{Color as AlacColor, NamedColor, Processor, Rgb}; use gpui_kit::component::scroll::ScrollableElement as _; use gpui_kit::component::{h_flex, v_flex}; use gpui_kit::prelude::FluentBuilder as _; @@ -18,22 +22,35 @@ use portable_pty::{native_pty_system, CommandBuilder, PtySize}; use std::io::{Read, Write}; use std::sync::{mpsc, Arc, Mutex}; use std::thread; -use vte::{Parser, Perform}; use crate::settings; use crate::theme::ThemeColors; /// PTY 固定尺寸(随窗缩放暂不支持)。 const TERMINAL_COLS: usize = 120; -const TERMINAL_ROWS: u16 = 30; +const TERMINAL_ROWS: usize = 30; -/// 输出行数上限,超出丢弃最旧。 -const MAX_TERMINAL_LINES: usize = 1000; +/// 网格尺寸(`Term::new` 与 `resize` 共用)。 +struct TermDims { + cols: usize, + rows: usize, +} -/// 渲染行数上限(样式段已合并,超出只保留末尾)。 -const MAX_RENDER_LINES: usize = 600; +impl Dimensions for TermDims { + fn total_lines(&self) -> usize { + self.rows + } -/// xterm 基础 16 色(加粗 0-7 自动取 8-15,标准终端行为)。 + fn screen_lines(&self) -> usize { + self.rows + } + + fn columns(&self) -> usize { + self.cols + } +} + +/// xterm 基础 16 色。 const ANSI_PALETTE: [(u8, u8, u8); 16] = [ (0, 0, 0), (205, 0, 0), @@ -82,305 +99,23 @@ fn rgba_of(rgb: (u8, u8, u8)) -> Rgba { } } -/// 语义颜色:默认色渲染时取主题,索引/RGB 取调色板。 -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -enum TermColor { - #[default] - Default, - Indexed(u8), - Rgb(u8, u8, u8), -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -struct SpanStyle { - fg: TermColor, - bg: TermColor, - bold: bool, -} - -#[derive(Debug, Clone)] -struct StyledChar { - c: char, - style: SpanStyle, -} - -/// 终端屏幕:行缓冲(末尾即当前行)+ 光标 + 当前样式 + 保存位。 -struct TermScreen { - lines: Vec>, - row: usize, - col: usize, - style: SpanStyle, - saved: Option<(usize, usize)>, -} - -impl TermScreen { - fn new() -> Self { - Self { - lines: vec![Vec::new()], - row: 0, - col: 0, - style: SpanStyle::default(), - saved: None, - } - } - - fn clear_all(&mut self) { - self.lines = vec![Vec::new()]; - self.row = 0; - self.col = 0; - self.style = SpanStyle::default(); - } - - /// 保证 `row` 行存在,返回其可变引用(行号超限自动补空行)。 - fn row_mut(&mut self, row: usize) -> &mut Vec { - while self.lines.len() <= row { - self.lines.push(Vec::new()); - } - &mut self.lines[row] - } - - /// 换行:光标移到下一行行首,超限丢弃最旧行。 - fn newline(&mut self) { - self.row += 1; - self.col = 0; - self.row_mut(self.row); - if self.lines.len() > MAX_TERMINAL_LINES { - let overflow = self.lines.len() - MAX_TERMINAL_LINES; - self.lines.drain(..overflow); - self.row = self.row.saturating_sub(overflow); - } - } - - /// 在光标处打印字符(覆盖写,超宽自动换行)。 - fn put_char(&mut self, c: char) { - if self.col >= TERMINAL_COLS { - self.newline(); - } - let style = self.style; - let col = self.col; - let row = self.row; - let line = self.row_mut(row); - if col < line.len() { - line[col] = StyledChar { c, style }; - } else { - while line.len() < col { - line.push(StyledChar { - c: ' ', - style: SpanStyle::default(), - }); - } - line.push(StyledChar { c, style }); - } - self.col += 1; - } -} - -/// 取第 `index` 个参数(无参回退 `default`)。 -fn csi_param(params: &vte::Params, index: usize, default: u16) -> u16 { - params - .iter() - .flat_map(|group| group.iter().copied()) - .nth(index) - .unwrap_or(default) -} - -impl Perform for TermScreen { - fn print(&mut self, c: char) { - self.put_char(c); - } - - fn execute(&mut self, byte: u8) { - match byte { - b'\n' | b'\x0b' | b'\x0c' => self.newline(), - b'\r' => self.col = 0, - // 退格:只回退不删除(覆盖写模型)。 - 0x08 => self.col = self.col.saturating_sub(1), - // 制表:到下一个 8 列停靠位。 - b'\t' => self.col = (self.col + 8) & !7, - // BEL 等其余控制字符忽略。 - _ => {} - } - } - - fn csi_dispatch( - &mut self, - params: &vte::Params, - _intermediates: &[u8], - _ignore: bool, - action: char, - ) { - match action { - // 光标移动 - 'A' => self.row = self.row.saturating_sub(csi_param(params, 0, 1) as usize), - 'B' => { - let row = self.row + csi_param(params, 0, 1) as usize; - self.row = row; - self.row_mut(row); - } - 'C' => self.col += csi_param(params, 0, 1) as usize, - 'D' => self.col = self.col.saturating_sub(csi_param(params, 0, 1) as usize), - 'E' => { - self.row += csi_param(params, 0, 1) as usize; - self.col = 0; - let row = self.row; - self.row_mut(row); - } - 'F' => { - self.row = self.row.saturating_sub(csi_param(params, 0, 1) as usize); - self.col = 0; - } - 'G' => self.col = csi_param(params, 0, 1).saturating_sub(1) as usize, - 'H' | 'f' => { - self.row = csi_param(params, 0, 1).saturating_sub(1) as usize; - self.col = csi_param(params, 1, 1).saturating_sub(1) as usize; - let row = self.row; - self.row_mut(row); - } - // 擦除显示 - 'J' => match csi_param(params, 0, 0) { - 1 => { - self.lines.drain(..self.row.min(self.lines.len())); - self.row = 0; - let row = self.row; - self.row_mut(row).clear(); - } - 2 | 3 => self.clear_all(), - // 0:光标到末尾清掉 - _ => { - let row = self.row; - let col = self.col; - let line = self.row_mut(row); - line.truncate(col.min(line.len())); - self.lines.truncate(row + 1); - } - }, - // 擦除行 - 'K' => match csi_param(params, 0, 0) { - 1 => { - let row = self.row; - let col = self.col; - let line = self.row_mut(row); - line.drain(..col.min(line.len())); - self.col = 0; - } - 2 => { - let row = self.row; - self.row_mut(row).clear(); - self.col = 0; - } - // 0:光标到行尾清掉 - _ => { - let row = self.row; - let col = self.col; - let len = self.row_mut(row).len(); - self.row_mut(row).truncate(col.min(len)); - } - }, - // 上卷:丢弃顶部 n 行 - 'S' => { - let n = csi_param(params, 0, 1) as usize; - let drop = n.min(self.lines.len().saturating_sub(1)); - self.lines.drain(..drop); - self.row = self.row.saturating_sub(drop); - } - // 光标存取 - 's' => self.saved = Some((self.row, self.col)), - 'u' => { - if let Some((row, col)) = self.saved { - self.row = row; - self.col = col; - self.row_mut(row); - } - } - // SGR 颜色与属性 - 'm' => { - let values: Vec = params.iter().flat_map(|g| g.iter().copied()).collect(); - let values = if values.is_empty() { vec![0] } else { values }; - let mut ix = 0; - while ix < values.len() { - match values[ix] { - 0 => self.style = SpanStyle::default(), - 1 => self.style.bold = true, - 22 => self.style.bold = false, - 30..=37 => { - self.style.fg = TermColor::Indexed((values[ix] - 30) as u8); - } - 39 => self.style.fg = TermColor::Default, - 40..=47 => { - self.style.bg = TermColor::Indexed((values[ix] - 40) as u8); - } - 49 => self.style.bg = TermColor::Default, - 90..=97 => { - self.style.fg = TermColor::Indexed((values[ix] - 90 + 8) as u8); - } - 100..=107 => { - self.style.bg = TermColor::Indexed((values[ix] - 100 + 8) as u8); - } - 38 | 48 => { - let is_fg = values[ix] == 38; - let color = match values.get(ix + 1).copied() { - Some(5) => values - .get(ix + 2) - .copied() - .map(|v| TermColor::Indexed(v.min(255) as u8)), - Some(2) => match ( - values.get(ix + 2).copied(), - values.get(ix + 3).copied(), - values.get(ix + 4).copied(), - ) { - (Some(r), Some(g), Some(b)) => Some(TermColor::Rgb( - r.min(255) as u8, - g.min(255) as u8, - b.min(255) as u8, - )), - _ => None, - }, - _ => None, - }; - if let Some(color) = color { - if is_fg { - self.style.fg = color; - } else { - self.style.bg = color; - } - } - ix += 4; - } - _ => {} - } - ix += 1; - } - } - _ => {} - } - } - - fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, byte: u8) { - match byte { - // RI:反向换行 - b'M' => { - if self.row == 0 { - self.lines.insert(0, Vec::new()); - if self.lines.len() > MAX_TERMINAL_LINES { - self.lines.pop(); - } - } else { - self.row -= 1; - } - } - // DECSC / DECRC:光标存取 - b'7' => self.saved = Some((self.row, self.col)), - b'8' => { - if let Some((row, col)) = self.saved { - self.row = row; - self.col = col; - self.row_mut(row); - } +/// 语义颜色解算:`Foreground`/`Background` 取主题,其余取调色板; +/// 加粗配 0-7 自动取高亮 variant(标准终端行为)。 +fn resolve_color(color: AlacColor, bold: bool, default: Rgba) -> Rgba { + match color { + AlacColor::Named(NamedColor::Foreground) => default, + AlacColor::Named(NamedColor::Background) => default, + AlacColor::Named(named) => { + let index = named as u8; + let index = if bold && index < 8 { index + 8 } else { index }; + if index < 16 { + rgba_of(ANSI_PALETTE[index as usize]) + } else { + default } - // RIS:全复位 - b'c' => self.clear_all(), - _ => {} } + AlacColor::Indexed(i) => rgba_of(palette_256(i)), + AlacColor::Spec(Rgb { r, g, b }) => rgba_of((r, g, b)), } } @@ -394,7 +129,8 @@ pub struct TerminalSession { impl TerminalSession { /// 打开 PTY 会话并起输出线程:线程只透传原始字节块,主线程泵喂给 - /// `vte` 解析器(输出实时出现,对齐 xterm 的流式展示)。 + /// 网格解析器(输出实时出现,对齐 xterm 的流式展示)。 + /// 返回会话、输出 channel 与展示用 shell 名。 pub fn new( cols: u16, rows: u16, @@ -476,8 +212,8 @@ impl TerminalSession { pub struct TerminalView { pub session: Option, pub working_dir: String, - screen: TermScreen, - parser: Parser, + term: Term, + processor: Processor, focus_handle: FocusHandle, } @@ -486,32 +222,36 @@ impl TerminalView { let mut view = Self { session: None, working_dir, - screen: TermScreen::new(), - parser: Parser::new(), + term: Term::new( + Config::default(), + &TermDims { + cols: TERMINAL_COLS, + rows: TERMINAL_ROWS, + }, + VoidListener, + ), + processor: Processor::new(), focus_handle: cx.focus_handle(), }; view.attach_session(cx); view } - /// 打开 PTY 会话并起主线程输出泵(channel 收字节块 → `vte` 解析 → notify)。 + /// 打开 PTY 会话并起主线程输出泵(channel 收字节块 → 网格解析 → notify)。 fn attach_session(&mut self, cx: &mut Context) { let shell = Self::resolve_shell(cx); match TerminalSession::new( TERMINAL_COLS as u16, - TERMINAL_ROWS, + TERMINAL_ROWS as u16, &self.working_dir, &shell, ) { Ok((session, rx, displayed)) => { self.session = Some(session); - self.screen.clear_all(); let banner = crate::i18n::menu_text(cx, "terminal.session").replace("{shell}", &displayed); - for c in banner.chars() { - self.screen.put_char(c); - } - self.screen.newline(); + // 首行提示直接画进网格(换行落行)。 + self.advance(format!("{banner}\r\n").as_bytes()); Self::spawn_pump(rx, cx); } Err(_) => { @@ -521,7 +261,14 @@ impl TerminalView { cx.notify(); } - /// 主线程输出泵:后台收字节块,回主线程喂解析器。 + /// 网格推进(含借用拆分:解析器暂存取出再放回)。 + fn advance(&mut self, bytes: &[u8]) { + let mut processor = std::mem::replace(&mut self.processor, Processor::new()); + processor.advance(&mut self.term, bytes); + self.processor = processor; + } + + /// 主线程输出泵:后台收字节块,回主线程喂网格解析器。 fn spawn_pump(rx: mpsc::Receiver>, cx: &mut Context) { let rx = Arc::new(Mutex::new(rx)); cx.spawn(async move |this, cx| loop { @@ -537,12 +284,7 @@ impl TerminalView { Some(bytes) => { if this .update(cx, |view, cx| { - view.parser.advance(&mut view.screen, &bytes); - if view.screen.lines.len() > MAX_TERMINAL_LINES { - let overflow = view.screen.lines.len() - MAX_TERMINAL_LINES; - view.screen.lines.drain(..overflow); - view.screen.row = view.screen.row.saturating_sub(overflow); - } + view.advance(&bytes); cx.notify(); }) .is_err() @@ -594,20 +336,38 @@ impl TerminalView { cx.notify(); } + /// 清屏(等价于 shell `clear`:擦除可见区,保留回滚)。 pub fn clear(&mut self, cx: &mut Context) { - self.screen.clear_all(); - self.screen - .put_str(&crate::i18n::menu_text(cx, "terminal.cleared").to_string()); - self.screen.newline(); + if self.session.is_some() { + self.advance(b"\x1b[2J\x1b[H"); + } cx.notify(); } - /// 用现有工作目录重建 PTY 会话(失败则置空并通知)。 + /// 用现有工作目录重建 PTY 会话与网格(失败则置空并通知)。 pub fn respawn(&mut self, cx: &mut Context) { self.session = None; + self.term = Term::new( + Config::default(), + &TermDims { + cols: TERMINAL_COLS, + rows: TERMINAL_ROWS, + }, + VoidListener, + ); + self.processor = Processor::new(); self.attach_session(cx); } + /// 应用光标键模式:程序用 `\x1bO` 系,否则 `\x1b[` 系。 + fn cursor_key(&self, normal: &[u8], app: &[u8]) -> Vec { + if self.term.mode().contains(TermMode::APP_CURSOR) { + app.to_vec() + } else { + normal.to_vec() + } + } + /// 键盘直输:可打印字符原样写 PTY,控制组合与功能键转义序列, /// 未知键放行(IDEA 快捷键不断)。返回是否消费。 fn handle_key(&mut self, event: &KeyDownEvent) -> bool { @@ -645,12 +405,12 @@ impl TerminalView { "backspace" => vec![0x7f], "tab" => b"\t".to_vec(), "escape" => vec![0x1b], - "up" => b"\x1b[A".to_vec(), - "down" => b"\x1b[B".to_vec(), - "right" => b"\x1b[C".to_vec(), - "left" => b"\x1b[D".to_vec(), - "home" => b"\x1b[H".to_vec(), - "end" => b"\x1b[F".to_vec(), + "up" => self.cursor_key(b"\x1b[A", b"\x1bOA"), + "down" => self.cursor_key(b"\x1b[B", b"\x1bOB"), + "right" => self.cursor_key(b"\x1b[C", b"\x1bOC"), + "left" => self.cursor_key(b"\x1b[D", b"\x1bOD"), + "home" => self.cursor_key(b"\x1b[H", b"\x1bOH"), + "end" => self.cursor_key(b"\x1b[F", b"\x1bOF"), "delete" => b"\x1b[3~".to_vec(), "pageup" => b"\x1b[5~".to_vec(), "pagedown" => b"\x1b[6~".to_vec(), @@ -680,27 +440,43 @@ impl TerminalView { } } -impl TermScreen { - /// 纯文本写入(banner/清空提示用当前默认样式)。 - fn put_str(&mut self, text: &str) { - for c in text.chars() { - if c == '\n' { - self.newline(); - } else { - self.put_char(c); - } - } - } -} - impl Render for TerminalView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let focused = self.focus_handle.is_focused(window); - let total = self.screen.lines.len(); - let start = total.saturating_sub(MAX_RENDER_LINES); let default_fg = ThemeColors::foreground(); let default_bg = ThemeColors::background(); + // 可见区按行分组(`display_iter` 即当前视口)。 + let content = self.term.renderable_content(); + let mut rows: Vec<(i32, Vec<(usize, char, AlacColor, AlacColor, bool)>)> = Vec::new(); + for indexed in content.display_iter { + let line = indexed.point.line.0; + let col = indexed.point.column.0; + if rows.last().map(|(number, _)| *number) != Some(line) { + rows.push((line, Vec::new())); + } + let cell = &indexed.cell; + let inverse = cell.flags.contains(Flags::INVERSE); + let (mut fg, mut bg) = (cell.fg, cell.bg); + if inverse { + std::mem::swap(&mut fg, &mut bg); + } + let bold = cell.flags.contains(Flags::BOLD); + rows.last_mut() + .expect("rows non-empty after push") + .1 + .push((col, cell.c, fg, bg, bold)); + } + let (cursor_line, cursor_col, cursor_visible) = ( + content.cursor.point.line.0, + content.cursor.point.column.0, + focused + && !matches!( + content.cursor.shape, + alacritty_terminal::vte::ansi::CursorShape::Hidden + ), + ); + v_flex() .size_full() .bg(ThemeColors::background()) @@ -727,63 +503,50 @@ impl Render for TerminalView { .text_xs() .text_size(px(crate::settings::get(cx).terminal_font_size)) .font_family("monospace") - .children( - self.screen.lines[start..] - .iter() - .enumerate() - .map(|(ix, line)| { - render_term_line( - start + ix, - line, - focused && start + ix == self.screen.row, - self.screen.col, - default_fg, - default_bg, - ) - }) - .collect::>(), - ), + .children(rows.into_iter().map(|(line, cells)| { + render_term_row( + line, + &cells, + cursor_visible && line == cursor_line, + cursor_col, + default_fg, + default_bg, + ) + })), ) } } -/// 语义颜色解算(加粗配 0-7 自动取高亮 variant,标准终端行为)。 -fn resolve_color(color: TermColor, bold: bool, default: Rgba) -> Rgba { - match color { - TermColor::Default => default, - TermColor::Indexed(i) => { - let i = if bold && i < 8 { i + 8 } else { i }; - rgba_of(palette_256(i)) - } - TermColor::Rgb(r, g, b) => rgba_of((r, g, b)), - } -} - -/// 渲染一行:同样式字符合并为一段;聚焦时在光标处画反色块。 -fn render_term_line( - row: usize, - line: &[StyledChar], +/// 渲染网格一行:同样式字符合并为一段;聚焦时在光标处画反色块。 +fn render_term_row( + line: i32, + cells: &[(usize, char, AlacColor, AlacColor, bool)], cursor_here: bool, cursor_col: usize, default_fg: Rgba, default_bg: Rgba, ) -> gpui_kit::AnyElement { - // 合并同样式段。 - let mut spans: Vec<(SpanStyle, String)> = Vec::new(); - for ch in line { - if let Some((style, text)) = spans.last_mut() { - if *style == ch.style { - text.push(ch.c); + // 合并同样式段(宽字符占位格视为空格参与合并)。 + let mut spans: Vec<(AlacColor, AlacColor, bool, String)> = Vec::new(); + for (col, c, fg, bg, bold) in cells { + let _ = col; + let key = (*fg, *bg, *bold); + if let Some((sfg, sbg, sbold, text)) = spans.last_mut() { + if (*sfg, *sbg, *sbold) == key { + text.push(*c); continue; } } - spans.push((ch.style, ch.c.to_string())); + spans.push((*fg, *bg, *bold, c.to_string())); } - let total: usize = spans.iter().map(|(_, text)| text.chars().count()).sum(); + let total: usize = spans + .iter() + .map(|(_, _, _, text)| text.chars().count()) + .sum(); // 光标超出文本末尾时在行尾补空格块。 let trailing = cursor_here && cursor_col >= total; let mut row_el = h_flex() - .id(row) + .id(format!("term-row-{line}")) .w_full() .items_center() .text_xs() @@ -794,14 +557,16 @@ fn render_term_line( .into_any_element(); } let mut col = 0; - for (style, text) in &spans { - let fg = resolve_color(style.fg, style.bold, default_fg); - let bg = resolve_color(style.bg, false, default_bg); + for (fg, bg, bold, text) in &spans { + let fg_resolved = resolve_color(*fg, *bold, default_fg); + let bg_resolved = resolve_color(*bg, false, default_bg); let chunk = |text: String| { div() - .text_color(fg) - .when(style.bg != TermColor::Default, |el| el.bg(bg)) - .when(style.bold, |el| el.font_weight(FontWeight::BOLD)) + .text_color(fg_resolved) + .when(*bg != AlacColor::Named(NamedColor::Background), |el| { + el.bg(bg_resolved) + }) + .when(*bold, |el| el.font_weight(FontWeight::BOLD)) .child(text) }; if trailing { @@ -827,7 +592,7 @@ fn render_term_line( row_el = row_el.child( div() .text_color(default_bg) - .bg(fg) + .bg(fg_resolved) .child(cursor_char.unwrap_or(' ').to_string()), ); if !after.is_empty() { From f8356560c8b63e06f3dd2f76c8ff6ede4b4fa315 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 11:37:00 +0800 Subject: [PATCH 048/109] =?UTF-8?q?fix(linux):=20=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E5=85=89=E6=A0=87=E6=95=B4=E8=A1=8C=E5=8F=AA=E7=94=BB=E4=B8=80?= =?UTF-8?q?=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- linux/src/workbench/terminal.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/linux/src/workbench/terminal.rs b/linux/src/workbench/terminal.rs index 48163bc62..ae64d4d13 100644 --- a/linux/src/workbench/terminal.rs +++ b/linux/src/workbench/terminal.rs @@ -557,6 +557,7 @@ fn render_term_row( .into_any_element(); } let mut col = 0; + let mut cursor_drawn = false; for (fg, bg, bold, text) in &spans { let fg_resolved = resolve_color(*fg, *bold, default_fg); let bg_resolved = resolve_color(*bg, false, default_bg); @@ -569,8 +570,11 @@ fn render_term_row( .when(*bold, |el| el.font_weight(FontWeight::BOLD)) .child(text) }; - if trailing { + let span_len = text.chars().count(); + // 光标块整行只画一次:完全在光标前/后的段原样输出。 + if trailing || col + span_len <= cursor_col || cursor_drawn { row_el = row_el.child(chunk(text.clone())); + col += span_len; continue; } let mut before = String::new(); @@ -595,6 +599,7 @@ fn render_term_row( .bg(fg_resolved) .child(cursor_char.unwrap_or(' ').to_string()), ); + cursor_drawn = true; if !after.is_empty() { row_el = row_el.child(chunk(after)); } From 6bf80b2b9709dbaf3fa2553962432e1cf324111b Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 11:54:28 +0800 Subject: [PATCH 049/109] =?UTF-8?q?feat(linux):=20=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E8=B7=9F=E9=9A=8F=E6=9C=AB=E5=B0=BE=E4=B8=8E?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=8D=A2=E8=A1=8C=E5=AF=B9=E9=BD=90=20Tauri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Run 输出加滚动跟随开关(默认开,落盘),新增输出滚到底 - 输出行显式换行模式 + 行首缩进保留(pre-wrap 口径) - 终端网格行视觉换行,不再裁掉超宽行 --- linux/src/settings.rs | 3 + linux/src/workbench/bottom_panel.rs | 150 +++++++++++++++++++--------- linux/src/workbench/terminal.rs | 1 + 3 files changed, 106 insertions(+), 48 deletions(-) diff --git a/linux/src/settings.rs b/linux/src/settings.rs index 1a10b668d..8114e0aa5 100644 --- a/linux/src/settings.rs +++ b/linux/src/settings.rs @@ -172,6 +172,8 @@ pub struct Settings { pub terminal_cursor_blink: bool, pub terminal_scrollback: usize, pub terminal_default_shell_id: String, + // Run + pub run_scroll_to_end: bool, // UI pub ui_font_size: f32, pub display_language: String, @@ -279,6 +281,7 @@ impl Default for Settings { terminal_cursor_blink: true, terminal_scrollback: 10000, terminal_default_shell_id: String::new(), + run_scroll_to_end: true, ui_font_size: 13.0, display_language: "zh-CN".to_string(), reduce_motion: false, diff --git a/linux/src/workbench/bottom_panel.rs b/linux/src/workbench/bottom_panel.rs index b49843ac1..3f392751f 100644 --- a/linux/src/workbench/bottom_panel.rs +++ b/linux/src/workbench/bottom_panel.rs @@ -7,7 +7,8 @@ use gpui_kit::component::{h_flex, v_flex, Disableable as _, Icon, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ div, px, AnyElement, AppContext as _, Context, Entity, FontWeight, InteractiveElement as _, - IntoElement, ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, Window, + IntoElement, ParentElement as _, Render, ScrollHandle, StatefulInteractiveElement as _, + Styled as _, Window, }; use crate::core::CoreClient; @@ -131,6 +132,12 @@ pub struct BottomPanelView { pub(crate) run_output: Vec, /// 是否有进程在跑。 pub(crate) run_running: bool, + /// 输出跟随末尾(对齐 Tauri `scrollOutputToEnd`,默认开,落盘持久化)。 + pub(crate) run_follow_end: bool, + /// 运行输出滚动句柄(跟随末尾用)。 + run_scroll: ScrollHandle, + /// 上次跟随到的输出行数(只在新增时滚动,避免无关重绘抢夺滚动条)。 + run_followed_len: usize, /// Maven 任务标题(对齐 Tauri `taskTitle`,如 `compile · pom.xml`)。 pub(crate) maven_title: Option, /// Maven 任务输出行(`$ mvn …` 开头,对齐 Tauri Maven 页)。 @@ -173,6 +180,9 @@ impl BottomPanelView { selected_run_config: None, run_output: Vec::new(), run_running: false, + run_follow_end: crate::settings::get(cx).run_scroll_to_end, + run_scroll: ScrollHandle::new(), + run_followed_len: 0, maven_title: None, maven_output: Vec::new(), maven_running: false, @@ -687,7 +697,8 @@ impl BottomPanelView { .into_any_element() } - /// Run 窗格头部:运行/停止 + 重扫 + 清空 + 最小化(对齐 Tauri RunPane 头)。 + /// Run 窗格头部:运行/停止 + 重扫 + 跟随末尾 + 清空 + 最小化 + ///(对齐 Tauri RunPane 头)。 fn render_run_header(&self, cx: &mut Context) -> AnyElement { let running = self.run_running; let status = running.then(|| { @@ -703,46 +714,60 @@ impl BottomPanelView { self.run_project_name ); let can_run = !matches!(self.run_state, RunProjectState::Loading); - self.render_pane_header( - IconName::Play, - title, - status, - vec![ - Self::header_button( - "run-toggle".to_string(), - if running { - IconName::Square - } else { - IconName::Play - }, - crate::i18n::menu_text(cx, if running { "run.stop" } else { "run.run" }) - .to_string(), - !can_run, - cx, - |this, _window, cx| this.run_selected_config(cx), - ), - Self::header_button( - "run-rescan".to_string(), - IconName::RotateCw, - crate::i18n::menu_text(cx, "run.rescan").to_string(), - running, - cx, - |this, _window, cx| this.reload_run_project(cx), - ), - Self::header_button( - "run-clear-output".to_string(), - IconName::Trash, - crate::i18n::menu_text(cx, "run.clearOutput").to_string(), - self.run_output.is_empty(), - cx, - |this, _window, cx| { - this.run_output.clear(); - cx.notify(); - }, - ), - ], + let follow_end = self.run_follow_end; + let mut buttons = vec![ + Self::header_button( + "run-toggle".to_string(), + if running { + IconName::Square + } else { + IconName::Play + }, + crate::i18n::menu_text(cx, if running { "run.stop" } else { "run.run" }) + .to_string(), + !can_run, + cx, + |this, _window, cx| this.run_selected_config(cx), + ), + Self::header_button( + "run-rescan".to_string(), + IconName::RotateCw, + crate::i18n::menu_text(cx, "run.rescan").to_string(), + running, + cx, + |this, _window, cx| this.reload_run_project(cx), + ), + ]; + buttons.push( + Button::new("run-follow-end") + .small() + .when(follow_end, |b| b.primary()) + .when(!follow_end, |b| b.ghost()) + .icon(IconName::ArrowDownToLine) + .tooltip(crate::i18n::menu_text(cx, "run.scrollToEnd")) + .on_click(cx.listener(|this, _event, _window, cx| { + this.run_follow_end = !this.run_follow_end; + crate::settings::update(cx, |s| { + s.run_scroll_to_end = this.run_follow_end; + }); + // 打开跟随即滚到底(长度归零触发下次 render 滚动)。 + this.run_followed_len = 0; + cx.notify(); + })) + .into_any_element(), + ); + buttons.push(Self::header_button( + "run-clear-output".to_string(), + IconName::Trash, + crate::i18n::menu_text(cx, "run.clearOutput").to_string(), + self.run_output.is_empty(), cx, - ) + |this, _window, cx| { + this.run_output.clear(); + cx.notify(); + }, + )); + self.render_pane_header(IconName::Play, title, status, buttons, cx) } /// Maven 窗格头部:停止 + 重跑 + 清空 + 最小化(对齐 Tauri MavenRunPane 头)。 @@ -805,9 +830,8 @@ impl BottomPanelView { ) } - /// Run 面板:Tauri RunPane 子集——顶部状态行(工程名+状态+运行/停止+ - /// 重扫+清空)+ 配置列表(选中高亮)+ 输出区(monospace)。 - fn render_run_panel(&self, cx: &mut Context) -> AnyElement { + /// Run 面板体:配置列表 + 输出区;头部由 `render_run_header` 负责。 + fn render_run_panel(&mut self, cx: &mut Context) -> AnyElement { if self.run_configs.is_empty() { match &self.run_state { RunProjectState::Loading => { @@ -936,9 +960,16 @@ impl BottomPanelView { } else { self.run_output[start..] .iter() - .map(|line| div().child(line.clone()).into_any_element()) + .map(|line| render_output_line(line)) .collect() }; + // 跟随末尾:只在新增输出时滚到最后一行(对齐 Tauri `useFollowOutputEnd`)。 + if self.run_follow_end && output.len() != self.run_followed_len { + self.run_followed_len = output.len(); + if output.len() > 1 { + self.run_scroll.scroll_to_item(output.len() - 1); + } + } v_flex() .size_full() @@ -962,7 +993,7 @@ impl BottomPanelView { div() .flex_1() .h_full() - .overflow_y_scrollbar() + .vertical_scrollbar(&self.run_scroll) .p_2() .font_family("monospace") .text_xs() @@ -985,7 +1016,7 @@ impl BottomPanelView { } else { self.maven_output[start..] .iter() - .map(|line| div().child(line.clone()).into_any_element()) + .map(|line| render_output_line(line)) .collect() }; div() @@ -1735,7 +1766,30 @@ fn run_steps_blocking( } } -/// 输出行入列(超限丢最旧)。 +/// 行首空白转不换行空格:GPUI `Normal` 会塌缩连续空白(Tauri `pre-wrap` +/// 保留),只转行首以兼顾缩进保留与行内换行。 +fn preserve_leading_whitespace(line: &str) -> String { + let indent_len = line.len() - line.trim_start_matches([' ', '\t']).len(); + let (indent, rest) = line.split_at(indent_len); + let mut out = String::with_capacity(line.len() + indent.len() * 2); + for c in indent.chars() { + if c == '\t' { + out.push_str("\u{a0}\u{a0}\u{a0}\u{a0}"); + } else { + out.push('\u{a0}'); + } + } + out.push_str(rest); + out +} + +/// 输出行渲染:显式换行模式(对齐 Tauri `pre-wrap`)+ 行首缩进保留。 +fn render_output_line(line: &str) -> AnyElement { + div() + .whitespace_normal() + .child(preserve_leading_whitespace(line)) + .into_any_element() +} fn push_run_line(output: &mut Vec, line: String) { output.push(line); if output.len() > MAX_RUN_OUTPUT { diff --git a/linux/src/workbench/terminal.rs b/linux/src/workbench/terminal.rs index ae64d4d13..471b03b72 100644 --- a/linux/src/workbench/terminal.rs +++ b/linux/src/workbench/terminal.rs @@ -549,6 +549,7 @@ fn render_term_row( .id(format!("term-row-{line}")) .w_full() .items_center() + .flex_wrap() .text_xs() .font_family("monospace"); if spans.is_empty() && !trailing { From 164aa2e6142f454ece15561fc0dc9b65ca3713b3 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 12:11:26 +0800 Subject: [PATCH 050/109] fix(windows): normalize persisted display language --- .../settings/lib/settings-normalization.test.ts | 14 ++++++++++++++ .../settings/lib/settings-normalization.ts | 5 +++++ windows/tauri/src/i18n/locale.ts | 10 ++++++++++ 3 files changed, 29 insertions(+) diff --git a/windows/tauri/src/features/settings/lib/settings-normalization.test.ts b/windows/tauri/src/features/settings/lib/settings-normalization.test.ts index 374f253d4..5a35c8717 100644 --- a/windows/tauri/src/features/settings/lib/settings-normalization.test.ts +++ b/windows/tauri/src/features/settings/lib/settings-normalization.test.ts @@ -51,6 +51,20 @@ describe("IDEA file icon theme normalization", () => { }); }); +describe("display language normalization", () => { + test("keeps the settings selector aligned with the persisted locale", () => { + const settings = getDefaultSettingsSnapshot(); + settings.displayLanguage = "zh" as typeof settings.displayLanguage; + + expect(normalizeSettings(settings).displayLanguage).toBe("zh-CN"); + }); + + test("normalizes legacy English locale aliases", () => { + // Simulate a legacy value that bypassed the Settings type at runtime. + expect(normalizeSettingValue("displayLanguage", "en_US" as never)).toBe("en-US"); + }); +}); + describe("Windows New UI defaults", () => { test("uses Chinese and the native CJK UI typography for new settings", () => { const settings = getDefaultSettingsSnapshot(); diff --git a/windows/tauri/src/features/settings/lib/settings-normalization.ts b/windows/tauri/src/features/settings/lib/settings-normalization.ts index 63e0de0fd..70b81df5e 100644 --- a/windows/tauri/src/features/settings/lib/settings-normalization.ts +++ b/windows/tauri/src/features/settings/lib/settings-normalization.ts @@ -23,6 +23,7 @@ import { } from "@/features/layout/config/item-order"; import { normalizeUiFontSize } from "@/features/settings/lib/ui-font-size"; import type { Settings, SettingsSection } from "@/features/settings/types/settings.types"; +import { normalizeDisplayLanguage } from "@/i18n/locale"; const AI_MODEL_MIGRATIONS: Record> = { anthropic: { @@ -514,6 +515,9 @@ export function normalizeSettings(settings: Settings): Settings { normalizedSettings.windowChromeDensity = normalizeWindowChromeDensity( (normalizedSettings as { windowChromeDensity?: unknown }).windowChromeDensity, ); + normalizedSettings.displayLanguage = normalizeDisplayLanguage( + (normalizedSettings as { displayLanguage?: unknown }).displayLanguage, + ); normalizedSettings.fileTreeSortOrder = normalizeFileTreeSortOrder( (normalizedSettings as { fileTreeSortOrder?: unknown }).fileTreeSortOrder, ); @@ -584,6 +588,7 @@ export function normalizeSettingValue( if (key === "aiCommit") return normalizeCommitAI(value) as Settings[K]; if (key === "gitFetchSubmodules") return (["inherit", "no", "onDemand", "yes"].includes(String(value)) ? value : "inherit") as Settings[K]; if (key === "gitFetchTags") return (["inherit", "all", "none", "prune"].includes(String(value)) ? value : "inherit") as Settings[K]; + if (key === "displayLanguage") return normalizeDisplayLanguage(value) as Settings[K]; if (key === "uiFontSize") { return normalizeUiFontSize(value as number) as Settings[K]; } diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index da843cf5b..b271ac322 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -3,6 +3,16 @@ export const DISPLAY_LANGUAGES = ["en-US", "zh-CN"] as const; export type DisplayLanguage = (typeof DISPLAY_LANGUAGES)[number]; +/** Convert persisted and legacy locale values to the settings option values. */ +export function normalizeDisplayLanguage(value: unknown): DisplayLanguage { + if (typeof value !== "string") return "zh-CN"; + + const language = value.trim().toLowerCase().replace("_", "-"); + if (language.startsWith("zh")) return "zh-CN"; + if (language.startsWith("en")) return "en-US"; + return "zh-CN"; +} + const catalogs = { "en-US": { ...aiCommitEnglish, From 1af5796440c14621375426aa733d112a70b131e5 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 12:13:03 +0800 Subject: [PATCH 051/109] Revert "fix(windows): normalize persisted display language" This reverts commit 164aa2e6142f454ece15561fc0dc9b65ca3713b3. --- .../settings/lib/settings-normalization.test.ts | 14 -------------- .../settings/lib/settings-normalization.ts | 5 ----- windows/tauri/src/i18n/locale.ts | 10 ---------- 3 files changed, 29 deletions(-) diff --git a/windows/tauri/src/features/settings/lib/settings-normalization.test.ts b/windows/tauri/src/features/settings/lib/settings-normalization.test.ts index 5a35c8717..374f253d4 100644 --- a/windows/tauri/src/features/settings/lib/settings-normalization.test.ts +++ b/windows/tauri/src/features/settings/lib/settings-normalization.test.ts @@ -51,20 +51,6 @@ describe("IDEA file icon theme normalization", () => { }); }); -describe("display language normalization", () => { - test("keeps the settings selector aligned with the persisted locale", () => { - const settings = getDefaultSettingsSnapshot(); - settings.displayLanguage = "zh" as typeof settings.displayLanguage; - - expect(normalizeSettings(settings).displayLanguage).toBe("zh-CN"); - }); - - test("normalizes legacy English locale aliases", () => { - // Simulate a legacy value that bypassed the Settings type at runtime. - expect(normalizeSettingValue("displayLanguage", "en_US" as never)).toBe("en-US"); - }); -}); - describe("Windows New UI defaults", () => { test("uses Chinese and the native CJK UI typography for new settings", () => { const settings = getDefaultSettingsSnapshot(); diff --git a/windows/tauri/src/features/settings/lib/settings-normalization.ts b/windows/tauri/src/features/settings/lib/settings-normalization.ts index 70b81df5e..63e0de0fd 100644 --- a/windows/tauri/src/features/settings/lib/settings-normalization.ts +++ b/windows/tauri/src/features/settings/lib/settings-normalization.ts @@ -23,7 +23,6 @@ import { } from "@/features/layout/config/item-order"; import { normalizeUiFontSize } from "@/features/settings/lib/ui-font-size"; import type { Settings, SettingsSection } from "@/features/settings/types/settings.types"; -import { normalizeDisplayLanguage } from "@/i18n/locale"; const AI_MODEL_MIGRATIONS: Record> = { anthropic: { @@ -515,9 +514,6 @@ export function normalizeSettings(settings: Settings): Settings { normalizedSettings.windowChromeDensity = normalizeWindowChromeDensity( (normalizedSettings as { windowChromeDensity?: unknown }).windowChromeDensity, ); - normalizedSettings.displayLanguage = normalizeDisplayLanguage( - (normalizedSettings as { displayLanguage?: unknown }).displayLanguage, - ); normalizedSettings.fileTreeSortOrder = normalizeFileTreeSortOrder( (normalizedSettings as { fileTreeSortOrder?: unknown }).fileTreeSortOrder, ); @@ -588,7 +584,6 @@ export function normalizeSettingValue( if (key === "aiCommit") return normalizeCommitAI(value) as Settings[K]; if (key === "gitFetchSubmodules") return (["inherit", "no", "onDemand", "yes"].includes(String(value)) ? value : "inherit") as Settings[K]; if (key === "gitFetchTags") return (["inherit", "all", "none", "prune"].includes(String(value)) ? value : "inherit") as Settings[K]; - if (key === "displayLanguage") return normalizeDisplayLanguage(value) as Settings[K]; if (key === "uiFontSize") { return normalizeUiFontSize(value as number) as Settings[K]; } diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index b271ac322..da843cf5b 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -3,16 +3,6 @@ export const DISPLAY_LANGUAGES = ["en-US", "zh-CN"] as const; export type DisplayLanguage = (typeof DISPLAY_LANGUAGES)[number]; -/** Convert persisted and legacy locale values to the settings option values. */ -export function normalizeDisplayLanguage(value: unknown): DisplayLanguage { - if (typeof value !== "string") return "zh-CN"; - - const language = value.trim().toLowerCase().replace("_", "-"); - if (language.startsWith("zh")) return "zh-CN"; - if (language.startsWith("en")) return "en-US"; - return "zh-CN"; -} - const catalogs = { "en-US": { ...aiCommitEnglish, From d30b862b7e0522db1297cab45f2017878558f408 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 12:13:53 +0800 Subject: [PATCH 052/109] fix(linux): normalize display language setting --- linux/src/settings.rs | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/linux/src/settings.rs b/linux/src/settings.rs index 8114e0aa5..a3b9405c6 100644 --- a/linux/src/settings.rs +++ b/linux/src/settings.rs @@ -148,6 +148,18 @@ impl Default for AiCommitSettings { } } +/// 将显示语言值规范化为设置下拉框使用的稳定标识。 +pub fn normalized_display_language(value: &str) -> String { + let value = value.trim().to_ascii_lowercase().replace('_', "-"); + if value.starts_with("zh") { + "zh-CN".to_string() + } else if value.starts_with("en") { + "en-US".to_string() + } else { + "zh-CN".to_string() + } +} + /// 应用设置真源,字段名与 Tauri `defaultSettings` 的 camelCase 键一一对应。 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -283,7 +295,7 @@ impl Default for Settings { terminal_default_shell_id: String::new(), run_scroll_to_end: true, ui_font_size: 13.0, - display_language: "zh-CN".to_string(), + display_language: normalized_display_language("zh-CN"), reduce_motion: false, show_status_bar: true, show_tab_icons: true, @@ -403,7 +415,9 @@ pub fn load() -> Settings { let Ok(text) = std::fs::read_to_string(&path) else { return Settings::default(); }; - serde_json::from_str::(&text).unwrap_or_default() + let mut settings = serde_json::from_str::(&text).unwrap_or_default(); + settings.display_language = normalized_display_language(&settings.display_language); + settings } /// 将设置写回磁盘(临时文件 + 原子替换)。 @@ -434,6 +448,8 @@ pub fn init(cx: &mut App) { /// 修改设置:写全局、落盘并刷新所有窗口。 pub fn update(cx: &mut App, f: impl FnOnce(&mut Settings)) { f(get_mut(cx)); + let settings = get_mut(cx); + settings.display_language = normalized_display_language(&settings.display_language); let snapshot = get(cx).clone(); persist(&snapshot); cx.refresh_windows(); @@ -468,3 +484,20 @@ pub fn apply_theme(theme_id: &str) { let palette = ThemePalette::for_theme_id(theme_id).unwrap_or_else(ThemePalette::dark); crate::theme::set_palette(palette); } + +#[cfg(test)] +mod tests { + use super::normalized_display_language; + + #[test] + fn display_language_aliases_match_settings_options() { + assert_eq!(normalized_display_language("zh"), "zh-CN"); + assert_eq!(normalized_display_language("zh_CN"), "zh-CN"); + assert_eq!(normalized_display_language("en_US"), "en-US"); + } + + #[test] + fn unknown_display_language_uses_product_default() { + assert_eq!(normalized_display_language("fr-FR"), "zh-CN"); + } +} From 419727629c46c69df6c7fbd2cbeeb3dbcaf158ed Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 12:43:58 +0800 Subject: [PATCH 053/109] feat(linux): replicate diagnostics panel behavior --- linux/src/workbench/bottom_panel.rs | 127 ++++++++++++++++++++++++---- linux/src/workbench/view.rs | 17 +++- 2 files changed, 125 insertions(+), 19 deletions(-) diff --git a/linux/src/workbench/bottom_panel.rs b/linux/src/workbench/bottom_panel.rs index 3f392751f..f5c2d82c7 100644 --- a/linux/src/workbench/bottom_panel.rs +++ b/linux/src/workbench/bottom_panel.rs @@ -5,6 +5,7 @@ use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::component::scroll::ScrollableElement as _; use gpui_kit::component::{h_flex, v_flex, Disableable as _, Icon, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::EventEmitter; use gpui_kit::{ div, px, AnyElement, AppContext as _, Context, Entity, FontWeight, InteractiveElement as _, IntoElement, ParentElement as _, Render, ScrollHandle, StatefulInteractiveElement as _, @@ -102,6 +103,24 @@ pub enum BottomTab { GitLog, } +/// 诊断面板中的一条问题,字段与 Tauri DiagnosticsBuffer 的展示模型对齐。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiagnosticEntry { + pub severity: String, + pub file_path: String, + pub line: u32, + pub column: u32, + pub message: String, + pub source: Option, + pub code: Option, +} + +/// 诊断面板向外发出的定位事件。 +#[derive(Debug, Clone)] +pub enum BottomPanelEvent { + OpenFile { path: String, line: u32 }, +} + /// Git 提交记录的一行:短 hash + 首行 message,只读展示不跳转。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct GitLogEntry { @@ -114,7 +133,7 @@ pub struct BottomPanelView { pub is_collapsed: bool, pub height: f32, pub terminal: Entity, - pub diagnostics: Vec, + pub diagnostics: Vec, /// 终端工作目录;GitLog 面板取数时作为 `git -C` 目标。 pub working_dir: String, /// 运行历史(`TerminalView::send_command` 被调用时由宿主经 @@ -162,6 +181,8 @@ pub struct BottomPanelView { pub git_log_error: Option, } +impl EventEmitter for BottomPanelView {} + impl BottomPanelView { pub fn new(working_dir: String, cx: &mut Context) -> Self { let terminal = cx.new(|cx| TerminalView::new(working_dir.clone(), cx)); @@ -201,6 +222,93 @@ impl BottomPanelView { !self.is_collapsed } + fn render_diagnostics_panel(&self, cx: &mut Context) -> AnyElement { + if self.diagnostics.is_empty() { + return div() + .size_full() + .flex() + .items_center() + .justify_center() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(crate::i18n::menu_text(cx, "diagnostics.empty").to_string()) + .into_any_element(); + } + + let rows: Vec = self + .diagnostics + .iter() + .enumerate() + .map(|(index, diagnostic)| { + let location = format!( + "{}:{}:{}", + diagnostic.file_path, + diagnostic.line + 1, + diagnostic.column + 1 + ); + let source = diagnostic + .source + .as_deref() + .unwrap_or("diagnostic") + .to_string(); + let path = diagnostic.file_path.clone(); + let line = diagnostic.line + 1; + let severity_color = match diagnostic.severity.as_str() { + "error" => ThemeColors::destructive(), + "warning" => ThemeColors::warning(), + _ => ThemeColors::accent_blue(), + }; + h_flex() + .id(format!("diagnostic-{index}")) + .w_full() + .items_start() + .gap_2() + .px_3() + .py_2() + .cursor_pointer() + .hover(|row| row.bg(ThemeColors::accent())) + .child( + div() + .text_xs() + .font_weight(FontWeight::BOLD) + .text_color(severity_color) + .child(diagnostic.severity.clone()), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child( + div() + .text_xs() + .text_color(ThemeColors::text_primary()) + .child(diagnostic.message.clone()), + ) + .child( + div() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(format!("{location} · {source}")), + ), + ) + .on_click(cx.listener(move |_this, _event, _window, cx| { + cx.emit(BottomPanelEvent::OpenFile { + path: path.clone(), + line, + }); + })) + .into_any_element() + }) + .collect(); + + div() + .size_full() + .overflow_y_scrollbar() + .children(rows) + .into_any_element() + } + pub fn set_height(&mut self, height: f32, cx: &mut Context) { self.height = height; cx.notify(); @@ -1156,22 +1264,7 @@ impl Render for BottomPanelView { )], cx, ), - div() - .size_full() - .p_3() - .overflow_y_scrollbar() - .text_xs() - .text_color(ThemeColors::text_muted()) - .child(if self.diagnostics.is_empty() { - crate::i18n::menu_text(cx, "diagnostics.empty").to_string() - } else { - format!( - "{} ({})", - crate::i18n::menu_text(cx, "workbench.diagnostics"), - self.diagnostics.len() - ) - }) - .into_any_element(), + self.render_diagnostics_panel(cx), ), BottomTab::GitLog => ( self.render_pane_header( diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index c1bd5cc96..a53727921 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -22,7 +22,7 @@ use crate::theme::ThemeColors; use crate::workbench::activity_rail::{ ActivityRailEvent, ActivityRailView, PluginActivityRailView, PluginRailEvent, }; -use crate::workbench::bottom_panel::{BottomPanelView, BottomTab}; +use crate::workbench::bottom_panel::{BottomPanelEvent, BottomPanelView, BottomTab}; use crate::workbench::branch_manager::{BranchManagerEvent, BranchManagerView}; use crate::workbench::command_palette::{CommandPaletteEvent, CommandPaletteModal}; use crate::workbench::editor::{EditorTabEvent, EditorView}; @@ -320,7 +320,19 @@ impl WorkbenchView { }, ); - // 4. 订阅右侧插件活动栏事件 + // 4. 订阅底部诊断面板事件 + let sub_bottom_panel = cx.subscribe( + &bottom_panel, + |this, _panel, event: &BottomPanelEvent, cx| match event { + BottomPanelEvent::OpenFile { path, line } => { + this.open_file(path, cx); + this.pending_goto_line = Some(*line); + cx.notify(); + } + }, + ); + + // 5. 订阅右侧插件活动栏事件 let sub_plugin_rail = cx.subscribe( &plugin_rail, |this, _rail, event: &PluginRailEvent, cx| match event { @@ -808,6 +820,7 @@ impl WorkbenchView { sub_sidebar, obs_sidebar, sub_rail, + sub_bottom_panel, sub_plugin_rail, sub_notifications, sub_extensions, From 526dd816284907a360fd74e4b873c6096a2c361a Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 13:05:53 +0800 Subject: [PATCH 054/109] =?UTF-8?q?feat(linux):=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E6=9C=8D=E5=8A=A1=E5=99=A8=E5=B9=B6=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E8=AF=8A=E6=96=AD=E9=9D=A2=E6=9D=BF=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux 端此前只有诊断面板 UI,没有任何数据生产者,面板恒为空。本次把编辑器 文档变化接到共享 Rust Core 的 LSP 引擎,让诊断面板拿到真实的语言服务器结果。 改动点: - 新增 `linux/src/lsp.rs`:语言/服务器选择(rust-analyzer、clangd、pyright、 typescript-language-server、gopls、jdtls)、PATH 可执行文件探测、工作区 路径与 `file://` URI 的双向编解码(含百分号转义)、`lsp.startServer` / `lsp.syncDocument` / `lsp.pollEvents` 的请求载荷,以及把 `diagnostics` 事件投影为面板条目的解析逻辑;并带 4 个单元测试。 - `linux/src/workbench/editor.rs`:`EditorTabEvent` 新增 `DocumentChanged`, 在打开文件、切换标签和编辑文本时广播当前活动文档。 - `linux/src/workbench/view.rs`:工作台持有 LSP 会话、待同步文档、轮询任务和 诊断缓存;订阅编辑器文档事件后按需启动会话(同 provider 只启一个)、 启动后 flush 暂存文档、以 400ms 有界间隔轮询事件,并把诊断聚合排序后交给 底部面板;会话终态或轮询失败后清理会话状态。 - `linux/src/workbench/bottom_panel.rs`:新增 `set_diagnostics` 供工作台写入, 清空按钮改为发出 `ClearDiagnostics` 事件,让宿主同步丢弃缓存,避免下一轮 诊断把已清空内容重新填回。 - `linux/src/lib.rs`、`linux/src/main.rs`:声明 `lsp` 模块。 验证: - `cargo build --manifest-path linux/Cargo.toml` 通过。 - `cargo test --manifest-path linux/Cargo.toml --bin lithe-linux` 23 项通过 (含 4 项新增 lsp 测试)。 - 用临时 smoke 程序直接调用 Core 的 `lsp.startServer`/`lsp.syncDocument`/ `lsp.pollEvents` 驱动 rust-analyzer,确认能收到带 `range.start.line`、 `utf16Column`、`severity`、`source` 的 diagnostics 事件,与投影逻辑一致。 --- linux/src/lib.rs | 1 + linux/src/lsp.rs | 391 ++++++++++++++++++++++++++++ linux/src/main.rs | 1 + linux/src/workbench/bottom_panel.rs | 9 + linux/src/workbench/editor.rs | 17 ++ linux/src/workbench/view.rs | 286 +++++++++++++++++++- 6 files changed, 702 insertions(+), 3 deletions(-) create mode 100644 linux/src/lsp.rs diff --git a/linux/src/lib.rs b/linux/src/lib.rs index f8775d333..559e4cf94 100644 --- a/linux/src/lib.rs +++ b/linux/src/lib.rs @@ -1,5 +1,6 @@ pub mod core; pub mod i18n; +pub mod lsp; pub mod settings; pub mod theme; pub mod workbench; diff --git a/linux/src/lsp.rs b/linux/src/lsp.rs new file mode 100644 index 000000000..c9ebc6f4d --- /dev/null +++ b/linux/src/lsp.rs @@ -0,0 +1,391 @@ +//! Linux 端 LSP 接线:把编辑器文档同步给 Core 管理的语言服务器,并把 +//! `lsp.pollEvents` 返回的诊断映射到诊断面板。 +//! +//! Core 拥有进程、JSON-RPC framing、生命周期和诊断状态;本模块只做 +//! 平台侧的语言/可执行文件探测、路径与 URI 规范化,以及事件投影。 + +use std::collections::HashMap; + +use serde_json::{json, Value}; + +use crate::workbench::bottom_panel::DiagnosticEntry; + +/// 一个可在本机通过 PATH 启动的语言服务器定义。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LanguageProvider { + /// 与 Core `providerId` 一致的稳定标识。 + pub id: &'static str, + /// PATH 中查找的可执行文件名。 + pub executable: &'static str, + /// 以 stdio 启动服务器所需的固定参数。 + pub arguments: &'static [&'static str], + /// 对应文件扩展名的 LSP `languageId` 前缀。 + pub language_id: &'static str, + /// 扩展名匹配集合,全部小写且不含点。 + pub extensions: &'static [&'static str], +} + +const RUST_ANALYZER: LanguageProvider = LanguageProvider { + id: "rust-analyzer", + executable: "rust-analyzer", + arguments: &[], + language_id: "rust", + extensions: &["rs"], +}; + +const CLANGD: LanguageProvider = LanguageProvider { + id: "clangd", + executable: "clangd", + arguments: &[], + language_id: "c", + extensions: &["c", "h", "cc", "cpp", "cxx", "hpp", "hh", "hxx"], +}; + +const PYRIGHT: LanguageProvider = LanguageProvider { + id: "pyright", + executable: "pyright-langserver", + arguments: &["--stdio"], + language_id: "python", + extensions: &["py", "pyi"], +}; + +const TYPESCRIPT: LanguageProvider = LanguageProvider { + id: "typescript-language-server", + executable: "typescript-language-server", + arguments: &["--stdio"], + language_id: "typescript", + extensions: &["ts", "tsx", "js", "jsx", "mjs", "cjs"], +}; + +const GOPLS: LanguageProvider = LanguageProvider { + id: "gopls", + executable: "gopls", + arguments: &[], + language_id: "go", + extensions: &["go"], +}; + +const JDTLS: LanguageProvider = LanguageProvider { + id: "jdtls", + executable: "jdtls", + arguments: &[], + language_id: "java", + extensions: &["java"], +}; + +const PROVIDERS: &[LanguageProvider] = &[RUST_ANALYZER, CLANGD, PYRIGHT, TYPESCRIPT, GOPLS, JDTLS]; + +/// 按文件扩展名选择语言服务器;未知扩展名返回 `None`。 +pub fn provider_for_path(path: &str) -> Option { + let extension = file_extension(path)?; + PROVIDERS + .iter() + .copied() + .find(|provider| provider.extensions.contains(&extension.as_str())) +} + +fn file_extension(path: &str) -> Option { + std::path::Path::new(path) + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) +} + +/// provider 的默认 `languageId` 对同族扩展名需要细分,例如 clangd 的 C/C++ +/// 与 typescript-language-server 的 TS/JS 使用不同的语言标识。 +pub fn language_id_for_path(path: &str, provider: LanguageProvider) -> &'static str { + let extension = file_extension(path).unwrap_or_default(); + match provider.id { + "clangd" => match extension.as_str() { + "cc" | "cpp" | "cxx" | "hpp" | "hh" | "hxx" => "cpp", + _ => "c", + }, + "typescript-language-server" => match extension.as_str() { + "js" | "jsx" | "mjs" | "cjs" => "javascript", + _ => "typescript", + }, + _ => provider.language_id, + } +} + +/// 在 `PATH` 中查找可执行文件;只返回可执行且存在的绝对路径。 +pub fn find_in_path(name: &str) -> Option { + let path = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path) { + let candidate = dir.join(name); + if !candidate.is_file() { + continue; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let executable = std::fs::metadata(&candidate) + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false); + if !executable { + continue; + } + } + return Some(candidate.to_string_lossy().into_owned()); + } + None +} + +/// 把相对工作区路径解析成绝对路径;已经是绝对路径时原样返回。 +pub fn absolute_path(root: &str, path: &str) -> String { + let candidate = std::path::Path::new(path); + if candidate.is_absolute() { + path.to_string() + } else { + std::path::Path::new(root) + .join(path) + .to_string_lossy() + .replace('\\', "/") + } +} + +/// 构造 LSP `file://` URI,保留 `/`,其余字节做百分号编码。 +pub fn file_uri(root: &str, path: &str) -> String { + let absolute = absolute_path(root, path); + let mut encoded = String::with_capacity(absolute.len() + 8); + for byte in absolute.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => { + encoded.push(byte as char) + } + other => encoded.push_str(&format!("%{other:02X}")), + } + } + format!("file://{encoded}") +} + +/// 把 LSP `file://` URI 映射回工作区相对路径;工作区外返回 `None`。 +pub fn workspace_relative_path(root: &str, uri: &str) -> Option { + let encoded = uri.strip_prefix("file://")?; + let decoded = percent_decode(encoded); + let root = root.replace('\\', "/"); + let root = root.trim_end_matches('/'); + let decoded = decoded.replace('\\', "/"); + if decoded == root { + return Some(String::new()); + } + decoded + .strip_prefix(&format!("{root}/")) + .map(|value| value.to_string()) +} + +fn percent_decode(value: &str) -> String { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).ok(); + if let Some(byte) = hex.and_then(|value| u8::from_str_radix(value, 16).ok()) { + decoded.push(byte); + index += 3; + continue; + } + } + decoded.push(bytes[index]); + index += 1; + } + String::from_utf8_lossy(&decoded).into_owned() +} + +/// 当前语言服务器可用性;不可用时返回空。 +pub fn resolve_provider(path: &str) -> Option<(LanguageProvider, String)> { + let provider = provider_for_path(path)?; + let executable = find_in_path(provider.executable)?; + Some((provider, executable)) +} + +/// `lsp.startServer` 的最小有效载荷。平台只提供进程与工作区信息, +/// 初始化参数与工作区指纹由 Core 的 provider 适配层负责。 +pub fn start_payload( + provider: LanguageProvider, + executable: &str, + root: &str, + cache_directory: &str, +) -> Value { + let mut arguments: Vec = provider + .arguments + .iter() + .map(|value| value.to_string()) + .collect(); + if provider.id == "jdtls" { + // jdtls 要求显式 data 目录;configuration 目录由发行包脚本补齐。 + arguments.push("-data".to_string()); + arguments.push(cache_directory.to_string()); + } + json!({ + "providerId": provider.id, + "executablePath": executable, + "arguments": arguments, + "rootUri": file_uri(root, root), + "workingDirectory": absolute_path(root, root), + "initializeTimeoutMilliseconds": 30_000, + "requestTimeoutMilliseconds": 30_000, + "shutdownTimeoutMilliseconds": 2_000, + "cacheDirectory": cache_directory, + }) +} + +pub fn parse_session_id(value: &Value) -> Option { + value + .get("sessionId") + .and_then(Value::as_str) + .map(str::to_string) +} + +pub fn sync_payload(session_id: &str, uri: &str, language_id: &str, text: &str) -> Value { + json!({ + "sessionId": session_id, + "uri": uri, + "languageId": language_id, + "text": text, + }) +} + +pub fn poll_payload(session_id: &str) -> Value { + json!({ "sessionId": session_id }) +} + +/// 语言服务器退出后 Core 只允许在收到终态事件后销毁会话。 +pub fn session_finished(value: &Value) -> bool { + value + .get("events") + .and_then(Value::as_array) + .map(|events| { + events.iter().any(|event| { + let kind = event.get("type").and_then(Value::as_str); + let state = event.get("state").and_then(Value::as_str); + matches!(kind, Some("stateChanged")) && matches!(state, Some("stopped" | "failed")) + }) + }) + .unwrap_or(false) +} + +/// 把一次 poll 的诊断事件投影为面板条目;无 URI 或工作区外的事件被丢弃。 +pub fn diagnostics_from_poll(value: &Value, root: &str) -> HashMap> { + let mut by_file: HashMap> = HashMap::new(); + let Some(events) = value.get("events").and_then(Value::as_array) else { + return by_file; + }; + for event in events { + if event.get("type").and_then(Value::as_str) != Some("diagnostics") { + continue; + } + let Some(uri) = event.get("uri").and_then(Value::as_str) else { + continue; + }; + let Some(path) = workspace_relative_path(root, uri) else { + continue; + }; + let diagnostics = event + .get("diagnostics") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .map(|diagnostic| DiagnosticEntry { + severity: severity_name(diagnostic.get("severity").and_then(Value::as_i64)), + file_path: path.clone(), + line: diagnostic + .pointer("/range/start/line") + .and_then(Value::as_u64) + .unwrap_or(0) as u32, + column: diagnostic + .pointer("/range/start/utf16Column") + .and_then(Value::as_u64) + .unwrap_or(0) as u32, + message: diagnostic + .get("message") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + source: diagnostic + .get("source") + .and_then(Value::as_str) + .map(str::to_string), + code: diagnostic + .get("code") + .and_then(Value::as_str) + .map(str::to_string), + }) + .collect::>() + }) + .unwrap_or_default(); + by_file.insert(path, diagnostics); + } + by_file +} + +fn severity_name(severity: Option) -> String { + match severity { + Some(1) => "error".to_string(), + Some(2) => "warning".to_string(), + Some(3) => "info".to_string(), + Some(4) => "hint".to_string(), + _ => "info".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encodes_and_decodes_file_uris() { + let uri = file_uri("/work space", "src/主 file.rs"); + assert_eq!(uri, "file:///work%20space/src/%E4%B8%BB%20file.rs"); + assert_eq!( + workspace_relative_path("/work space", &uri).as_deref(), + Some("src/主 file.rs") + ); + } + + #[test] + fn selects_provider_by_extension() { + assert_eq!( + provider_for_path("src/main.rs").map(|p| p.id), + Some("rust-analyzer") + ); + assert_eq!( + provider_for_path("src/App.tsx").map(|p| p.id), + Some("typescript-language-server") + ); + assert_eq!(provider_for_path("README.md").map(|p| p.id), None); + } + + #[test] + fn refines_language_id_for_shared_providers() { + let clangd = provider_for_path("src/native.cpp").expect("clangd"); + assert_eq!(language_id_for_path("src/native.cpp", clangd), "cpp"); + assert_eq!(language_id_for_path("src/native.c", clangd), "c"); + let tsserver = provider_for_path("src/app.js").expect("tsserver"); + assert_eq!(language_id_for_path("src/app.js", tsserver), "javascript"); + assert_eq!(language_id_for_path("src/app.ts", tsserver), "typescript"); + } + + #[test] + fn projects_diagnostics_inside_the_workspace() { + let value = serde_json::json!({ + "events": [{ + "type": "diagnostics", + "uri": "file:///work/src/main.rs", + "diagnostics": [{ + "severity": 1, + "message": "mismatched types", + "range": { "start": { "line": 4, "utf16Column": 7 } } + }] + }] + }); + let diagnostics = diagnostics_from_poll(&value, "/work"); + let entries = diagnostics.get("src/main.rs").expect("diagnostics"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].severity, "error"); + assert_eq!(entries[0].line, 4); + assert_eq!(entries[0].column, 7); + } +} diff --git a/linux/src/main.rs b/linux/src/main.rs index 49095f030..d89dd7899 100644 --- a/linux/src/main.rs +++ b/linux/src/main.rs @@ -2,6 +2,7 @@ mod core; mod i18n; +mod lsp; mod settings; mod theme; mod workbench; diff --git a/linux/src/workbench/bottom_panel.rs b/linux/src/workbench/bottom_panel.rs index f5c2d82c7..3079d014a 100644 --- a/linux/src/workbench/bottom_panel.rs +++ b/linux/src/workbench/bottom_panel.rs @@ -119,6 +119,8 @@ pub struct DiagnosticEntry { #[derive(Debug, Clone)] pub enum BottomPanelEvent { OpenFile { path: String, line: u32 }, + /// 面板清空按钮:请求宿主同步丢弃缓存的 LSP 诊断。 + ClearDiagnostics, } /// Git 提交记录的一行:短 hash + 首行 message,只读展示不跳转。 @@ -330,6 +332,12 @@ impl BottomPanelView { cx.notify(); } + /// 用最新一轮 LSP 诊断替换面板内容;顺序与 [`crate::lsp`] 投影一致。 + pub fn set_diagnostics(&mut self, diagnostics: Vec, cx: &mut Context) { + self.diagnostics = diagnostics; + cx.notify(); + } + pub fn toggle_collapsed(&mut self, cx: &mut Context) { self.is_collapsed = !self.is_collapsed; cx.notify(); @@ -1259,6 +1267,7 @@ impl Render for BottomPanelView { cx, |this, _window, cx| { this.diagnostics.clear(); + cx.emit(BottomPanelEvent::ClearDiagnostics); cx.notify(); }, )], diff --git a/linux/src/workbench/editor.rs b/linux/src/workbench/editor.rs index 282778855..10e5fbbdf 100644 --- a/linux/src/workbench/editor.rs +++ b/linux/src/workbench/editor.rs @@ -129,6 +129,7 @@ impl EditorView { } } self.active_tab_index = Some(pos); + self.publish_active_document(cx); cx.notify(); return; } @@ -150,6 +151,7 @@ impl EditorView { self.active_tab_index = Some(self.tabs.len() - 1); self.sync_needed = true; + self.publish_active_document(cx); cx.notify(); } @@ -206,6 +208,18 @@ impl EditorView { self.synced_tab = Some(index); self.sync_needed = false; + self.publish_active_document(cx); + } + + /// 向父视图广播当前活动文档(无活动标签时不广播)。 + fn publish_active_document(&mut self, cx: &mut Context) { + let Some(tab) = self.active_tab_index.and_then(|index| self.tabs.get(index)) else { + return; + }; + cx.emit(EditorTabEvent::DocumentChanged { + path: tab.path.clone(), + text: tab.content.clone(), + }); } /// 编辑器文本变化后回写标签内容并标记为已修改。 @@ -229,6 +243,7 @@ impl EditorView { } self.redo_stack.clear(); } + self.publish_active_document(cx); cx.notify(); } @@ -993,6 +1008,8 @@ impl EditorView { #[derive(Debug, Clone)] pub enum EditorTabEvent { OpenInTerminal { dir: String }, + /// 活动标签的内容或身份发生变化,父视图据此同步 LSP 文档态。 + DocumentChanged { path: String, text: String }, } impl EventEmitter for EditorView {} diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index a53727921..6d00bcdc0 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -13,16 +13,20 @@ use gpui_kit::{ MouseMoveEvent, MouseUpEvent, ParentElement as _, Render, Styled as _, Subscription, Window, }; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::rc::Rc; +use std::time::Duration; use crate::core::CoreClient; +use crate::lsp::{self, LanguageProvider}; use crate::settings; use crate::theme::ThemeColors; use crate::workbench::activity_rail::{ ActivityRailEvent, ActivityRailView, PluginActivityRailView, PluginRailEvent, }; -use crate::workbench::bottom_panel::{BottomPanelEvent, BottomPanelView, BottomTab}; +use crate::workbench::bottom_panel::{ + BottomPanelEvent, BottomPanelView, BottomTab, DiagnosticEntry, +}; use crate::workbench::branch_manager::{BranchManagerEvent, BranchManagerView}; use crate::workbench::command_palette::{CommandPaletteEvent, CommandPaletteModal}; use crate::workbench::editor::{EditorTabEvent, EditorView}; @@ -50,6 +54,14 @@ pub enum RightToolView { Extensions, } +/// 语言服务器尚未就绪时暂存的一份文档同步请求。 +#[derive(Debug, Clone)] +struct PendingDocument { + uri: String, + language_id: String, + text: String, +} + /// 主工作台视图。 pub struct WorkbenchView { /// 项目工作区根路径 @@ -101,6 +113,18 @@ pub struct WorkbenchView { pub extensions: Entity, /// 通知诊断行点击后的待跳转行号(1 起,文件加载完成后在 render 应用) pending_goto_line: Option, + /// LSP:provider id → Core 会话 id(非空即已启动)。 + lsp_sessions: HashMap, + /// LSP:正在启动中的 provider id,避免重复 `lsp.startServer`。 + lsp_starting: HashSet, + /// LSP:正在轮询的会话 id,保证每个会话只有一个 poll 循环。 + lsp_polling: HashSet, + /// LSP:会话中已同步文档的版本号(`session\0uri` → version)。 + lsp_document_versions: HashMap, + /// LSP:会话未就绪时暂存的待同步文档(provider id → 文档)。 + lsp_pending_documents: HashMap>, + /// LSP:工作区相对路径 → 该文件最新诊断,供底部面板聚合展示。 + lsp_diagnostics: HashMap>, /// 侧边栏面板 (Files / Git / Search) pub sidebar: Entity, /// 多窗格布局模型(`Split{Horizontal}` 左右并排,`{Vertical}` 上下堆叠) @@ -329,6 +353,9 @@ impl WorkbenchView { this.pending_goto_line = Some(*line); cx.notify(); } + BottomPanelEvent::ClearDiagnostics => { + this.lsp_diagnostics.clear(); + } }, ); @@ -642,7 +669,7 @@ impl WorkbenchView { }, ); - // 8b. 订阅编辑器标签页事件(在终端中打开) + // 8b. 订阅编辑器标签页事件(在终端中打开 / 文档变化同步 LSP) let sub_editor_tab = cx.subscribe( &editor, @@ -650,6 +677,9 @@ impl WorkbenchView { EditorTabEvent::OpenInTerminal { dir } => { this.send_terminal_command(&format!("cd \"{dir}\""), cx); } + EditorTabEvent::DocumentChanged { path, text } => { + this.lsp_on_document_changed(path.clone(), text.clone(), cx); + } }, ); @@ -795,6 +825,12 @@ impl WorkbenchView { show_branch_manager: false, right_tool: None, pending_goto_line: None, + lsp_sessions: HashMap::new(), + lsp_starting: HashSet::new(), + lsp_polling: HashSet::new(), + lsp_document_versions: HashMap::new(), + lsp_pending_documents: HashMap::new(), + lsp_diagnostics: HashMap::new(), toolbar, activity_rail, plugin_rail, @@ -1089,6 +1125,247 @@ impl WorkbenchView { self.active_editor() } + /// 编辑器文档变化:选择语言服务器、按需启动会话并同步全文。 + /// 未知语言或本机无对应可执行文件时静默跳过,不影响编辑。 + fn lsp_on_document_changed(&mut self, path: String, text: String, cx: &mut Context) { + let Some((provider, executable)) = lsp::resolve_provider(&path) else { + return; + }; + let root = self.workspace_root.clone(); + let uri = lsp::file_uri(&root, &path); + let language_id = lsp::language_id_for_path(&path, provider).to_string(); + + if let Some(session) = self.lsp_sessions.get(provider.id).cloned() { + self.lsp_sync_document(&session, uri, language_id, text, cx); + return; + } + + // 会话未就绪:先暂存最新全文,会话 ready 后统一 flush。 + let pending = self + .lsp_pending_documents + .entry(provider.id.to_string()) + .or_default(); + if let Some(existing) = pending.iter_mut().find(|document| document.uri == uri) { + existing.text = text; + } else { + pending.push(PendingDocument { + uri, + language_id, + text, + }); + } + self.lsp_start_server(provider, executable, root, cx); + } + + /// 启动一个语言服务器会话;同一 provider 同时只允许一个启动任务。 + fn lsp_start_server( + &mut self, + provider: LanguageProvider, + executable: String, + root: String, + cx: &mut Context, + ) { + if self.lsp_starting.contains(provider.id) || self.lsp_sessions.contains_key(provider.id) { + return; + } + self.lsp_starting.insert(provider.id.to_string()); + + let client = self.client.clone(); + let cache_directory = format!("{root}/.lithe/lsp/{}", provider.id); + let payload = lsp::start_payload(provider, &executable, &root, &cache_directory); + cx.spawn(async move |this, cx| { + let result = client + .execute::<_, serde_json::Value>(&cx, "lsp.startServer", payload) + .await; + let _ = this.update(cx, |this, cx| { + this.lsp_starting.remove(provider.id); + match result { + Ok(value) => match lsp::parse_session_id(&value) { + Some(session) => { + this.lsp_sessions + .insert(provider.id.to_string(), session.clone()); + this.lsp_start_poll(provider.id, session.clone(), cx); + this.lsp_flush_pending(provider.id, &session, cx); + } + None => { + this.lsp_push_error(provider.id, "lsp.startServer 未返回 sessionId", cx) + } + }, + Err(error) => this.lsp_push_error(provider.id, &error, cx), + } + cx.notify(); + }); + }) + .detach(); + } + + /// 会话建立后把所有暂存文档送入 Core。 + fn lsp_flush_pending(&mut self, provider_id: &str, session: &str, cx: &mut Context) { + let Some(pending) = self.lsp_pending_documents.remove(provider_id) else { + return; + }; + for document in pending { + self.lsp_sync_document( + session, + document.uri, + document.language_id, + document.text, + cx, + ); + } + } + + /// 把一份全文同步给 Core;版本号在响应后写回,供后续状态查询使用。 + fn lsp_sync_document( + &mut self, + session: &str, + uri: String, + language_id: String, + text: String, + cx: &mut Context, + ) { + let client = self.client.clone(); + let key = format!("{session}\u{0}{uri}"); + let payload = lsp::sync_payload(session, &uri, &language_id, &text); + cx.spawn(async move |this, cx| { + let result = client + .execute::<_, serde_json::Value>(&cx, "lsp.syncDocument", payload) + .await; + if let Ok(value) = result { + let version = value + .get("documentVersion") + .and_then(serde_json::Value::as_i64); + let _ = this.update(cx, |this, _cx| { + match version { + Some(version) => { + this.lsp_document_versions.insert(key, version); + } + // 保留会话-文档映射,便于后续清理时识别归属。 + None => { + this.lsp_document_versions.entry(key).or_insert(0); + } + } + }); + } + }) + .detach(); + } + + /// 为会话启动唯一的事件轮询循环,直至服务器停止或命令失败。 + fn lsp_start_poll( + &mut self, + provider_id: &'static str, + session: String, + cx: &mut Context, + ) { + if !self.lsp_polling.insert(session.clone()) { + return; + } + let client = self.client.clone(); + let root = self.workspace_root.clone(); + cx.spawn(async move |this, cx| { + loop { + let payload = lsp::poll_payload(&session); + let result = client + .execute::<_, serde_json::Value>(&cx, "lsp.pollEvents", payload) + .await; + let finished = match result { + Ok(value) => { + let diagnostics = lsp::diagnostics_from_poll(&value, &root); + let finished = lsp::session_finished(&value); + let provider = provider_id.to_string(); + let session_key = session.clone(); + let _ = this.update(cx, |this, cx| { + if !diagnostics.is_empty() { + this.lsp_apply_diagnostics(diagnostics, cx); + } + if finished { + this.lsp_forget_session(&provider, &session_key, cx); + } + }); + finished + } + Err(_) => true, + }; + if finished { + break; + } + cx.background_executor() + .timer(Duration::from_millis(400)) + .await; + } + let _ = this.update(cx, |this, _cx| { + this.lsp_polling.remove(&session); + }); + }) + .detach(); + } + + /// 会话终止后的清理:移除会话、轮询标记、文档版本与错误占位。 + fn lsp_forget_session(&mut self, provider_id: &str, session: &str, cx: &mut Context) { + self.lsp_sessions.remove(provider_id); + self.lsp_polling.remove(session); + let prefix = format!("{session}\u{0}"); + self.lsp_document_versions + .retain(|key, _| !key.starts_with(&prefix)); + let error_key = format!("__lsp__{provider_id}"); + if self.lsp_diagnostics.remove(&error_key).is_some() { + self.sync_diagnostics_panel(cx); + } + } + + /// 合并一轮诊断:空列表代表服务端已清空该文件的问题。 + fn lsp_apply_diagnostics( + &mut self, + updates: HashMap>, + cx: &mut Context, + ) { + let mut changed = false; + for (path, entries) in updates { + if entries.is_empty() { + changed |= self.lsp_diagnostics.remove(&path).is_some(); + } else { + self.lsp_diagnostics.insert(path, entries); + changed = true; + } + } + if changed { + self.sync_diagnostics_panel(cx); + } + } + + /// 启动/同步失败时在诊断面板留一条错误记录,便于定位缺失的服务器。 + fn lsp_push_error(&mut self, provider_id: &str, message: &str, cx: &mut Context) { + let entry = DiagnosticEntry { + severity: "error".to_string(), + file_path: provider_id.to_string(), + line: 0, + column: 0, + message: message.to_string(), + source: Some("LSP".to_string()), + code: None, + }; + self.lsp_diagnostics + .insert(format!("__lsp__{provider_id}"), vec![entry]); + self.sync_diagnostics_panel(cx); + } + + /// 把所有文件的诊断按路径/行/列排序后交给底部面板。 + fn sync_diagnostics_panel(&mut self, cx: &mut Context) { + let mut entries: Vec = + self.lsp_diagnostics.values().flatten().cloned().collect(); + entries.sort_by(|left, right| { + left.file_path + .cmp(&right.file_path) + .then(left.line.cmp(&right.line)) + .then(left.column.cmp(&right.column)) + }); + let _ = self + .bottom_panel + .update(cx, |panel, cx| panel.set_diagnostics(entries, cx)); + } + + /// 活动编辑器上执行动作(无活动窗格时忽略)。 fn with_active_editor( &self, @@ -1153,6 +1430,9 @@ impl WorkbenchView { EditorTabEvent::OpenInTerminal { dir } => { this.send_terminal_command(&format!("cd \"{dir}\""), cx); } + EditorTabEvent::DocumentChanged { path, text } => { + this.lsp_on_document_changed(path.clone(), text.clone(), cx); + } }, ); self._subscriptions.push(sub); From 0c96cfc998b687d26619b5fb50d9fb8ae344b576 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 13:16:09 +0800 Subject: [PATCH 055/109] =?UTF-8?q?feat(linux):=20LSP=20=E9=9B=86=E6=88=90?= =?UTF-8?q?=E4=B8=8E=20Run=20=E9=85=8D=E7=BD=AE=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 lsp.rs 模块:支持 rust-analyzer、clangd、pyright、typescript-language-server、gopls、jdtls 等语言服务器 - 新增 run/ 模块(config.rs + process.rs):重构 Run 配置管理,使用 Core runConfig.inspect/generate/resolve 替代本地文件读写 - bottom_panel.rs:引入 ProcessManager 统一管理子进程生命周期,支持 Run/Maven 多进程隔离 - editor.rs:新增 publish_active_document 事件,支持 LSP 文档同步 - settings_dialog.rs:Run 配置切换时自动重载,支持 workspace root 变更时重置配置状态 - view.rs:LSP 会话管理、诊断聚合、编辑器同步等能力接入 - 更新 Java 项目构建与启动边界笔记 --- ...-java-project-build-and-launch-boundary.md | 15 + linux/Cargo.lock | 1 + linux/Cargo.toml | 1 + linux/src/workbench/bottom_panel.rs | 1361 ++++++++--------- linux/src/workbench/editor.rs | 47 +- linux/src/workbench/mod.rs | 1 + linux/src/workbench/run/config.rs | 1105 +++++++++++++ linux/src/workbench/run/mod.rs | 15 + linux/src/workbench/run/process.rs | 1331 ++++++++++++++++ linux/src/workbench/settings_dialog.rs | 294 ++-- linux/src/workbench/view.rs | 76 +- 11 files changed, 3334 insertions(+), 913 deletions(-) create mode 100644 linux/src/workbench/run/config.rs create mode 100644 linux/src/workbench/run/mod.rs create mode 100644 linux/src/workbench/run/process.rs diff --git a/.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md b/.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md index 37d59f586..4339455f0 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md +++ b/.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md @@ -36,6 +36,13 @@ classpath/module-path;随后 Run 模块只启动一次项目 JDK。Maven 仍 4. Rust Core 接收结构化 `javaLaunch`,生成 `project-jdk` 直启计划; 5. macOS/Windows 宿主按各自路径分隔符拼接参数并启动一个 JVM。 +Linux 第一阶段沿用同一条配置边界:`bottom_panel.rs` 只发 Core 命令和展示 +状态,`workbench/run/config.rs` 持久化 Core 生成结果、解析工具链与启动计划, +`workbench/run/process.rs` 独占 session、子进程组和输出事件。Linux 不读取旧 +`name/type/mainClass` 文档作为主路径,不再用源码正则补入口,也不在计划失败 +后伪造 `mvn compile`。Maven 项目里的 `java.main` 仍因缺少 JDT 返回的 +`javaLaunch` 明确失败,直到 Linux 接入同一 Java 项目准备能力。 + Run 和 Debug 共用同一套 Java 项目准备逻辑。配置中的 Maven 信息仍用于 JDT LS 导入、Profile、settings.xml 和项目模型;Maven 工具窗口、框架 goal、测试与显式 Maven 任务仍走 Maven 启动计划。 @@ -176,6 +183,9 @@ Run 控件,也不在 `java-run-launch` 或平台 adapter 再做一次 `blocksR - 点击运行可能需要等待 Java 语言服务 ready;构建失败会保留真实诊断,并在目标路径 仍可解析时暂停等待用户决定,而不是永久阻止启动。 - 独立 Java 文件仍遵循“`javac` 编译再运行”的既有方案,不依赖语言服务。 +- Linux Run 只把 Core 计划解析成 Linux 进程;Core 或配置失败会保留真实错误, + 不再退化为只执行 `compile`。Maven 项目里的 Java 入口在 JDT 准备完成前不会 + 启动,Linux 也不会维护第二套源码扫描结果。 - 打开大型 Maven 项目后立即点击运行,可能要先等 JDT 的项目更新结束;等待原因 会写入日志(`Java project build is waiting`),不会再因 30 秒期限而半路失败。 Windows 运行面板在准备期间先显示一行“正在等待 Java 语言服务更新并构建项目”, @@ -202,6 +212,10 @@ Run 控件,也不在 `java-run-launch` 或平台 adapter 再做一次 `blocksR 终态测试覆盖同一次启动继续、按工作区记忆选择和索引恢复。 - Windows:Run Store 把准备结果传入 Core,并分别用 `;` 拼 classpath/module-path; Run/Debug 共用的 Store 测试覆盖同一次启动继续、取消、记忆选择和索引恢复。 +- Linux:`cargo test --manifest-path linux/Cargo.toml --lib --bins` 覆盖 Core + process/Spring Boot 计划、`javac -> java`、POSIX classpath、工具链映射、 + pre-launch 失败门禁、过期 sequence 与完整进程组停止;测试不依赖本机 + Maven/JDK/网络。 - 共享契约:`shared/contracts/rust-core-api.md` 与 `shared/contracts/application-boundary.md`,跨平台样例为 `shared/fixtures/lsp/java-build-report-v1.json`。 @@ -213,5 +227,6 @@ Run 控件,也不在 `java-run-launch` 或平台 adapter 再做一次 `blocksR `rust/lithe-core/src/lsp/interface/engine.rs` - macOS:`LanguageToolingSessionManager`、`AppModel+RunConfiguration`、`RunService` - Windows:`java-run-launch.ts`、`lsp-core-adapter.ts`、`run.store.ts` +- Linux:`linux/src/workbench/bottom_panel.rs`、`linux/src/workbench/run/` - 相关笔记: `.agents/notes/implemented/feature/2026-09-17-standalone-java-compile-then-run.md` diff --git a/linux/Cargo.lock b/linux/Cargo.lock index 0b98ccd21..375ab4c10 100644 --- a/linux/Cargo.lock +++ b/linux/Cargo.lock @@ -3960,6 +3960,7 @@ dependencies = [ "alacritty_terminal", "anyhow", "gpui-kit", + "libc", "lithe-core", "portable-pty", "rfd", diff --git a/linux/Cargo.toml b/linux/Cargo.toml index d14c4ac61..d5365b396 100644 --- a/linux/Cargo.toml +++ b/linux/Cargo.toml @@ -26,3 +26,4 @@ serde_json = "1" tokio = { version = "1", features = ["sync", "macros", "rt"] } anyhow = "1" uuid = { version = "1", features = ["v4"] } +libc = "0.2" diff --git a/linux/src/workbench/bottom_panel.rs b/linux/src/workbench/bottom_panel.rs index 3079d014a..be61351f9 100644 --- a/linux/src/workbench/bottom_panel.rs +++ b/linux/src/workbench/bottom_panel.rs @@ -1,4 +1,4 @@ -use std::sync::{mpsc, Arc, Mutex}; +use std::sync::mpsc; use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants as _}; @@ -14,6 +14,13 @@ use gpui_kit::{ use crate::core::CoreClient; use crate::theme::ThemeColors; +use crate::workbench::editor::EditorView; +use crate::workbench::run::{ + create_launch_plan_request, default_generated_configuration_id, list_java_sources, + maven_context_for_configuration, parse_resolved_configurations, read_toolchain_paths, + sequence_is_current, toolchain_candidates, write_generated_documents, LaunchPlan, OutputStream, + ProcessEvent, ProcessManager, RunConfigItem, +}; use crate::workbench::terminal::TerminalView; /// 运行历史上限。 @@ -36,51 +43,6 @@ pub enum RunProjectState { Failed(String), } -/// Run 配置行:本地 `.lithe/run/configurations.json` 与 -/// `runConfig.generate` 结果的显示子集。 -#[derive(Debug, Clone)] -pub struct RunConfigItem { - /// core 配置 id(本地文件项用配置名代替;`createLaunchPlan` - /// 可能因此找不到而走退化执行)。 - pub id: String, - pub name: String, - /// `mainClass`(java)|`maven`|`npm`。 - pub kind: String, - /// 副标题:主类 / 模块 / 脚本参数。 - pub detail: String, - pub main_class: Option, - /// java 源码路径(generate 结果的 `extensions.java.source`),单文件退化编译用。 - pub source: Option, -} - -/// 后台运行步骤(退化执行用,不经 core 常驻进程宿主)。 -#[derive(Debug, Clone)] -struct RunStep { - program: String, - args: Vec, - cwd: String, -} - -/// 进程 framing 行文案(`run_steps_blocking` 无 `cx`,调用方预取传入)。 -#[derive(Debug, Clone)] -struct StepText { - start_failed: String, - exit_code: String, - exited: String, - finished: String, -} - -impl StepText { - fn load(cx: &gpui_kit::App) -> Self { - Self { - start_failed: crate::i18n::menu_text(cx, "run.startFailed").to_string(), - exit_code: crate::i18n::menu_text(cx, "run.exitCode").to_string(), - exited: crate::i18n::menu_text(cx, "run.exited").to_string(), - finished: crate::i18n::menu_text(cx, "run.finished").to_string(), - } - } -} - /// 上次 Maven 目标参数(头部重跑按钮回放,对齐 Tauri `rerunLastTest` 语义的子集)。 #[derive(Debug, Clone)] struct MavenGoalParams { @@ -118,7 +80,10 @@ pub struct DiagnosticEntry { /// 诊断面板向外发出的定位事件。 #[derive(Debug, Clone)] pub enum BottomPanelEvent { - OpenFile { path: String, line: u32 }, + OpenFile { + path: String, + line: u32, + }, /// 面板清空按钮:请求宿主同步丢弃缓存的 LSP 诊断。 ClearDiagnostics, } @@ -145,14 +110,24 @@ pub struct BottomPanelView { pub(crate) run_state: RunProjectState, /// Run 面板工程展示名(工作区目录名)。 pub(crate) run_project_name: String, - /// Run 配置列表(本地文件优先,否则 generate 结果)。 + /// Run 配置列表(Core resolve 的有效配置)。 pub(crate) run_configs: Vec, + /// Core resolve 给出的默认配置 id。 + pub(crate) default_run_config: Option, /// 选中的 Run 配置 id。 pub(crate) selected_run_config: Option, + /// reload 尚未完成时,行内 Run 要在 ready 后启动的指定 id。 + pending_run_id: Option, + /// reload 完成后自动启动 Core 选出的配置。 + run_on_ready: bool, + /// Run/配置加载诊断,按 Core 返回顺序展示。 + pub(crate) run_diagnostics: Vec, /// 运行输出行(后台线程经 channel 推送,`cx.spawn` 泵入)。 pub(crate) run_output: Vec, - /// 是否有进程在跑。 + /// 是否有 Run execution 在准备或运行。 pub(crate) run_running: bool, + /// 最近一次 Run 主进程退出码。 + pub(crate) run_exit_code: Option, /// 输出跟随末尾(对齐 Tauri `scrollOutputToEnd`,默认开,落盘持久化)。 pub(crate) run_follow_end: bool, /// 运行输出滚动句柄(跟随末尾用)。 @@ -167,16 +142,18 @@ pub struct BottomPanelView { pub(crate) maven_running: bool, /// core 客户端(`reload_run_project` / `createLaunchPlan` 经它走 core JSON 命令)。 client: CoreClient, - /// 在跑子进程句柄:停止按钮经 `kill` 停,运行线程经 `try_wait` 短锁轮询收割。 - run_child: Arc>>, - /// Maven 任务子进程句柄(与 Run 共用停止语义,分开存放可各自启停)。 - maven_child: Arc>>, + /// 所有 Run/Maven 子进程的 session/execution 所有者。 + processes: ProcessManager, /// Maven 启动序号,丢弃过期 `launchPlan` 结果。 maven_seq: u64, /// 上次 Maven 目标参数(头部重跑按钮回放)。 last_maven_goal: Option, /// reload 序号,丢弃过期探测结果。 run_seq: u64, + /// Run execution 序号,阻止被替换的 session 继续写输出。 + run_execution_seq: u64, + /// 当前编辑器,Run 前用于保存脏文件。 + run_editor: Option>, /// 最近一次加载的 Git 提交记录。 pub git_log: Vec, /// Git 记录加载失败时的展示文案;成功后清空。 @@ -200,21 +177,27 @@ impl BottomPanelView { run_state: RunProjectState::Missing, run_project_name: crate::settings::project_dir_name(&working_dir).to_string(), run_configs: Vec::new(), + default_run_config: None, selected_run_config: None, + pending_run_id: None, + run_on_ready: false, + run_diagnostics: Vec::new(), run_output: Vec::new(), run_running: false, + run_exit_code: None, run_follow_end: crate::settings::get(cx).run_scroll_to_end, run_scroll: ScrollHandle::new(), run_followed_len: 0, maven_title: None, maven_output: Vec::new(), maven_running: false, - maven_child: Arc::new(Mutex::new(None)), + client: CoreClient::new(), + processes: ProcessManager::new(), maven_seq: 0, last_maven_goal: None, - client: CoreClient::new(), - run_child: Arc::new(Mutex::new(None)), run_seq: 0, + run_execution_seq: 0, + run_editor: None, git_log: Vec::new(), git_log_error: None, } @@ -360,85 +343,270 @@ impl BottomPanelView { /// 更新工作目录并重探 Run 工程(替代直接写 `working_dir` 字段)。 pub fn set_working_dir(&mut self, dir: String, cx: &mut Context) { + let _ = self.processes.request_stop("run", None); + self.maven_seq += 1; + let _ = self.processes.request_stop("maven", None); + self.maven_running = false; + self.pending_run_id = None; + self.run_on_ready = false; self.working_dir = dir; self.reload_run_project(cx); } - /// 重探 Run 工程:本地 `.lithe/run/configurations.json` 优先命中即 `Ready`; - /// 否则先 `maven.scan`(payload `{root, paths}`)探测工程类型,再 - /// `runConfig.generate`(payload `{root}`,返回 - /// `{generated: {configurations[]}, entryCount}`)取配置列表,失败即 `Failed`。 + /// 设置 Run 前需要保存的编辑器实体。 + pub fn set_run_editor(&mut self, editor: Option>, cx: &mut Context) { + self.run_editor = editor; + cx.notify(); + } + + /// 重探 Run 工程:先 inspect,再按需 generate,随后持久化并 resolve。 pub fn reload_run_project(&mut self, cx: &mut Context) { self.run_seq += 1; + self.run_execution_seq += 1; let seq = self.run_seq; - self.run_project_name = crate::settings::project_dir_name(&self.working_dir).to_string(); - let local = read_local_run_configs(&self.working_dir); - if !local.is_empty() { - self.run_configs = local; - self.keep_run_selection(); - self.run_state = RunProjectState::Ready; - cx.notify(); - return; + if self.run_running { + let _ = self.processes.request_stop("run", None); + self.run_running = false; } + self.run_project_name = crate::settings::project_dir_name(&self.working_dir).to_string(); + self.run_configs.clear(); + self.default_run_config = None; + self.selected_run_config = None; + self.run_diagnostics.clear(); self.run_state = RunProjectState::Loading; cx.notify(); + let client = self.client.clone(); let root = self.working_dir.clone(); cx.spawn(async move |this, cx| { - // 先探 Maven 工程(只作工程类型参考;失败不直接 Failed,交给 generate 定夺)。 - let _ = client + let inspection = client .execute::( &cx, - "maven.scan", - serde_json::json!({ "root": root, "paths": [] }), + "runConfig.inspect", + serde_json::json!({ "root": root, "checkFingerprint": true }), ) .await; - let generated = client + let inspection = match inspection { + Ok(value) => value, + Err(error) => { + let _ = this.update(cx, |view, cx| { + if view.run_seq == seq { + view.run_state = RunProjectState::Failed(error); + cx.notify(); + } + }); + return; + } + }; + let status = inspection + .get("status") + .and_then(serde_json::Value::as_str) + .unwrap_or("missing"); + let inspection_diagnostics = inspection + .get("diagnostics") + .and_then(serde_json::Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| { + item.get("message") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + .collect::>() + }) + .unwrap_or_default(); + let inspection_diagnostics = if status == "ready" { + inspection_diagnostics + } else { + Vec::new() + }; + if status != "ready" { + let paths = list_java_sources(&root); + let generated = client + .execute::( + &cx, + "runConfig.generate", + serde_json::json!({ + "root": root, + "paths": paths, + "modulePaths": [] + }), + ) + .await; + let generated = match generated { + Ok(value) => value, + Err(error) => { + let _ = this.update(cx, |view, cx| { + if view.run_seq == seq { + view.run_state = RunProjectState::Failed(error); + cx.notify(); + } + }); + return; + } + }; + if !this + .update(cx, |view, _cx| view.run_seq == seq) + .unwrap_or(false) + { + return; + } + let generated_document = generated.get("generated").cloned().unwrap_or_default(); + let requirements = generated + .get("toolchainRequirements") + .cloned() + .unwrap_or_else(|| serde_json::json!({"version": 1, "toolchains": {}})); + let default_id = default_generated_configuration_id(&generated_document); + if let Err(error) = write_generated_documents( + &root, + &generated_document, + &requirements, + default_id.as_deref(), + ) { + let _ = this.update(cx, |view, cx| { + if view.run_seq == seq { + view.run_state = RunProjectState::Failed(error); + cx.notify(); + } + }); + return; + } + if !this + .update(cx, |view, _cx| view.run_seq == seq) + .unwrap_or(false) + { + return; + } + } + + let toolchains = read_toolchain_paths(&root); + let candidates = toolchain_candidates(&root, &toolchains); + let resolved = client .execute::( &cx, - "runConfig.generate", - serde_json::json!({ "root": root }), + "runConfig.resolve", + serde_json::json!({ + "root": root, + "toolchainCandidates": candidates + }), ) .await; - let _ = this.update(cx, |view, cx| { - if view.run_seq != seq { + let resolved = match resolved { + Ok(value) => value, + Err(error) => { + let _ = this.update(cx, |view, cx| { + if view.run_seq == seq { + view.run_state = RunProjectState::Failed(error); + cx.notify(); + } + }); return; } - match generated { - Ok(value) => { - view.run_configs = parse_generated_configs(&value); - // 无 LSP 时 generate 为空:本地文本扫描 main 方法兜底 - //(对齐 Tauri 入口点发现,Linux 无 JDT 故用源码文本匹配)。 - if view.run_configs.is_empty() { - view.run_configs = scan_java_mains(&root); + }; + let parsed = match parse_resolved_configurations(&resolved) { + Ok(value) => value, + Err(error) => { + let _ = this.update(cx, |view, cx| { + if view.run_seq == seq { + view.run_state = RunProjectState::Failed(error); + cx.notify(); } - view.keep_run_selection(); - view.run_state = if view.run_configs.is_empty() { - RunProjectState::Missing - } else { - RunProjectState::Ready - }; - } - Err(err) => { - view.run_state = RunProjectState::Failed(err); - } + }); + return; } + }; + let launch = this.update(cx, |view, cx| { + if view.run_seq != seq { + return None; + } + view.run_configs = parsed.configurations; + view.default_run_config = parsed.default_configuration_id.clone(); + view.run_diagnostics = inspection_diagnostics.clone(); + view.run_diagnostics.extend(parsed.diagnostics.clone()); + view.run_diagnostics.dedup(); + let selected = view + .pending_run_id + .clone() + .filter(|id| view.run_configs.iter().any(|item| &item.id == id)) + .or_else(|| parsed.default_configuration_id.clone()) + .or_else(|| view.run_configs.first().map(|item| item.id.clone())); + view.selected_run_config = selected.clone(); + view.run_state = if view.run_configs.is_empty() { + RunProjectState::Missing + } else { + RunProjectState::Ready + }; + let should_launch = view.run_on_ready || view.pending_run_id.is_some(); + view.run_on_ready = false; cx.notify(); + let launch_id = view + .pending_run_id + .take() + .or_else(|| should_launch.then(|| selected.clone()).flatten()); + launch_id.and_then(|id| view.run_configs.iter().find(|item| item.id == id).cloned()) }); + if let Ok(Some(item)) = launch { + let _ = this.update(cx, |view, cx| view.start_run(item, cx)); + } }) .detach(); } - /// 运行选中配置;运行中则改为停止。 + /// 顶部 Run 只在当前 reload 结果 ready 后使用默认/当前选择。 pub fn run_selected_config(&mut self, cx: &mut Context) { if self.run_running { self.stop_running(cx); return; } - let Some(item) = self.selected_run_item() else { + if !matches!(self.run_state, RunProjectState::Ready) { + self.run_on_ready = true; + if !matches!(self.run_state, RunProjectState::Loading) { + self.reload_run_project(cx); + } else { + cx.notify(); + } return; - }; + } + if let Some(item) = self.selected_run_item() { + self.start_run(item, cx); + } else { + self.run_on_ready = true; + self.reload_run_project(cx); + } + } + + /// 行内 Run 永远携带用户指定的配置 id;配置尚未 ready 时等待 reload。 + pub fn run_configuration_id(&mut self, id: String, cx: &mut Context) { + if self.run_running { + return; + } + self.pending_run_id = Some(id.clone()); + self.selected_run_config = Some(id.clone()); + self.run_on_ready = true; + if matches!(self.run_state, RunProjectState::Ready) + && self.run_configs.iter().any(|item| item.id == id) + { + self.pending_run_id = None; + self.run_on_ready = false; + if let Some(item) = self.run_configs.iter().find(|item| item.id == id).cloned() { + self.start_run(item, cx); + } + } else if !matches!(self.run_state, RunProjectState::Loading) { + self.reload_run_project(cx); + } else { + cx.notify(); + } + } + + fn start_run(&mut self, item: RunConfigItem, cx: &mut Context) { + if self.run_running { + return; + } + self.run_execution_seq += 1; + let execution_seq = self.run_execution_seq; self.run_running = true; + self.run_exit_code = None; push_run_line( &mut self.run_output, format!( @@ -448,122 +616,256 @@ impl BottomPanelView { ), ); cx.notify(); - let (tx, rx) = mpsc::channel::(); - let child_slot = self.run_child.clone(); - let text = StepText::load(cx); + let launch_seq = self.run_seq; + let launch_execution_seq = execution_seq; let client = self.client.clone(); let root = self.working_dir.clone(); + let editor = self.run_editor.clone(); + let processes = self.processes.clone(); cx.spawn(async move |this, cx| { - // 先问 core 要可执行计划(payload `{root, configurationId}`,返回 - // `{executable, arguments, workingDirectory}`);本地配置或工具链 - // 未解析时拿不到可直跑的命令,退化到按 kind 拼的本地命令。 + if let Some(editor) = editor.as_ref() { + let save = editor.update(cx, |editor, cx| editor.save_active_task(cx)); + if let Err(error) = save.await { + let _ = this.update(cx, |view, cx| { + if view.is_current_run_execution(launch_seq, launch_execution_seq) { + view.run_running = false; + push_run_line(&mut view.run_output, error); + cx.notify(); + } + }); + return; + } + } + if this + .update(cx, |view, _cx| { + !view.is_current_run_execution(launch_seq, launch_execution_seq) + }) + .unwrap_or(true) + { + return; + } + let current_file = editor + .as_ref() + .and_then(|editor| editor.update(cx, |editor, _cx| editor.active_file_path())); + let current_file = current_file.and_then(|path| workspace_relative_path(&root, &path)); + let mut toolchains = read_toolchain_paths(&root); + if let Some(path) = item.java_home_path() { + toolchains.java_home_path = path.to_string(); + } + if let Some(path) = item.maven_executable_path() { + toolchains.maven_executable_path = path.to_string(); + } + if let Some(path) = item.maven_java_home_path() { + toolchains.maven_java_home_path = path.to_string(); + } + let maven_context = item + .uses_maven() + .then(|| maven_context_for_configuration(&item, &toolchains)); + let payload = create_launch_plan_request( + &root, + &item, + current_file.as_deref(), + maven_context.as_ref(), + None, + ); let plan = client .execute::( &cx, "runConfig.createLaunchPlan", - serde_json::json!({ "root": root, "configurationId": item.id }), + payload, ) - .await - .ok(); - let steps = plan_to_steps(plan.as_ref(), &item, &root); - let started = this.update(cx, |view, cx| { - if steps.is_empty() { - view.run_running = false; - push_run_line( - &mut view.run_output, - crate::i18n::menu_text(cx, "run.noSteps").to_string(), - ); - cx.notify(); - return false; + .await; + if this + .update(cx, |view, _cx| { + !view.is_current_run_execution(launch_seq, launch_execution_seq) + }) + .unwrap_or(true) + { + return; + } + let plan = match plan { + Ok(value) => value, + Err(error) => { + let _ = this.update(cx, |view, cx| { + if view.is_current_run_execution(launch_seq, launch_execution_seq) { + view.run_running = false; + push_run_line(&mut view.run_output, error); + cx.notify(); + } + }); + return; } - let display = format!("$ {} {}", steps[0].program, steps[0].args.join(" ")); - push_run_line(&mut view.run_output, display.clone()); - view.record_run(&display, cx); - cx.notify(); - true - }); - if !matches!(started, Ok(true)) { + }; + let plan = match LaunchPlan::from_value(&plan) { + Ok(plan) => plan, + Err(error) => { + let _ = this.update(cx, |view, cx| { + if view.is_current_run_execution(launch_seq, launch_execution_seq) { + view.run_running = false; + push_run_line(&mut view.run_output, error); + cx.notify(); + } + }); + return; + } + }; + let (steps, _main) = + match crate::workbench::run::process::resolve_launch(&plan, &root, &toolchains) { + Ok(value) => value, + Err(error) => { + let _ = this.update(cx, |view, cx| { + if view.is_current_run_execution(launch_seq, launch_execution_seq) { + view.run_running = false; + push_run_line(&mut view.run_output, error); + cx.notify(); + } + }); + return; + } + }; + if this + .update(cx, |view, _cx| { + !view.is_current_run_execution(launch_seq, launch_execution_seq) + }) + .unwrap_or(true) + { return; } - std::thread::spawn(move || run_steps_blocking(steps, child_slot, text, tx)); - // 输出泵:每行经 background 线程阻塞收,再回到主线程落盘展示。 - let rx = Arc::new(Mutex::new(rx)); + let execution_id = uuid::Uuid::new_v4().to_string(); + let (sender, receiver) = mpsc::channel::(); + let handle = match processes.start("run", &execution_id, steps, sender) { + Ok(handle) => handle, + Err(error) => { + let _ = this.update(cx, |view, cx| { + if view.is_current_run_execution(launch_seq, launch_execution_seq) { + view.run_running = false; + push_run_line(&mut view.run_output, error); + cx.notify(); + } + }); + return; + } + }; + let receiver = std::sync::Arc::new(std::sync::Mutex::new(receiver)); loop { - let slot = rx.clone(); - let next = cx + let slot = receiver.clone(); + let event = cx .background_executor() .spawn(async move { let guard = slot.lock().ok()?; - guard.recv().ok() + Some( + match guard.recv_timeout(std::time::Duration::from_secs(1)) { + Ok(event) => Ok(event), + Err(error) => Err(error), + }, + ) }) .await; - match next { - Some(line) => { - if this - .update(cx, |view, cx| { - push_run_line(&mut view.run_output, line); - cx.notify(); - }) - .is_err() - { - break; + let event = match event { + Some(Ok(event)) => event, + Some(Err(mpsc::RecvTimeoutError::Timeout)) => continue, + Some(Err(mpsc::RecvTimeoutError::Disconnected)) | None => break, + }; + let finished = matches!(&event, ProcessEvent::Finished { .. }); + let stale = this + .update(cx, |view, _cx| { + !view.is_current_run_execution(launch_seq, launch_execution_seq) + }) + .unwrap_or(true); + if stale { + let _ = processes.stop("run", Some(&execution_id)); + break; + } + let _ = this.update(cx, |view, cx| match event { + ProcessEvent::Started { + index: _index, + label, + } => { + push_run_line(&mut view.run_output, label.clone()); + view.record_run(&label, cx); + cx.notify(); + } + ProcessEvent::Output { stream, text } => { + let text = if stream == OutputStream::Stderr { + format!("[stderr] {text}") + } else { + text + }; + push_run_line(&mut view.run_output, text); + cx.notify(); + } + ProcessEvent::Finished { + exit_code, + cancelled, + error, + } => { + if let Some(error) = error { + push_run_line(&mut view.run_output, error); + } + let exit_code = exit_code.or_else(|| Some(1)); + if cancelled { + push_run_line( + &mut view.run_output, + crate::i18n::menu_text(cx, "run.finished").to_string(), + ); + } else { + push_run_line( + &mut view.run_output, + format!( + "{}:{}", + crate::i18n::menu_text(cx, "run.exited"), + exit_code.unwrap_or(1) + ), + ); } + view.run_exit_code = exit_code; + view.run_running = false; + cx.notify(); } - None => break, + }); + if finished { + break; } } - let _ = this.update(cx, |view, cx| { - view.run_running = false; - cx.notify(); - }); + let _ = handle.join(); }) .detach(); } - /// 停止在跑进程(Run 与 Maven 各自 kill;mvn 拉起的 java 孙进程不在此列)。 + /// 停止在跑进程;ProcessManager 异步终止完整进程树并在输出泵中回收。 pub fn stop_running(&mut self, cx: &mut Context) { let mut stopped = false; - if let Ok(mut slot) = self.run_child.lock() { - if let Some(child) = slot.as_mut() { - let _ = child.kill(); - stopped = true; - } - } - if let Ok(mut slot) = self.maven_child.lock() { - if let Some(child) = slot.as_mut() { - let _ = child.kill(); - stopped = true; - } - } if self.run_running { + self.run_execution_seq += 1; + let _ = self.processes.request_stop("run", None); push_run_line( &mut self.run_output, crate::i18n::menu_text(cx, "run.stopping").to_string(), ); + self.run_running = false; stopped = true; } if self.maven_running { + self.maven_seq += 1; + let _ = self.processes.request_stop("maven", None); push_run_line( &mut self.maven_output, crate::i18n::menu_text(cx, "run.stopping").to_string(), ); + self.maven_running = false; stopped = true; } if stopped { cx.notify(); } } - /// 是否发生过 Maven 运行;宿主据此决定左侧栏是否展示 maven 项 ///(对齐 Tauri `hasMavenRun`:任务跑过即真,与终端历史无关)。 pub fn has_maven_run(&self) -> bool { self.maven_running || !self.maven_output.is_empty() } - /// 运行 Maven 目标:切 Maven 页 + 经 core `maven.launchPlan` 拿确定性参数 - ///(payload `{root, context: {version: 1, reactorPath: ".", profiles, skipTests}, module, goals}`), - /// 起受管进程并把输出泵入 Maven 页(首行 `$ mvn …`,对齐 Tauri Maven 页)。 - /// 已有任务在跑时先停掉(对齐 Tauri 停掉上一个 session)。 + /// 运行 Maven 目标:Core 生成计划,Linux 只解析工具链并托管进程。 pub fn run_maven_goal( &mut self, pom_path: &str, @@ -578,11 +880,7 @@ impl BottomPanelView { return; } if self.maven_running { - if let Ok(mut slot) = self.maven_child.lock() { - if let Some(child) = slot.as_mut() { - let _ = child.kill(); - } - } + let _ = self.processes.request_stop("maven", None); } self.maven_seq += 1; let seq = self.maven_seq; @@ -596,7 +894,7 @@ impl BottomPanelView { }); self.active_tab = BottomTab::Maven; self.is_collapsed = false; - self.maven_title = Some(title.clone()); + self.maven_title = Some(title); self.maven_output.clear(); self.maven_running = true; cx.notify(); @@ -605,8 +903,7 @@ impl BottomPanelView { let root = self.working_dir.clone(); let module = maven_module_for_pom(&self.working_dir, pom_path); let profiles = profiles.to_vec(); - let text = StepText::load(cx); - let child_slot = self.maven_child.clone(); + let processes = self.processes.clone(); cx.spawn(async move |this, cx| { let plan = client .execute::( @@ -624,63 +921,141 @@ impl BottomPanelView { "goals": [goal], }), ) - .await - .ok(); - let (program, args, cwd) = maven_plan_to_step(plan.as_ref(), &root, &goal); - let display = format!("$ {} {}", program, args.join(" ")); - let started = this.update(cx, |view, cx| { - if view.maven_seq != seq { - return false; + .await; + if this + .update(cx, |view, _cx| view.maven_seq != seq) + .unwrap_or(true) + { + return; + } + let plan = match plan { + Ok(value) => value, + Err(error) => { + let _ = this.update(cx, |view, cx| { + if view.maven_seq == seq { + view.maven_running = false; + push_run_line(&mut view.maven_output, error); + cx.notify(); + } + }); + return; } - push_run_line(&mut view.maven_output, display.clone()); - view.record_run(&display, cx); - cx.notify(); - true - }); - if !matches!(started, Ok(true)) { + }; + let plan = match LaunchPlan::from_value(&plan) { + Ok(plan) => plan, + Err(error) => { + let _ = this.update(cx, |view, cx| { + if view.maven_seq == seq { + view.maven_running = false; + push_run_line(&mut view.maven_output, error); + cx.notify(); + } + }); + return; + } + }; + let toolchains = read_toolchain_paths(&root); + let (steps, _main) = + match crate::workbench::run::process::resolve_launch(&plan, &root, &toolchains) { + Ok(value) => value, + Err(error) => { + let _ = this.update(cx, |view, cx| { + if view.maven_seq == seq { + view.maven_running = false; + push_run_line(&mut view.maven_output, error); + cx.notify(); + } + }); + return; + } + }; + if this + .update(cx, |view, _cx| view.maven_seq != seq) + .unwrap_or(true) + { return; } - let steps = vec![RunStep { program, args, cwd }]; - let (tx, rx) = mpsc::channel::(); - std::thread::spawn(move || run_steps_blocking(steps, child_slot, text, tx)); - let rx = Arc::new(Mutex::new(rx)); + let execution_id = uuid::Uuid::new_v4().to_string(); + let (sender, receiver) = mpsc::channel::(); + let handle = match processes.start("maven", &execution_id, steps, sender) { + Ok(handle) => handle, + Err(error) => { + let _ = this.update(cx, |view, cx| { + if view.maven_seq == seq { + view.maven_running = false; + push_run_line(&mut view.maven_output, error); + cx.notify(); + } + }); + return; + } + }; + let receiver = std::sync::Arc::new(std::sync::Mutex::new(receiver)); loop { - let slot = rx.clone(); - let next = cx + let slot = receiver.clone(); + let event = cx .background_executor() .spawn(async move { let guard = slot.lock().ok()?; - guard.recv().ok() + Some( + match guard.recv_timeout(std::time::Duration::from_secs(1)) { + Ok(event) => Ok(event), + Err(error) => Err(error), + }, + ) }) .await; - match next { - Some(line) => { - // `run_steps_blocking` 首行会再发一次 `$ …`,Maven 页已有 - // 展示首行,跳过重复(对齐 Tauri 单首行)。 - let skip = this - .update(cx, |view, cx| { - let duplicate = line.trim_start().starts_with("$ ") - && !view.maven_output.is_empty(); - if !duplicate { - push_run_line(&mut view.maven_output, line); - } - cx.notify(); - view.maven_seq == seq - }) - .unwrap_or(false); - if !skip { - break; + let event = match event { + Some(Ok(event)) => event, + Some(Err(mpsc::RecvTimeoutError::Timeout)) => continue, + Some(Err(mpsc::RecvTimeoutError::Disconnected)) | None => break, + }; + let finished = matches!(&event, ProcessEvent::Finished { .. }); + let _ = this.update(cx, |view, cx| { + if view.maven_seq != seq { + return; + } + match event { + ProcessEvent::Started { label, .. } => { + push_run_line(&mut view.maven_output, label.clone()); + view.record_run(&label, cx); + } + ProcessEvent::Output { text, .. } => { + push_run_line(&mut view.maven_output, text); + } + ProcessEvent::Finished { + exit_code, + cancelled, + error, + } => { + if let Some(error) = error { + push_run_line(&mut view.maven_output, error); + } + if let Some(code) = exit_code { + push_run_line( + &mut view.maven_output, + format!( + "{}:{}", + crate::i18n::menu_text(cx, "run.exited"), + code + ), + ); + } else if cancelled { + push_run_line( + &mut view.maven_output, + crate::i18n::menu_text(cx, "run.finished").to_string(), + ); + } + view.maven_running = false; } } - None => break, + cx.notify(); + }); + if finished { + break; } } - let _ = this.update(cx, |view, cx| { - if view.maven_seq == seq { - view.maven_running = false; - } - cx.notify(); - }); + let _ = handle.join(); }) .detach(); } @@ -703,23 +1078,16 @@ impl BottomPanelView { } } - /// 当前选中项(无选中或已失效时回退首项)。 + fn is_current_run_execution(&self, reload_seq: u64, execution_seq: u64) -> bool { + sequence_is_current(self.run_seq, reload_seq) + && sequence_is_current(self.run_execution_seq, execution_seq) + } + + /// 当前选中项只按稳定 id 查找,不在 reload 后回退到旧列表。 fn selected_run_item(&self) -> Option { self.selected_run_config .as_deref() - .and_then(|id| self.run_configs.iter().find(|c| c.id == id).cloned()) - .or_else(|| self.run_configs.first().cloned()) - } - - /// 保持选中有效:原选中仍在则不动,否则选首项。 - fn keep_run_selection(&mut self) { - let keep = self - .selected_run_config - .as_ref() - .is_some_and(|id| self.run_configs.iter().any(|c| &c.id == id)); - if !keep { - self.selected_run_config = self.run_configs.first().map(|c| c.id.clone()); - } + .and_then(|id| self.run_configs.iter().find(|item| item.id == id).cloned()) } /// 重新加载 Git 提交记录。core 的 `git.historyPage` 需经宿主异步接线, @@ -998,8 +1366,6 @@ impl BottomPanelView { .primary() .label(run_ui_text(cx, "生成配置", "Generate")) .on_click(cx.listener(|this, _event, _window, cx| { - let root = this.working_dir.clone(); - let _ = append_default_run_config(&root); this.reload_run_project(cx); })), ) @@ -1008,6 +1374,19 @@ impl BottomPanelView { } } + let diagnostic_rows: Vec = self + .run_diagnostics + .iter() + .map(|message| { + div() + .px_2() + .py_1() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(message.clone()) + .into_any_element() + }) + .collect(); let rows: Vec = self .run_configs .iter() @@ -1054,8 +1433,7 @@ impl BottomPanelView { .icon(IconName::Play) .tooltip(run_ui_text(cx, "运行", "Run")) .on_click(cx.listener(move |this, _event, _window, cx| { - this.selected_run_config = Some(run_id.clone()); - this.run_selected_config(cx); + this.run_configuration_id(run_id.clone(), cx); })), ) .on_click(cx.listener(move |this, _event, _window, cx| { @@ -1103,7 +1481,8 @@ impl BottomPanelView { .border_r_1() .border_color(ThemeColors::border()) .py_1() - .children(rows), + .children(rows) + .children(diagnostic_rows), ) .child( div() @@ -1346,297 +1725,23 @@ fn load_git_log(workdir: &str) -> Result, String> { Ok(entries) } -/// 读本地运行配置(`/.lithe/run/configurations.json`),兼容数组与 -/// `{configurations: []}` 两种形状;字段沿用 v1 形态(`name/type/mainClass`)。 -fn read_local_run_configs(workspace_root: &str) -> Vec { - let path = std::path::Path::new(workspace_root) - .join(".lithe") - .join("run") - .join("configurations.json"); - let Ok(text) = std::fs::read_to_string(&path) else { - return Vec::new(); - }; - let Ok(value) = serde_json::from_str::(&text) else { - return Vec::new(); - }; - let arr = if let Some(arr) = value.as_array() { - arr.clone() - } else if let Some(arr) = value.get("configurations").and_then(|v| v.as_array()) { - arr.clone() +fn workspace_relative_path(root: &str, path: &str) -> Option { + let root = std::path::Path::new(root); + let path = std::path::Path::new(path); + let relative = if path.is_absolute() { + path.strip_prefix(root).ok()? } else { - return Vec::new(); + path }; - arr.iter() - .filter_map(|item| { - let name = item.get("name")?.as_str()?; - let kind_raw = item.get("type").and_then(|t| t.as_str()).unwrap_or("java"); - let main_class = item - .get("mainClass") - .and_then(|m| m.as_str()) - .map(str::to_string); - let kind = if kind_raw.contains("npm") || kind_raw.contains("node") { - "npm" - } else if kind_raw.contains("maven") { - "maven" - } else { - "mainClass" - }; - let detail = main_class.clone().unwrap_or_else(|| kind_raw.to_string()); - Some(RunConfigItem { - id: name.to_string(), - name: name.to_string(), - kind: kind.to_string(), - detail, - main_class, - source: None, - }) - }) - .collect() -} - -/// 追加一个默认 Java 运行配置(保留已有配置),供 Missing 空态的生成按钮用。 -fn append_default_run_config(workspace_root: &str) -> bool { - let path = std::path::Path::new(workspace_root) - .join(".lithe") - .join("run") - .join("configurations.json"); - if let Some(parent) = path.parent() { - if std::fs::create_dir_all(parent).is_err() { - return false; - } - } - let mut list: Vec = std::fs::read_to_string(&path) - .ok() - .and_then(|text| serde_json::from_str::(&text).ok()) - .and_then(|v| { - if let Some(arr) = v.as_array() { - Some(arr.clone()) - } else { - v.get("configurations").and_then(|c| c.as_array()).cloned() - } - }) - .unwrap_or_default(); - list.push(serde_json::json!({"name": "Run Main", "type": "java", "mainClass": "Main"})); - let value = serde_json::json!({"configurations": list}); - serde_json::to_string_pretty(&value) - .ok() - .and_then(|text| std::fs::write(&path, text).ok()) - .is_some() + Some(relative.to_string_lossy().replace('\\', "/")) } -/// 解析 `runConfig.generate` 返回的 `{generated: {configurations[]}}`。 -/// 本地 Java 入口扫描:无 LSP 时的兜底(对齐 Tauri `discoverJavaEntrypoints`, -/// Linux 无 JDT 故用源码文本匹配 `public static void main`)。 -/// 主类由 `package` 声明 + 文件名推导;上限 20 个,按路径排序保证稳定。 -fn scan_java_mains(root: &str) -> Vec { - const MAX_MAINS: usize = 20; - const MAX_DEPTH: usize = 8; - const SKIP_DIRS: &[&str] = &[ - "target", - "build", - "out", - "dist", - "node_modules", - ".git", - ".idea", - ".vscode", - "vendor", - ]; - const MAX_FILE_BYTES: u64 = 512 * 1024; - - fn walk(dir: &std::path::Path, depth: usize, out: &mut Vec) { - if depth > MAX_DEPTH || out.len() >= MAX_MAINS * 4 { - return; - } - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - let mut entries: Vec<_> = entries.filter_map(|e| e.ok()).collect(); - entries.sort_by_key(|e| e.file_name()); - for entry in entries { - let path = entry.path(); - let name = entry.file_name().to_string_lossy().to_string(); - if path.is_dir() { - if !name.starts_with('.') && !SKIP_DIRS.contains(&name.as_str()) { - walk(&path, depth + 1, out); - } - } else if name.ends_with(".java") { - out.push(path); - } - } - } - - fn package_of(text: &str) -> Option { - text.lines().find_map(|line| { - let line = line.trim(); - line.strip_prefix("package ") - .and_then(|rest| rest.strip_suffix(';')) - .map(|pkg| pkg.trim().to_string()) - .filter(|pkg| { - !pkg.is_empty() - && pkg - .chars() - .all(|c| c.is_alphanumeric() || c == '.' || c == '_') - }) - }) - } - - let root_path = std::path::Path::new(root); - let mut files = Vec::new(); - walk(root_path, 0, &mut files); - let mut items = Vec::new(); - for path in files { - if items.len() >= MAX_MAINS { - break; - } - if std::fs::metadata(&path).is_ok_and(|m| m.len() > MAX_FILE_BYTES) { - continue; - } - let Ok(text) = std::fs::read_to_string(&path) else { - continue; - }; - if !text.contains("public static void main") { - continue; - } - let stem = path - .file_stem() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_default(); - if stem.is_empty() { - continue; - } - let main_class = match package_of(&text) { - Some(pkg) => format!("{pkg}.{stem}"), - None => stem, - }; - let source = path - .strip_prefix(root_path) - .map(|p| p.to_string_lossy().replace('\\', "/")) - .unwrap_or_default(); - items.push(RunConfigItem { - id: format!("local-main:{main_class}"), - name: main_class.clone(), - kind: "mainClass".to_string(), - detail: main_class.clone(), - main_class: Some(main_class), - source: Some(source), - }); - } - items.sort_by(|a, b| a.name.cmp(&b.name)); - items -} - -fn parse_generated_configs(value: &serde_json::Value) -> Vec { - value - .get("generated") - .and_then(|g| g.get("configurations")) - .and_then(|c| c.as_array()) - .map(|arr| arr.iter().filter_map(config_item_from_generated).collect()) - .unwrap_or_default() -} - -fn config_item_from_generated(item: &serde_json::Value) -> Option { - let id = item.get("id")?.as_str()?; - let name = item.get("name").and_then(|n| n.as_str()).unwrap_or(id); - let provider = item.get("provider").and_then(|p| p.as_str()).unwrap_or(""); - let command = item.get("command").and_then(|c| c.as_str()).unwrap_or(""); - let maven = &item["extensions"]["maven"]; - let main_class = maven - .get("mainClass") - .and_then(|m| m.as_str()) - .map(str::to_string); - let module = maven - .get("module") - .and_then(|m| m.as_str()) - .map(str::to_string); - let source = item - .get("extensions") - .and_then(|e| e.get("java")) - .and_then(|j| j.get("source")) - .and_then(|s| s.as_str()) - .map(str::to_string); - let kind = if provider.contains("npm") || provider.contains("node") || command == "npm" { - "npm" - } else if provider.contains("maven") { - "maven" - } else { - "mainClass" - }; - let detail = if kind == "npm" { - item.get("args") - .and_then(|a| a.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str()) - .collect::>() - .join(" ") - }) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "run dev".to_string()) - } else { - main_class - .clone() - .or(module.clone()) - .unwrap_or_else(|| provider.to_string()) - }; - Some(RunConfigItem { - id: id.to_string(), - name: name.to_string(), - kind: kind.to_string(), - detail, - main_class, - source, - }) -} - -/// 由 `createLaunchPlan` 结果拼直跑步骤:只有 `executable.command` -/// 是真实可执行路径;`executable.toolchain` 需宿主解析,拿不到就走退化。 -fn plan_to_steps( - plan: Option<&serde_json::Value>, - item: &RunConfigItem, - root: &str, -) -> Vec { - if let Some(plan) = plan { - let cwd = plan - .get("workingDirectory") - .and_then(|v| v.as_str()) - .unwrap_or("."); - let cwd = if cwd == "." || cwd.is_empty() { - root.to_string() - } else { - format!("{root}/{cwd}") - }; - if let Some(command) = plan - .get("executable") - .and_then(|e| e.get("command")) - .and_then(|c| c.as_str()) - .filter(|c| !c.is_empty()) - { - let args = plan - .get("arguments") - .and_then(|a| a.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(str::to_string)) - .collect() - }) - .unwrap_or_default(); - return vec![RunStep { - program: command.to_string(), - args, - cwd, - }]; - } - } - fallback_run_steps(item, root) -} - -/// pom 所在目录相对 root 的模块路径:根 pom 为 `.`,与 core 模块约定一致。 +/// pom 所在目录相对 root 的模块路径:根 pom 为 `.`。 fn maven_module_for_pom(root: &str, pom: &str) -> String { let relative = std::path::Path::new(pom) .parent() - .and_then(|dir| dir.strip_prefix(root).ok()) - .map(|rel| rel.to_string_lossy().replace('\\', "/")) + .and_then(|directory| directory.strip_prefix(root).ok()) + .map(|relative| relative.to_string_lossy().replace('\\', "/")) .unwrap_or_default(); let trimmed = relative.trim_matches('/').to_string(); if trimmed.is_empty() { @@ -1646,228 +1751,6 @@ fn maven_module_for_pom(root: &str, pom: &str) -> String { } } -/// `maven.launchPlan`(`{executable.toolchain, arguments[], workingDirectory}`) -/// 转本地执行步骤;拿不到计划时退化为 `mvn `。 -fn maven_plan_to_step( - plan: Option<&serde_json::Value>, - root: &str, - goal: &str, -) -> (String, Vec, String) { - if let Some(plan) = plan { - let cwd = plan - .get("workingDirectory") - .and_then(|v| v.as_str()) - .unwrap_or("."); - let cwd = if cwd == "." || cwd.is_empty() { - root.to_string() - } else { - format!("{root}/{cwd}") - }; - let args = plan - .get("arguments") - .and_then(|a| a.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(str::to_string)) - .collect::>() - }) - .filter(|args| !args.is_empty()); - if let Some(args) = args { - return (resolve_maven_executable(root), args, cwd); - } - } - ( - resolve_maven_executable(root), - vec![goal.to_string()], - root.to_string(), - ) -} - -/// 退化执行规则:maven 用 mvn 跑 `compile exec:java`;npm 跑 `run dev`; -/// java 单文件走 javac+java,否则直跑主类。 -fn fallback_run_steps(item: &RunConfigItem, root: &str) -> Vec { - match item.kind.as_str() { - "maven" => { - let mut args = vec!["-B".to_string(), "-ntp".to_string(), "compile".to_string()]; - if let Some(main) = item.main_class.as_deref().filter(|s| !s.is_empty()) { - args.push("exec:java".to_string()); - args.push(format!("-Dexec.mainClass={main}")); - } - vec![RunStep { - program: resolve_maven_executable(root), - args, - cwd: root.to_string(), - }] - } - "npm" => vec![RunStep { - program: "npm".to_string(), - args: vec!["run".to_string(), pick_npm_script(root)], - cwd: root.to_string(), - }], - _ => { - let classes = format!("{root}/.lithe/run/classes"); - if let (Some(source), Some(main)) = (item.source.as_deref(), item.main_class.as_deref()) - { - let abs = format!("{root}/{source}"); - if !source.is_empty() && !main.is_empty() && std::path::Path::new(&abs).is_file() { - let _ = std::fs::create_dir_all(&classes); - return vec![ - RunStep { - program: "javac".to_string(), - args: vec!["-d".to_string(), classes.clone(), abs], - cwd: root.to_string(), - }, - RunStep { - program: "java".to_string(), - args: vec!["-cp".to_string(), classes, main.to_string()], - cwd: root.to_string(), - }, - ]; - } - } - let main = item.main_class.clone().unwrap_or_else(|| item.name.clone()); - vec![RunStep { - program: "java".to_string(), - args: vec![main], - cwd: root.to_string(), - }] - } - } -} - -/// mvn 路径:先看 `.lithe/run/local.json` 的 `mavenExecutablePath` -///(含 `toolchain` 下),没有就用 `PATH` 里的 `mvn`。 -fn resolve_maven_executable(root: &str) -> String { - let path = std::path::Path::new(root) - .join(".lithe") - .join("run") - .join("local.json"); - let from_file = std::fs::read_to_string(&path) - .ok() - .and_then(|text| serde_json::from_str::(&text).ok()) - .and_then(|value| { - value - .get("mavenExecutablePath") - .or_else(|| { - value - .get("toolchain") - .and_then(|t| t.get("mavenExecutablePath")) - }) - .and_then(|v| v.as_str()) - .map(str::to_string) - }) - .filter(|s| !s.trim().is_empty()); - from_file.unwrap_or_else(|| "mvn".to_string()) -} - -/// npm 脚本选择:`package.json` 有 dev 用 dev,否则 start,再没有还用 dev(报错由输出展示)。 -fn pick_npm_script(root: &str) -> String { - let has = |name: &str, value: &serde_json::Value| { - value.get("scripts").and_then(|s| s.get(name)).is_some() - }; - std::fs::read_to_string(std::path::Path::new(root).join("package.json")) - .ok() - .and_then(|text| serde_json::from_str::(&text).ok()) - .map(|value| { - if has("dev", &value) { - "dev".to_string() - } else if has("start", &value) { - "start".to_string() - } else { - "dev".to_string() - } - }) - .unwrap_or_else(|| "dev".to_string()) -} - -/// 顺序跑步骤并把输出行推入 channel;首个失败步骤后停。stdout/stderr 各一根 -/// 转发线程;子进程句柄共享给停止按钮,`try_wait` 短锁轮询避免 `wait` -/// 占锁导致 kill 拿不到锁。 -fn run_steps_blocking( - steps: Vec, - child_slot: Arc>>, - text: StepText, - tx: mpsc::Sender, -) { - use std::io::BufRead as _; - for step in &steps { - let _ = tx.send(format!("$ {} {}", step.program, step.args.join(" "))); - let mut child = match std::process::Command::new(&step.program) - .args(&step.args) - .current_dir(&step.cwd) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - { - Ok(child) => child, - Err(err) => { - let _ = tx.send(format!("{}:{err}", text.start_failed)); - return; - } - }; - let stdout = child.stdout.take(); - let stderr = child.stderr.take(); - if let Ok(mut slot) = child_slot.lock() { - *slot = Some(child); - } - let tx_out = tx.clone(); - let out_handle = stdout.map(|out| { - std::thread::spawn(move || { - for line in std::io::BufReader::new(out).lines().map_while(Result::ok) { - if tx_out.send(line).is_err() { - break; - } - } - }) - }); - let tx_err = tx.clone(); - let err_handle = stderr.map(|err| { - std::thread::spawn(move || { - for line in std::io::BufReader::new(err).lines().map_while(Result::ok) { - if tx_err.send(line).is_err() { - break; - } - } - }) - }); - let status = loop { - let Ok(mut slot) = child_slot.lock() else { - break None; - }; - match slot.as_mut().map(|child| child.try_wait()) { - Some(Ok(Some(status))) => break Some(status), - Some(Ok(None)) => { - drop(slot); - std::thread::sleep(std::time::Duration::from_millis(50)); - } - _ => break None, - } - }; - if let Some(handle) = out_handle { - let _ = handle.join(); - } - if let Some(handle) = err_handle { - let _ = handle.join(); - } - if let Ok(mut slot) = child_slot.lock() { - let _ = slot.take(); - } - match status { - Some(status) if status.success() => { - let _ = tx.send(format!("{}:0", text.exit_code)); - } - Some(status) => { - let _ = tx.send(format!("{}:{status}", text.exited)); - return; - } - None => { - let _ = tx.send(text.finished.clone()); - return; - } - } - } -} - /// 行首空白转不换行空格:GPUI `Normal` 会塌缩连续空白(Tauri `pre-wrap` /// 保留),只转行首以兼顾缩进保留与行内换行。 fn preserve_leading_whitespace(line: &str) -> String { diff --git a/linux/src/workbench/editor.rs b/linux/src/workbench/editor.rs index 10e5fbbdf..d40a745b4 100644 --- a/linux/src/workbench/editor.rs +++ b/linux/src/workbench/editor.rs @@ -247,38 +247,51 @@ impl EditorView { cx.notify(); } - /// 保存当前活动的标签页至磁盘(对接 `file.write`) + /// 返回当前活动文件路径,供 Run 构造 Current File 请求。 + pub fn active_file_path(&self) -> Option { + let index = self.active_tab_index?; + self.tabs.get(index).map(|tab| tab.path.clone()) + } + + /// 保存当前活动的标签页至磁盘(对接 `file.write`)。 pub fn save_active(&mut self, cx: &mut Context) { + self.save_active_task(cx).detach(); + } + + /// Run 前等待保存完成;错误原样返回给启动工作流。 + pub fn save_active_task( + &mut self, + cx: &mut Context, + ) -> gpui_kit::Task> { let Some(idx) = self.active_tab_index else { - return; + return cx.spawn(async move |_this, _cx| Ok(())); }; let Some(tab) = self.tabs.get(idx) else { - return; + return cx.spawn(async move |_this, _cx| Ok(())); }; + if !tab.is_dirty { + return cx.spawn(async move |_this, _cx| Ok(())); + } // 以编辑器内的实时文本为准,避免依赖事件回写的时序。 let text = self.editor_state.read(cx).value().to_string(); - let root = self.workspace_root.clone(); let path = tab.path.clone(); let client = self.client.clone(); - cx.spawn(async move |this, cx| { - let task = client.write_file(&cx, &root, &path, &text); - if task.await.is_ok() { - let _ = this.update(cx, |ed, cx| { - if let Some(active_idx) = ed.active_tab_index { - if let Some(t) = ed.tabs.get_mut(active_idx) { - if t.path == path { - t.is_dirty = false; - cx.notify(); - } + client.write_file(&cx, &root, &path, &text).await?; + let _ = this.update(cx, |ed, cx| { + if let Some(active_idx) = ed.active_tab_index { + if let Some(tab) = ed.tabs.get_mut(active_idx) { + if tab.path == path { + tab.is_dirty = false; + cx.notify(); } } - }); - } + } + }); + Ok(()) }) - .detach(); } /// 全文替换并记入撤销历史(`set_value` 不触发 Change,需手动维护历史与标签同步)。 diff --git a/linux/src/workbench/mod.rs b/linux/src/workbench/mod.rs index 3ae503ed6..9d870daef 100644 --- a/linux/src/workbench/mod.rs +++ b/linux/src/workbench/mod.rs @@ -11,6 +11,7 @@ pub mod notifications; pub mod panes; pub mod project_dialog; pub mod quick_open; +pub mod run; pub mod search_everywhere; pub mod settings_dialog; pub mod sidebar; diff --git a/linux/src/workbench/run/config.rs b/linux/src/workbench/run/config.rs new file mode 100644 index 000000000..3d1bc0ec1 --- /dev/null +++ b/linux/src/workbench/run/config.rs @@ -0,0 +1,1105 @@ +//! Run 配置文档、工具链读取和 Linux 启动计划的纯适配。 +//! +//! 这里不判断 Java 入口是否存在,也不从源码文本推导项目模型;这些事实由 +//! Core/JDT 生成。Linux 只保存 Core 返回的文档,并把平台路径和工具链选择 +//! 解析成 `std::process::Command` 所需的形状。 + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde_json::{json, Value}; + +const SIDECAR_VERSION: u32 = 1; +const RUN_VERSION: u32 = 2; +const MAX_JAVA_SOURCES: usize = 8_000; +const MAX_JAVA_WALK_DEPTH: usize = 32; +const LITHE_GITIGNORE_ENTRIES: &[&str] = &[ + "run/local.json", + "toolchains/local.json", + "run/classes/", + "**/*.tmp", +]; + +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +/// Core 解析后的一个有效运行配置。 +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct RunConfigItem { + pub id: String, + pub name: String, + pub provider: String, + pub kind: String, + pub detail: String, + pub execution: String, + pub category: String, + pub cwd: String, + pub args: Vec, + pub env: BTreeMap, + pub toolchains: BTreeMap, + pub extensions: Value, + pub main_class: Option, + pub source: Option, + pub module: Option, + pub raw: Value, +} + +impl RunConfigItem { + /// 从 `runConfig.resolve` 的有效配置项构造展示模型。 + pub fn from_value(value: &Value) -> Option { + let object = value.as_object()?; + let id = object.get("id")?.as_str()?.to_string(); + let name = object + .get("name") + .and_then(Value::as_str) + .unwrap_or(&id) + .to_string(); + let provider = object + .get("provider") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let command = object.get("command").and_then(Value::as_str); + let extensions = object + .get("extensions") + .cloned() + .unwrap_or_else(|| json!({})); + let toolchains = string_map(object.get("toolchains")); + let args = string_array(object.get("args")).unwrap_or_default(); + let env = string_map(object.get("env")); + let module = extensions + .get("maven") + .and_then(|maven| maven.get("module")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let main_class = extensions + .get("maven") + .and_then(|maven| maven.get("mainClass")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let source = extensions + .get("java") + .and_then(|java| java.get("source")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let kind = if provider.starts_with("java.") { + "java".to_string() + } else if provider.contains("maven") || toolchains.contains_key("maven") { + "maven".to_string() + } else if command.is_some_and(|value| { + let lower = value.to_ascii_lowercase(); + lower.contains("npm") + || lower.contains("node") + || lower.contains("pnpm") + || lower.contains("yarn") + }) { + "npm".to_string() + } else { + "process".to_string() + }; + let detail = main_class + .clone() + .or_else(|| module.clone()) + .or_else(|| source.clone()) + .or_else(|| { + let value = args.join(" "); + (!value.is_empty()).then_some(value) + }) + .or_else(|| command.map(str::to_string)) + .unwrap_or_else(|| provider.clone()); + Some(Self { + id, + name, + provider, + kind, + detail, + execution: object + .get("execution") + .and_then(Value::as_str) + .unwrap_or("application") + .to_string(), + category: object + .get("category") + .and_then(Value::as_str) + .unwrap_or("project") + .to_string(), + cwd: object + .get("cwd") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .unwrap_or(".") + .to_string(), + args, + env, + toolchains, + extensions, + main_class, + source, + module, + raw: value.clone(), + }) + } + + pub fn uses_maven(&self) -> bool { + self.toolchains.contains_key("maven") + || self.provider.contains("maven") + || (self.provider == "java.main" + && self + .extensions + .get("maven") + .and_then(|value| value.get("module")) + .is_some()) + } + + pub fn java_home_path(&self) -> Option<&str> { + self.extensions + .get("java") + .and_then(|java| java.get("homePath")) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + } + + pub fn maven_executable_path(&self) -> Option<&str> { + self.extensions + .get("java") + .and_then(|java| java.get("mavenExecutablePath")) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + } + + pub fn maven_java_home_path(&self) -> Option<&str> { + self.extensions + .get("java") + .and_then(|java| java.get("mavenJavaHomePath")) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + } +} + +/// 判断一次异步结果是否仍属于当前请求;旧 reload/execution token 必须被拒绝。 +pub fn sequence_is_current(current: u64, expected: u64) -> bool { + current == expected +} + +/// Core resolve 返回的项目快照。 +#[derive(Debug, Clone)] +pub struct ResolvedRunProject { + pub configurations: Vec, + pub default_configuration_id: Option, + pub diagnostics: Vec, +} + +/// 解析 Core 的有效配置数组,保留稳定顺序并提取可展示错误。 +pub fn parse_resolved_configurations(value: &Value) -> Result { + let configurations = value + .get("configurations") + .and_then(Value::as_array) + .ok_or_else(|| "Run configuration resolve returned no configurations array".to_string())?; + let parsed = configurations + .iter() + .filter_map(RunConfigItem::from_value) + .collect::>(); + let default_configuration_id = value + .get("defaultRunConfiguration") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .map(str::to_string); + let diagnostics = value + .get("diagnostics") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| { + item.get("message") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| item.as_str().map(str::to_string)) + }) + .collect() + }) + .unwrap_or_default(); + Ok(ResolvedRunProject { + configurations: parsed, + default_configuration_id, + diagnostics, + }) +} + +/// Tauri 持久化规则中的默认配置:优先框架服务,其次非 Current File。 +pub fn default_generated_configuration_id(generated: &Value) -> Option { + let configurations = generated.get("configurations").and_then(Value::as_array)?; + let framework = configurations.iter().find(|item| { + matches!( + item.get("provider").and_then(Value::as_str), + Some("spring-boot.maven" | "quarkus.maven" | "micronaut.maven") + ) + }); + framework + .or_else(|| { + configurations + .iter() + .find(|item| item.get("id").and_then(Value::as_str) != Some("current-file")) + }) + .and_then(|item| item.get("id")) + .and_then(Value::as_str) + .map(str::to_string) +} + +/// 只列出 Java 文件路径,不读取源码、不判断 main;入口事实仍由 Core/JDT 提供。 +pub fn list_java_sources(root: &str) -> Vec { + fn walk(root: &Path, directory: &Path, depth: usize, output: &mut Vec) { + if depth > MAX_JAVA_WALK_DEPTH || output.len() >= MAX_JAVA_SOURCES { + return; + } + let Ok(entries) = fs::read_dir(directory) else { + return; + }; + let mut entries = entries.filter_map(Result::ok).collect::>(); + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + if output.len() >= MAX_JAVA_SOURCES { + break; + } + let path = entry.path(); + let name = entry.file_name().to_string_lossy().into_owned(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_dir() { + if name.starts_with('.') || is_build_directory(&name) { + continue; + } + walk(root, &path, depth + 1, output); + } else if file_type.is_file() && name.to_ascii_lowercase().ends_with(".java") { + if let Ok(relative) = path.strip_prefix(root) { + output.push(relative.to_string_lossy().replace('\\', "/")); + } + } + } + } + + let root_path = Path::new(root); + let mut output = Vec::new(); + walk(root_path, root_path, 0, &mut output); + output.sort(); + output.dedup(); + output +} + +fn is_build_directory(name: &str) -> bool { + matches!( + name, + "target" + | "build" + | "out" + | "dist" + | "node_modules" + | "vendor" + | "coverage" + | "bin" + | "obj" + | ".gradle" + ) +} + +/// Linux 主机保存的机器工具链选择。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ToolchainPaths { + pub java_home_path: String, + pub maven_executable_path: String, + pub maven_java_home_path: String, + pub runtime_executable_paths: BTreeMap, + pub maven_settings_path: String, + pub local_repository_path: String, +} + +/// 读取 canonical Core 形状以及旧 Linux 扁平键;canonical 值优先。 +pub fn read_toolchain_paths(root: &str) -> ToolchainPaths { + let mut result = ToolchainPaths::default(); + let local_path = Path::new(root).join(".lithe/run/local.json"); + if let Ok(text) = fs::read_to_string(local_path) { + if let Ok(document) = serde_json::from_str::(&text) { + let toolchain = document.get("toolchain"); + result.java_home_path = first_toolchain_value(toolchain, &["javaHomePath", "javaHome"]) + .or_else(|| nested_toolchain_value(toolchain, "java", "homePath")) + .unwrap_or_default(); + result.maven_executable_path = + first_toolchain_value(toolchain, &["mavenExecutablePath", "mavenExecutable"]) + .or_else(|| nested_toolchain_value(toolchain, "maven", "executablePath")) + .unwrap_or_default(); + result.maven_java_home_path = + first_toolchain_value(toolchain, &["mavenJavaHomePath", "mavenJavaHome"]) + .or_else(|| nested_toolchain_value(toolchain, "maven", "javaHomePath")) + .unwrap_or_default(); + } + } + let local_toolchains_path = Path::new(root).join(".lithe/toolchains/local.json"); + if let Ok(text) = fs::read_to_string(local_toolchains_path) { + if let Ok(document) = serde_json::from_str::(&text) { + if let Some(entries) = document.get("toolchains").and_then(Value::as_object) { + for (id, entry) in entries { + if let Some(executable) = entry + .get("executable") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + { + result + .runtime_executable_paths + .insert(id.clone(), executable.to_string()); + } + } + } + } + } + result +} + +fn first_toolchain_value(toolchain: Option<&Value>, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + toolchain + .and_then(|value| value.get(*key)) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }) +} + +fn nested_toolchain_value(toolchain: Option<&Value>, group: &str, key: &str) -> Option { + toolchain + .and_then(|value| value.get(group)) + .and_then(|value| value.get(key)) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +/// 构造 Core resolve 所需的候选;候选只描述已发现的真实路径,不执行安装探测。 +pub fn toolchain_candidates(root: &str, paths: &ToolchainPaths) -> Vec { + let mut candidates = Vec::new(); + let mut add = |id: &str, kind: &str, path: &str| { + if path.trim().is_empty() { + return; + } + let candidate = Path::new(root).join(path); + let exists = candidate.is_file() || candidate.is_dir(); + if exists { + candidates.push(json!({ + "id": id, + "type": kind, + "version": "", + "vendor": "" + })); + } + }; + add("project-jdk", "java", &paths.java_home_path); + add("project-maven", "maven", &paths.maven_executable_path); + if let Some(node) = paths.runtime_executable_paths.get("project-node") { + add("project-node", "node", node); + } + candidates +} + +/// 写回项目 local 层;保留配置覆盖和旧文档其它字段。 +pub fn write_toolchain_paths(root: &str, paths: &ToolchainPaths) -> Result<(), String> { + let path = Path::new(root).join(".lithe/run/local.json"); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + let mut document = match fs::read_to_string(&path) { + Ok(text) => { + let value: Value = serde_json::from_str(&text) + .map_err(|error| format!("Run local configuration is invalid JSON: {error}"))?; + if !value.is_object() { + return Err("Run local configuration must be a JSON object.".to_string()); + } + value + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + json!({"version": RUN_VERSION, "configurations": []}) + } + Err(error) => return Err(error.to_string()), + }; + if document.get("version").is_none() { + document["version"] = json!(RUN_VERSION); + } + let mut toolchain = document + .get("toolchain") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + for legacy in ["javaHome", "mavenExecutable", "mavenJavaHome"] { + toolchain.remove(legacy); + } + toolchain.insert("javaHomePath".to_string(), json!(paths.java_home_path)); + toolchain.insert( + "mavenExecutablePath".to_string(), + json!(paths.maven_executable_path), + ); + toolchain.insert( + "mavenJavaHomePath".to_string(), + json!(paths.maven_java_home_path), + ); + toolchain.insert( + "java".to_string(), + json!({"homePath": paths.java_home_path}), + ); + toolchain.insert( + "maven".to_string(), + json!({ + "executablePath": paths.maven_executable_path, + "javaHomePath": paths.maven_java_home_path + }), + ); + document["toolchain"] = Value::Object(toolchain); + write_json_atomically(&path, &document) +} + +/// 写 Core 生成结果及三个 `.lithe` 辅助文档;任一写入失败会恢复已写文件。 +pub fn write_generated_documents( + root: &str, + generated: &Value, + requirements: &Value, + default_configuration_id: Option<&str>, +) -> Result<(), String> { + let root_path = Path::new(root); + if !root_path.is_dir() { + return Err("The project directory is unavailable.".to_string()); + } + let lithe = root_path.join(".lithe"); + let run_dir = lithe.join("run"); + let toolchain_dir = lithe.join("toolchains"); + let generated_path = run_dir.join("generated.json"); + let requirements_path = toolchain_dir.join("requirements.json"); + let ignore_path = lithe.join(".gitignore"); + let manifest_path = lithe.join("project.json"); + + fs::create_dir_all(&run_dir).map_err(|error| error.to_string())?; + fs::create_dir_all(&toolchain_dir).map_err(|error| error.to_string())?; + + let manifest = if manifest_path.exists() { + fs::read(&manifest_path).map_err(|error| error.to_string())? + } else { + let mut value = json!({"version": SIDECAR_VERSION}); + if let Some(id) = default_configuration_id.filter(|id| !id.trim().is_empty()) { + value["defaultRunConfiguration"] = json!(id); + } + serde_json::to_vec_pretty(&value).map_err(|error| error.to_string())? + }; + let ignore = ensure_gitignore(&ignore_path)?; + let generated_bytes = + serde_json::to_vec_pretty(generated).map_err(|error| error.to_string())?; + let requirements_bytes = + serde_json::to_vec_pretty(requirements).map_err(|error| error.to_string())?; + let documents = [ + (generated_path, generated_bytes), + (requirements_path, requirements_bytes), + (ignore_path, ignore), + (manifest_path, manifest), + ]; + write_document_transaction(&documents) +} + +fn ensure_gitignore(path: &Path) -> Result, String> { + let existing = if path.is_file() { + fs::read_to_string(path).map_err(|error| error.to_string())? + } else { + String::new() + }; + let mut lines = existing.lines().map(str::to_string).collect::>(); + for entry in LITHE_GITIGNORE_ENTRIES { + if !lines.iter().any(|line| line.trim() == *entry) { + lines.push((*entry).to_string()); + } + } + Ok(format!("{}\n", lines.join("\n")).into_bytes()) +} + +fn write_document_transaction(documents: &[(PathBuf, Vec)]) -> Result<(), String> { + let snapshots = documents + .iter() + .map(|(path, _)| { + if path.exists() { + fs::read(path).map(Some).map_err(|error| error.to_string()) + } else { + Ok(None) + } + }) + .collect::, String>>()?; + let mut completed: Vec<&Path> = Vec::new(); + for (path, contents) in documents { + if let Err(error) = write_bytes_atomically(path, contents) { + let mut rollback_error = None; + for completed_path in completed.iter().rev() { + let index = documents + .iter() + .position(|(candidate, _)| candidate == completed_path) + .unwrap_or(0); + let result = match &snapshots[index] { + Some(previous) => write_bytes_atomically(completed_path, previous), + None => fs::remove_file(completed_path).or_else(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + Ok(()) + } else { + Err(error.to_string()) + } + }), + }; + if let Err(error) = result { + rollback_error = Some(error); + } + } + return match rollback_error { + Some(rollback) => Err(format!("{error}; rollback failed: {rollback}")), + None => Err(error), + }; + } + completed.push(path.as_path()); + } + Ok(()) +} + +fn write_json_atomically(path: &Path, value: &Value) -> Result<(), String> { + let bytes = serde_json::to_vec_pretty(value).map_err(|error| error.to_string())?; + write_bytes_atomically(path, &bytes) +} + +fn write_bytes_atomically(path: &Path, contents: &[u8]) -> Result<(), String> { + if path.exists() { + if fs::read(path).ok().as_deref() == Some(contents) { + return Ok(()); + } + } + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("document.json"); + let temporary = path.with_file_name(format!( + ".{file_name}.{}.tmp", + TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + fs::write(&temporary, contents).map_err(|error| error.to_string())?; + match fs::rename(&temporary, path) { + Ok(()) => Ok(()), + Err(error) => { + let _ = fs::remove_file(&temporary); + Err(error.to_string()) + } + } +} + +/// Core `createLaunchPlan` 的 Linux 形状。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LaunchExecutable { + pub toolchain: Option, + pub command: Option, + pub tool: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreLaunchStep { + pub executable: LaunchExecutable, + pub arguments: Vec, + pub classpath: Vec, + pub modulepath: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LaunchPlan { + pub executable: LaunchExecutable, + pub arguments: Vec, + pub working_directory: String, + pub environment: BTreeMap, + pub env: BTreeMap, + pub pre_launch_steps: Vec, + pub classpath: Vec, + pub modulepath: Vec, +} + +impl LaunchPlan { + pub fn from_value(value: &Value) -> Result { + let executable = value + .get("executable") + .ok_or_else(|| "Launch plan has no executable".to_string())?; + let executable = LaunchExecutable::from_value(executable)?; + let arguments = string_array(value.get("arguments")) + .ok_or_else(|| "Launch plan arguments must be an array of strings".to_string())?; + let pre_launch_steps = value + .get("preLaunchSteps") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .map(PreLaunchStep::from_value) + .collect::, _>>() + }) + .transpose()? + .unwrap_or_default(); + Ok(Self { + executable, + arguments, + working_directory: value + .get("workingDirectory") + .and_then(Value::as_str) + .filter(|cwd| !cwd.is_empty()) + .unwrap_or(".") + .to_string(), + environment: value + .get("environment") + .and_then(Value::as_object) + .map(|entries| { + entries + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect() + }) + .unwrap_or_default(), + env: string_map(value.get("env")), + pre_launch_steps, + classpath: string_array(value.get("classpath")).unwrap_or_default(), + modulepath: string_array(value.get("modulepath")).unwrap_or_default(), + }) + } +} + +impl LaunchExecutable { + fn from_value(value: &Value) -> Result { + let object = value + .as_object() + .ok_or_else(|| "Launch executable must be an object".to_string())?; + let toolchain = object + .get("toolchain") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let command = object + .get("command") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let tool = object + .get("tool") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if toolchain.is_none() && command.is_none() { + return Err("Launch executable names neither a toolchain nor a command".to_string()); + } + Ok(Self { + toolchain, + command, + tool, + }) + } +} + +impl PreLaunchStep { + fn from_value(value: &Value) -> Result { + Ok(Self { + executable: LaunchExecutable::from_value( + value + .get("executable") + .ok_or_else(|| "Pre-launch step has no executable".to_string())?, + )?, + arguments: string_array(value.get("arguments")).ok_or_else(|| { + "Pre-launch step arguments must be an array of strings".to_string() + })?, + classpath: string_array(value.get("classpath")).unwrap_or_default(), + modulepath: string_array(value.get("modulepath")).unwrap_or_default(), + }) + } +} + +/// 为 Core 构造 Maven 上下文;上下文只承载项目默认值,不重新推断模块。 +pub fn maven_context_for_configuration(item: &RunConfigItem, toolchains: &ToolchainPaths) -> Value { + let maven = item.extensions.get("maven"); + let profiles = maven + .and_then(|value| value.get("profiles")) + .and_then(Value::as_array) + .map(|items| { + let mut values = items + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect::>(); + values.sort(); + values.dedup(); + values + }) + .unwrap_or_default(); + let reactor_path = maven + .and_then(|value| value.get("reactorPath")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .unwrap_or(item.cwd.as_str()) + .to_string(); + json!({ + "version": 1, + "reactorPath": reactor_path, + "profiles": profiles, + "settingsPath": if toolchains.maven_settings_path.is_empty() { Value::Null } else { json!(toolchains.maven_settings_path) }, + "localRepositoryPath": if toolchains.local_repository_path.is_empty() { Value::Null } else { json!(toolchains.local_repository_path) }, + "skipTests": maven.and_then(|value| value.get("skipTests")).and_then(Value::as_bool).unwrap_or(false), + "mavenExecutablePath": item.maven_executable_path().or(Some(toolchains.maven_executable_path.as_str())).filter(|value| !value.is_empty()).map(Value::from).unwrap_or(Value::Null), + "javaHomePath": item + .maven_java_home_path() + .or(Some(toolchains.maven_java_home_path.as_str())) + .filter(|value| !value.is_empty()) + .map(Value::from) + .unwrap_or(Value::Null), + }) +} + +/// POSIX 下把 Core 的结构化路径合并到已有 JVM 参数,避免第二个 `-cp` 覆盖它。 +pub fn merge_java_paths( + arguments: &[String], + paths: &[String], + default_flag: &str, + existing_flags: &[&str], +) -> Vec { + if paths.is_empty() { + return arguments.to_vec(); + } + let joined = paths.join(":"); + let mut result = arguments.to_vec(); + for index in (0..result.len().saturating_sub(1)).rev() { + if existing_flags.iter().any(|flag| *flag == result[index]) { + result[index + 1] = format!("{joined}:{}", result[index + 1]); + return result; + } + } + let mut with_path = vec![default_flag.to_string(), joined]; + with_path.append(&mut result); + with_path +} + +/// 生成 `runConfig.createLaunchPlan` 请求;Linux 不填充 JDT 专属元数据。 +pub fn create_launch_plan_request( + root: &str, + item: &RunConfigItem, + current_file: Option<&str>, + maven_context: Option<&Value>, + java_launch: Option<&Value>, +) -> Value { + let mut payload = json!({ + "root": root, + "configurationId": item.id, + }); + if let Some(current_file) = current_file { + payload["currentFile"] = json!(current_file); + } + if let Some(context) = maven_context { + payload["mavenContext"] = context.clone(); + } + if let Some(launch) = java_launch { + payload["javaLaunch"] = launch.clone(); + } + payload +} + +fn string_array(value: Option<&Value>) -> Option> { + value? + .as_array()? + .iter() + .map(|item| item.as_str().map(str::to_string)) + .collect() +} + +fn string_map(value: Option<&Value>) -> BTreeMap { + value + .and_then(Value::as_object) + .map(|object| { + object + .iter() + .filter_map(|(key, value)| { + value.as_str().map(|value| (key.clone(), value.to_string())) + }) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0); + + struct Fixture(PathBuf); + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn fixture(name: &str) -> Fixture { + let path = std::env::temp_dir().join(format!( + "lithe-linux-config-{name}-{}-{}", + std::process::id(), + TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&path).expect("fixture"); + Fixture(path) + } + + fn core_data(command: &str, payload: Value) -> Value { + let request = json!({ + "id": format!("linux-test-{command}"), + "command": command, + "payload": payload + }); + let response: Value = serde_json::from_str(&lithe_core::execute_json(&request.to_string())) + .expect("core response"); + assert_eq!(response["ok"], true, "core response: {response}"); + response["data"].clone() + } + + fn write_generated(root: &Path, configurations: Value) { + let path = root.join(".lithe/run/generated.json"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + path, + serde_json::to_vec(&json!({"version": 2, "configurations": configurations})).unwrap(), + ) + .unwrap(); + } + + #[test] + fn core_plan_keeps_process_and_spring_goals_without_compile_fallback() { + let fixture = fixture("core-plan"); + let root = fixture.0.to_string_lossy().into_owned(); + write_generated( + &fixture.0, + json!([ + { + "id": "process", + "name": "Node process", + "provider": "npm.script", + "command": "node", + "args": ["server.js"], + "toolchains": {"runtime": "project-node"} + }, + { + "id": "spring", + "name": "Spring service", + "provider": "spring-boot.maven", + "toolchains": {"java": "project-jdk", "maven": "project-maven"}, + "extensions": {"maven": {"module": "."}} + } + ]), + ); + let process = core_data( + "runConfig.createLaunchPlan", + json!({"root": root, "configurationId": "process"}), + ); + assert_eq!(process["executable"]["command"], "node"); + assert!(process.get("preLaunchSteps").is_none()); + let spring = core_data( + "runConfig.createLaunchPlan", + json!({"root": root, "configurationId": "spring"}), + ); + let arguments = spring["arguments"].as_array().unwrap(); + assert!(arguments.iter().any(|value| value == "spring-boot:run")); + assert!(!arguments.iter().any(|value| value == "compile")); + assert!(spring.get("preLaunchSteps").is_none()); + } + + #[test] + fn core_plan_uses_javac_then_java_for_standalone_current_file() { + let fixture = fixture("standalone-plan"); + let root = fixture.0.to_string_lossy().into_owned(); + fs::create_dir_all(fixture.0.join("src")).unwrap(); + fs::write( + fixture.0.join("src/App.java"), + "package demo; class App { public static void main(String[] args) {} }", + ) + .unwrap(); + write_generated( + &fixture.0, + json!([{ + "id": "current-file", + "name": "Current File", + "provider": "java.current-file", + "toolchains": {"java": "project-jdk"}, + "extensions": {"java": {}, "maven": {"module": "."}} + }]), + ); + let plan = core_data( + "runConfig.createLaunchPlan", + json!({ + "root": root, + "configurationId": "current-file", + "currentFile": "src/App.java" + }), + ); + assert_eq!(plan["executable"]["toolchain"], "project-jdk"); + assert_eq!(plan["preLaunchSteps"][0]["executable"]["tool"], "javac"); + assert!(plan["arguments"] + .as_array() + .unwrap() + .iter() + .any(|value| value == "demo.App")); + } + + #[test] + fn maven_java_main_without_jdt_metadata_fails_explicitly() { + let fixture = fixture("jdt-not-ready"); + let root = fixture.0.to_string_lossy().into_owned(); + fs::create_dir_all(fixture.0.join("src")).unwrap(); + fs::write(fixture.0.join("src/App.java"), "class App {}").unwrap(); + write_generated( + &fixture.0, + json!([{ + "id": "java-main:demo.App", + "name": "App", + "provider": "java.main", + "toolchains": {"java": "project-jdk", "maven": "project-maven"}, + "extensions": { + "java": {"source": "src/App.java"}, + "maven": {"module": ".", "mainClass": "demo.App"} + } + }]), + ); + let request = json!({ + "id": "linux-test-jdt-not-ready", + "command": "runConfig.createLaunchPlan", + "payload": {"root": root, "configurationId": "java-main:demo.App"} + }); + let response: Value = + serde_json::from_str(&lithe_core::execute_json(&request.to_string())).unwrap(); + assert_eq!(response["ok"], false, "{response}"); + assert!(response["error"]["message"] + .as_str() + .unwrap() + .contains("Java project launch metadata is unavailable")); + } + + #[test] + fn core_toolchain_defaults_are_read_from_canonical_nested_document() { + let fixture = fixture("toolchain"); + let root = fixture.0.to_string_lossy().into_owned(); + write_generated( + &fixture.0, + json!([{ + "id": "current-file", + "name": "Current File", + "provider": "java.current-file", + "toolchains": {"java": "project-jdk"}, + "extensions": {"maven": {"module": "."}} + }]), + ); + let local = fixture.0.join(".lithe/run/local.json"); + fs::create_dir_all(local.parent().unwrap()).unwrap(); + fs::write( + local, + r#"{"version":2,"toolchain":{"java":{"homePath":"/fixture/jdk"},"maven":{"executablePath":"/fixture/mvn","javaHomePath":"/fixture/maven-jdk"}},"configurations":[]}"#, + ) + .unwrap(); + let resolved = core_data( + "runConfig.resolve", + json!({"root": root, "toolchainCandidates": []}), + ); + let config = &resolved["configurations"][0]; + assert_eq!(config["extensions"]["java"]["homePath"], "/fixture/jdk"); + assert_eq!( + config["extensions"]["java"]["mavenJavaHomePath"], + "/fixture/maven-jdk" + ); + } + + #[test] + fn stale_sequence_rejects_previous_reload_and_execution() { + assert!(sequence_is_current(7, 7)); + assert!(!sequence_is_current(6, 7)); + } + + #[test] + fn posix_classpath_merges_into_existing_flag() { + let arguments = vec!["-cp".to_string(), "old".to_string(), "demo.App".to_string()]; + let merged = merge_java_paths( + &arguments, + &["one".to_string(), "two".to_string()], + "-cp", + &["-cp", "-classpath", "--class-path"], + ); + assert_eq!(merged, vec!["-cp", "one:two:old", "demo.App"]); + } + + #[test] + fn toolchain_reader_accepts_legacy_keys_and_writer_replaces_them() { + let fixture = fixture("toolchain-compat"); + let local = fixture.0.join(".lithe/run/local.json"); + fs::create_dir_all(local.parent().unwrap()).unwrap(); + fs::write( + &local, + r#"{"version":2,"toolchain":{"javaHome":"/legacy/jdk","mavenExecutable":"/legacy/mvn","mavenJavaHome":"/legacy/maven-jdk"},"configurations":[]}"#, + ) + .unwrap(); + let paths = read_toolchain_paths(&fixture.0.to_string_lossy()); + assert_eq!(paths.java_home_path, "/legacy/jdk"); + assert_eq!(paths.maven_executable_path, "/legacy/mvn"); + assert_eq!(paths.maven_java_home_path, "/legacy/maven-jdk"); + write_toolchain_paths( + &fixture.0.to_string_lossy(), + &ToolchainPaths { + java_home_path: "/canonical/jdk".to_string(), + maven_executable_path: "/canonical/mvn".to_string(), + maven_java_home_path: "/canonical/maven-jdk".to_string(), + ..ToolchainPaths::default() + }, + ) + .unwrap(); + let value: Value = serde_json::from_str(&fs::read_to_string(local).unwrap()).unwrap(); + assert_eq!(value["toolchain"]["javaHomePath"], "/canonical/jdk"); + assert_eq!(value["toolchain"]["mavenExecutablePath"], "/canonical/mvn"); + assert_eq!( + value["toolchain"]["mavenJavaHomePath"], + "/canonical/maven-jdk" + ); + assert!(value["toolchain"].get("javaHome").is_none()); + assert!(value["toolchain"].get("mavenExecutable").is_none()); + assert!(value["toolchain"].get("mavenJavaHome").is_none()); + } + + #[test] + fn generated_documents_write_all_lithe_sidecars_and_preserve_gitignore() { + let fixture = fixture("documents"); + fs::create_dir_all(fixture.0.join(".lithe")).unwrap(); + fs::write( + fixture.0.join(".lithe/.gitignore"), + "custom/\nrun/local.json\n", + ) + .unwrap(); + write_generated_documents( + &fixture.0.to_string_lossy(), + &json!({"version": 2, "configurations": [{"id": "x", "name": "X", "provider": "npm.script", "command": "node"}]}), + &json!({"version": 1, "toolchains": {}}), + Some("x"), + ) + .unwrap(); + assert!(fixture.0.join(".lithe/run/generated.json").is_file()); + assert!(fixture + .0 + .join(".lithe/toolchains/requirements.json") + .is_file()); + let ignore = fs::read_to_string(fixture.0.join(".lithe/.gitignore")).unwrap(); + assert!(ignore.starts_with("custom/\nrun/local.json\n")); + assert!(ignore.contains("run/classes/\n")); + let manifest: Value = serde_json::from_str( + &fs::read_to_string(fixture.0.join(".lithe/project.json")).unwrap(), + ) + .unwrap(); + assert_eq!(manifest["defaultRunConfiguration"], "x"); + } +} diff --git a/linux/src/workbench/run/mod.rs b/linux/src/workbench/run/mod.rs new file mode 100644 index 000000000..112c48bcf --- /dev/null +++ b/linux/src/workbench/run/mod.rs @@ -0,0 +1,15 @@ +//! Linux Run 与 Maven 执行边界。 +//! +//! Core 负责配置文档、工具链需求和平台无关启动计划;本模块只负责 +//! Linux 宿主侧的文档落盘、工具链解析和进程会话生命周期。 + +pub mod config; +pub mod process; + +pub use config::{ + create_launch_plan_request, default_generated_configuration_id, list_java_sources, + maven_context_for_configuration, parse_resolved_configurations, read_toolchain_paths, + sequence_is_current, toolchain_candidates, write_generated_documents, write_toolchain_paths, + LaunchPlan, RunConfigItem, ToolchainPaths, +}; +pub use process::{OutputStream, ProcessEvent, ProcessManager}; diff --git a/linux/src/workbench/run/process.rs b/linux/src/workbench/run/process.rs new file mode 100644 index 000000000..e78ca8362 --- /dev/null +++ b/linux/src/workbench/run/process.rs @@ -0,0 +1,1331 @@ +//! Linux Run/Maven 进程解析与受管会话。 +//! +//! 每个执行由一个 session/execution 身份拥有。一个后台线程顺序执行所有步骤, +//! stdout/stderr 各自有明确归属并在进程结束后 join;停止操作向独立进程组 +//! 发送 TERM,超时后再 KILL,调用方通过 `ProcessHandle::join` 完成回收。 + +use std::collections::{BTreeMap, HashMap}; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc, Condvar, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use serde_json::Value; + +use super::config::{ + merge_java_paths, LaunchExecutable, LaunchPlan, PreLaunchStep, ToolchainPaths, +}; + +const PROCESS_STOP_TIMEOUT: Duration = Duration::from_secs(2); +const PROCESS_POLL_INTERVAL: Duration = Duration::from_millis(20); + +/// 一个已经解析到 Linux 路径和环境的命令。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProcessCommand { + pub program: String, + pub arguments: Vec, + pub working_directory: String, + pub environment: BTreeMap, +} + +impl ProcessCommand { + fn label(&self) -> String { + format!("{} {}", self.program, self.arguments.join(" ")) + } +} + +/// 一个由同一 session 顺序执行的步骤。 +#[derive(Debug, Clone)] +pub struct ProcessStep { + pub label: String, + pub command: ProcessCommand, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputStream { + Stdout, + Stderr, +} + +#[derive(Debug)] +pub enum ProcessEvent { + Started { + index: usize, + label: String, + }, + Output { + stream: OutputStream, + text: String, + }, + Finished { + exit_code: Option, + cancelled: bool, + error: Option, + }, +} + +#[derive(Debug)] +struct Control { + stop_requested: AtomicBool, + pgid: Mutex>, + finished: Mutex, + finished_signal: Condvar, +} + +impl Control { + fn new() -> Self { + Self { + stop_requested: AtomicBool::new(false), + pgid: Mutex::new(None), + finished: Mutex::new(false), + finished_signal: Condvar::new(), + } + } + + fn request_stop(&self) { + self.stop_requested.store(true, Ordering::Release); + } + + fn is_stop_requested(&self) -> bool { + self.stop_requested.load(Ordering::Acquire) + } + + fn set_pgid(&self, pgid: i32) { + if let Ok(mut slot) = self.pgid.lock() { + *slot = Some(pgid); + } + } + + fn pgid(&self) -> Option { + self.pgid.lock().ok().and_then(|value| *value) + } + + fn mark_finished(&self) { + if let Ok(mut pgid) = self.pgid.lock() { + *pgid = None; + } + if let Ok(mut finished) = self.finished.lock() { + *finished = true; + } + self.finished_signal.notify_all(); + } + + fn wait_finished(&self, timeout: Duration) -> bool { + let Ok(finished) = self.finished.lock() else { + return true; + }; + let (finished, timeout_result) = self + .finished_signal + .wait_timeout_while(finished, timeout, |finished| !*finished) + .unwrap(); + *finished || timeout_result.timed_out() + } +} + +struct SessionEntry { + execution_id: String, + control: Arc, + join: Arc>>>, +} + +#[derive(Clone)] +struct ProcessManagerInner { + sessions: Arc>>, +} + +/// 管理所有 Linux Run/Maven session;同一个 session 启动新执行前先停止旧执行。 +#[derive(Clone)] +pub struct ProcessManager { + inner: ProcessManagerInner, +} + +/// 一个可停止、可等待回收的执行句柄。 +pub struct ProcessHandle { + manager: ProcessManager, + session_id: String, + execution_id: String, + control: Arc, + join: Arc>>>, +} + +impl ProcessManager { + pub fn new() -> Self { + Self { + inner: ProcessManagerInner { + sessions: Arc::new(Mutex::new(HashMap::new())), + }, + } + } + + /// 启动一个 session;步骤失败会由同一个 worker 停止后续步骤。 + pub fn start( + &self, + session_id: &str, + execution_id: &str, + steps: Vec, + sender: mpsc::Sender, + ) -> Result { + if session_id.trim().is_empty() || execution_id.trim().is_empty() { + return Err("A process session and execution id are required".to_string()); + } + self.stop(session_id, None).ok(); + + let control = Arc::new(Control::new()); + let join = Arc::new(Mutex::new(None)); + { + let mut sessions = self + .inner + .sessions + .lock() + .map_err(|_| "Process session state is unavailable".to_string())?; + sessions.insert( + session_id.to_string(), + SessionEntry { + execution_id: execution_id.to_string(), + control: control.clone(), + join: join.clone(), + }, + ); + } + + let worker_control = control.clone(); + let worker = thread::Builder::new() + .name("lithe-run-process".to_string()) + .spawn(move || { + let outcome = execute_steps(&steps, &sender, &worker_control); + if let Err(error) = outcome { + let _ = sender.send(ProcessEvent::Finished { + exit_code: Some(1), + cancelled: false, + error: Some(error), + }); + } + worker_control.mark_finished(); + // The entry remains until the owner calls join/reap, so stop can + // still observe the exact execution while its final event drains. + }) + .map_err(|error| { + let mut sessions = self.inner.sessions.lock().ok(); + if let Some(sessions) = sessions.as_mut() { + sessions.remove(session_id); + } + format!("Could not start process worker: {error}") + })?; + if let Ok(mut slot) = join.lock() { + *slot = Some(worker); + } + Ok(ProcessHandle { + manager: self.clone(), + session_id: session_id.to_string(), + execution_id: execution_id.to_string(), + control, + join, + }) + } + + /// 发出停止信号后立即返回;worker 负责有界等待和 join。 + pub fn request_stop(&self, session_id: &str, execution_id: Option<&str>) -> Result<(), String> { + let control = { + let sessions = self + .inner + .sessions + .lock() + .map_err(|_| "Process session state is unavailable".to_string())?; + sessions + .get(session_id) + .filter(|entry| execution_id.is_none_or(|id| entry.execution_id == id)) + .map(|entry| entry.control.clone()) + }; + if let Some(control) = control { + control.request_stop(); + send_signal(control.pgid(), libc::SIGTERM); + } + Ok(()) + } + + /// 停止当前 session;传入 execution id 时只停止匹配的执行。 + pub fn stop(&self, session_id: &str, execution_id: Option<&str>) -> Result<(), String> { + let entry = { + let sessions = self + .inner + .sessions + .lock() + .map_err(|_| "Process session state is unavailable".to_string())?; + sessions + .get(session_id) + .filter(|entry| execution_id.is_none_or(|id| entry.execution_id == id)) + .map(|entry| { + ( + entry.execution_id.clone(), + entry.control.clone(), + entry.join.clone(), + ) + }) + }; + let Some((owned_execution, control, join)) = entry else { + return Ok(()); + }; + stop_control(&control)?; + if let Ok(mut slot) = join.lock() { + if let Some(worker) = slot.take() { + let _ = worker.join(); + } + } + if let Ok(mut sessions) = self.inner.sessions.lock() { + if sessions + .get(session_id) + .is_some_and(|entry| entry.execution_id == owned_execution) + { + sessions.remove(session_id); + } + } + Ok(()) + } + + fn reap(&self, session_id: &str, execution_id: &str) { + let should_remove = { + let Ok(sessions) = self.inner.sessions.lock() else { + return; + }; + sessions + .get(session_id) + .is_some_and(|entry| entry.execution_id == execution_id) + }; + if should_remove { + if let Ok(mut sessions) = self.inner.sessions.lock() { + sessions.remove(session_id); + } + } + } +} + +impl Default for ProcessManager { + fn default() -> Self { + Self::new() + } +} + +impl ProcessHandle { + pub fn stop(&self) -> Result<(), String> { + self.control.request_stop(); + send_signal(self.control.pgid(), libc::SIGTERM); + if !self.control.wait_finished(PROCESS_STOP_TIMEOUT) { + send_signal(self.control.pgid(), libc::SIGKILL); + if !self.control.wait_finished(PROCESS_STOP_TIMEOUT) { + return Err("Process did not exit within the stop deadline".to_string()); + } + } + self.join() + } + + /// 等待 worker 完成并移除 session 条目;必须在消费 Finished 事件后调用。 + pub fn join(&self) -> Result<(), String> { + if !self.control.wait_finished(PROCESS_STOP_TIMEOUT) { + return Err("Process worker did not finish within the join deadline".to_string()); + } + if let Ok(mut slot) = self.join.lock() { + if let Some(worker) = slot.take() { + worker + .join() + .map_err(|_| "Process worker panicked".to_string())?; + } + } + self.manager.reap(&self.session_id, &self.execution_id); + Ok(()) + } +} + +impl Drop for ProcessHandle { + fn drop(&mut self) { + let _ = self.stop(); + } +} + +impl Drop for ProcessManagerInner { + fn drop(&mut self) { + let sessions = self + .sessions + .lock() + .map(|mut sessions| sessions.drain().map(|(_, entry)| entry).collect::>()) + .unwrap_or_default(); + for entry in sessions { + entry.control.request_stop(); + send_signal(entry.control.pgid(), libc::SIGTERM); + let _ = entry.control.wait_finished(PROCESS_STOP_TIMEOUT); + send_signal(entry.control.pgid(), libc::SIGKILL); + if let Ok(mut slot) = entry.join.lock() { + if let Some(worker) = slot.take() { + let _ = worker.join(); + } + } + } + } +} + +fn execute_steps( + steps: &[ProcessStep], + sender: &mpsc::Sender, + control: &Control, +) -> Result<(), String> { + for (index, step) in steps.iter().enumerate() { + if control.is_stop_requested() { + let _ = sender.send(ProcessEvent::Finished { + exit_code: None, + cancelled: true, + error: None, + }); + return Ok(()); + } + if sender + .send(ProcessEvent::Started { + index, + label: step.label.clone(), + }) + .is_err() + { + return Ok(()); + } + let outcome = execute_step(&step.command, sender, control)?; + let Some(code) = outcome else { + let _ = sender.send(ProcessEvent::Finished { + exit_code: None, + cancelled: true, + error: None, + }); + return Ok(()); + }; + if code != 0 { + let _ = sender.send(ProcessEvent::Finished { + exit_code: Some(code), + cancelled: false, + error: None, + }); + return Ok(()); + } + } + let _ = sender.send(ProcessEvent::Finished { + exit_code: Some(0), + cancelled: false, + error: None, + }); + Ok(()) +} + +/// Returns `Some(exit_code)` for a completed child, or `None` when stopped. +fn execute_step( + spec: &ProcessCommand, + sender: &mpsc::Sender, + control: &Control, +) -> Result, String> { + let mut command = Command::new(&spec.program); + command + .args(&spec.arguments) + .current_dir(&spec.working_directory) + .envs(&spec.environment) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + configure_process_group(&mut command); + let mut child = command + .spawn() + .map_err(|error| format!("Unable to start process {}: {error}", spec.program))?; + let pgid = child.id() as i32; + control.set_pgid(pgid); + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let stdout_thread = spawn_reader(stdout, OutputStream::Stdout, sender.clone()); + let stderr_thread = spawn_reader(stderr, OutputStream::Stderr, sender.clone()); + + let status = wait_for_child(&mut child, control); + // A child can exit while a forked descendant still owns the pipe. Kill the + // whole process group before joining readers so the reader threads cannot + // remain blocked on inherited descriptors. + send_signal(Some(pgid), libc::SIGKILL); + if let Some(thread) = stdout_thread { + let _ = thread.join(); + } + if let Some(thread) = stderr_thread { + let _ = thread.join(); + } + if control.is_stop_requested() { + return Ok(None); + } + let status = status?; + status.code().map(Some).ok_or_else(|| { + format!( + "Process {} was terminated without an exit code", + spec.program + ) + }) +} + +fn spawn_reader( + reader: Option, + stream: OutputStream, + sender: mpsc::Sender, +) -> Option> +where + R: std::io::Read + Send + 'static, +{ + let reader = reader?; + Some(thread::spawn(move || { + for line in BufReader::new(reader).lines() { + let Ok(line) = line else { break }; + if sender + .send(ProcessEvent::Output { stream, text: line }) + .is_err() + { + break; + } + } + })) +} + +fn wait_for_child( + child: &mut Child, + control: &Control, +) -> Result { + loop { + match child.try_wait() { + Ok(Some(status)) => return Ok(status), + Ok(None) if control.is_stop_requested() => { + send_signal(control.pgid(), libc::SIGTERM); + thread::park_timeout(PROCESS_STOP_TIMEOUT.min(Duration::from_millis(200))); + send_signal(control.pgid(), libc::SIGKILL); + return child + .wait() + .map_err(|error| format!("Waiting for process failed: {error}")); + } + Ok(None) => thread::park_timeout(PROCESS_POLL_INTERVAL), + Err(error) => return Err(format!("Waiting for process failed: {error}")), + } + } +} + +fn stop_control(control: &Control) -> Result<(), String> { + control.request_stop(); + send_signal(control.pgid(), libc::SIGTERM); + if !control.wait_finished(PROCESS_STOP_TIMEOUT) { + send_signal(control.pgid(), libc::SIGKILL); + if !control.wait_finished(PROCESS_STOP_TIMEOUT) { + return Err("Process did not exit within the stop deadline".to_string()); + } + } + Ok(()) +} + +fn configure_process_group(command: &mut Command) { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + #[cfg(not(unix))] + { + let _ = command; + } +} + +fn send_signal(pgid: Option, signal: i32) { + if let Some(pgid) = pgid.filter(|value| *value > 1) { + unsafe { + libc::kill(-pgid, signal); + } + } +} + +/// 解析 Core 计划中的主进程和每个 pre-launch 步骤。 +pub fn resolve_launch( + plan: &LaunchPlan, + root: &str, + toolchains: &ToolchainPaths, +) -> Result<(Vec, ProcessCommand), String> { + let working_directory = resolve_working_directory(root, &plan.working_directory)?; + let mut environment = base_environment(); + let java_home = configured_java_home(root, toolchains)?; + let maven_java_home = configured_maven_java_home(root, toolchains, java_home.as_deref())?; + apply_plan_environment( + &mut environment, + &plan.environment, + java_home.as_deref(), + maven_java_home.as_deref(), + ); + environment.extend(plan.env.clone()); + if plan.executable.toolchain.as_deref() == Some("project-maven") + && !plan.env.contains_key("JAVA_HOME") + { + if let Some(home) = maven_java_home.as_deref() { + environment.insert("JAVA_HOME".to_string(), home.to_string()); + } + } + let main_executable = resolve_executable( + root, + &working_directory, + &plan.executable, + toolchains, + java_home.as_deref(), + )?; + let mut main_arguments = merge_java_paths( + &plan.arguments, + &resolve_paths(root, &plan.classpath)?, + "-cp", + &["-cp", "-classpath", "--class-path"], + ); + main_arguments = merge_java_paths( + &main_arguments, + &resolve_paths(root, &plan.modulepath)?, + "--module-path", + &["-p", "--module-path"], + ); + prepend_executable_path(&mut environment, &main_executable); + let main = ProcessCommand { + program: main_executable, + arguments: main_arguments, + working_directory: working_directory.clone(), + environment: environment.clone(), + }; + + let mut steps = Vec::with_capacity(plan.pre_launch_steps.len() + 1); + for step in &plan.pre_launch_steps { + steps.push(resolve_prelaunch_step( + root, + &working_directory, + step, + toolchains, + java_home.as_deref(), + &environment, + )?); + } + steps.push(ProcessStep { + label: format!("$ {}", main.label()), + command: main.clone(), + }); + Ok((steps, main)) +} + +fn resolve_prelaunch_step( + root: &str, + working_directory: &str, + step: &PreLaunchStep, + toolchains: &ToolchainPaths, + java_home: Option<&str>, + base_environment: &BTreeMap, +) -> Result { + let executable = resolve_executable( + root, + working_directory, + &step.executable, + toolchains, + java_home, + )?; + prepare_prelaunch_output_directory(root, &step.executable, &step.arguments)?; + let mut arguments = merge_java_paths( + &step.arguments, + &resolve_paths(root, &step.classpath)?, + "-cp", + &["-cp", "-classpath", "--class-path"], + ); + arguments = merge_java_paths( + &arguments, + &resolve_paths(root, &step.modulepath)?, + "--module-path", + &["-p", "--module-path"], + ); + let mut environment = base_environment.clone(); + prepend_executable_path(&mut environment, &executable); + let command = ProcessCommand { + program: executable.clone(), + arguments, + working_directory: working_directory.to_string(), + environment, + }; + Ok(ProcessStep { + label: format!("$ {} {}", executable, command.arguments.join(" ")), + command, + }) +} + +fn prepare_prelaunch_output_directory( + root: &str, + executable: &LaunchExecutable, + arguments: &[String], +) -> Result<(), String> { + let is_javac = executable.tool.as_deref() == Some("javac") + || executable.command.as_deref().is_some_and(|command| { + Path::new(command) + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == "javac") + }); + if !is_javac { + return Ok(()); + } + for (index, argument) in arguments.iter().enumerate() { + let output = if argument == "-d" || argument == "--output-dir" { + arguments.get(index + 1).ok_or_else(|| { + format!("Compiler output option {argument} is missing its directory") + })? + } else if let Some(value) = argument.strip_prefix("--output-dir=") { + value + } else { + continue; + }; + let path = Path::new(output); + if !path.is_absolute() + && path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err("Compiler output directory must stay inside the project.".to_string()); + } + let output_path = if path.is_absolute() { + path.to_path_buf() + } else { + Path::new(root).join(path) + }; + fs::create_dir_all(&output_path) + .map_err(|error| format!("Unable to create compiler output directory: {error}"))?; + } + Ok(()) +} + +fn configured_java_home(root: &str, toolchains: &ToolchainPaths) -> Result, String> { + if let Some(path) = non_empty(&toolchains.java_home_path) { + let selected = absolute_path(root, &path); + return selected_java_home(&selected) + .map(|home| Some(home.to_string_lossy().into_owned())) + .ok_or_else(|| { + format!( + "The selected JDK does not contain bin/java: {}", + selected.to_string_lossy() + ) + }); + } + Ok(std::env::var_os("JAVA_HOME") + .map(PathBuf::from) + .map(|path| path.to_string_lossy().into_owned()) + .filter(|path| jdk_tool(path, "java").is_some()) + .or_else(|| { + lookup_on_path("java") + .and_then(|path| path.parent().and_then(Path::parent).map(Path::to_path_buf)) + .map(|path| path.to_string_lossy().into_owned()) + .filter(|path| jdk_tool(path, "java").is_some()) + })) +} + +fn configured_maven_java_home( + root: &str, + toolchains: &ToolchainPaths, + java_home: Option<&str>, +) -> Result, String> { + if let Some(path) = non_empty(&toolchains.maven_java_home_path) { + let selected = absolute_path(root, &path); + return selected_java_home(&selected) + .map(|home| Some(home.to_string_lossy().into_owned())) + .ok_or_else(|| { + format!( + "The selected Maven JDK does not contain bin/java: {}", + selected.to_string_lossy() + ) + }); + } + Ok(java_home.map(str::to_string)) +} + +fn selected_java_home(path: &Path) -> Option { + if jdk_tool(&path.to_string_lossy(), "java").is_some() { + return Some(path.to_path_buf()); + } + if path.is_file() + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == "java") + { + let home = path.parent().and_then(Path::parent)?; + if jdk_tool(&home.to_string_lossy(), "java").is_some() { + return Some(home.to_path_buf()); + } + } + None +} + +fn non_empty(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) +} + +fn resolve_executable( + root: &str, + working_directory: &str, + executable: &LaunchExecutable, + toolchains: &ToolchainPaths, + java_home: Option<&str>, +) -> Result { + if let Some(toolchain) = executable.toolchain.as_deref() { + return match toolchain { + "project-jdk" => { + let home = java_home.ok_or_else(|| { + "No Java runtime was found. Set javaHomePath or install a JDK.".to_string() + })?; + let tool = executable.tool.as_deref().unwrap_or("java"); + jdk_tool(&home, tool) + .ok_or_else(|| format!("The selected JDK does not contain bin/{tool}.")) + } + "project-maven" => { + resolve_maven(root, working_directory, &toolchains.maven_executable_path) + } + "project-node" => { + resolve_runtime(root, "project-node", &toolchains.runtime_executable_paths) + } + other => resolve_runtime(root, other, &toolchains.runtime_executable_paths), + }; + } + let command = executable + .command + .as_deref() + .ok_or_else(|| "Launch plan names neither a toolchain nor a command".to_string())?; + resolve_command( + root, + working_directory, + command, + &toolchains.runtime_executable_paths, + ) +} + +fn resolve_runtime( + root: &str, + id: &str, + paths: &BTreeMap, +) -> Result { + if let Some(configured) = paths.get(id).and_then(|value| non_empty(value)) { + let path = absolute_path(root, &configured); + if path.is_file() { + return Ok(path.to_string_lossy().into_owned()); + } + if id == "project-node" { + for name in ["node", "node.exe", "npm", "npm.cmd"] { + let direct = path.join(name); + if direct.is_file() { + return Ok(direct.to_string_lossy().into_owned()); + } + let nested = path.join("bin").join(name); + if nested.is_file() { + return Ok(nested.to_string_lossy().into_owned()); + } + } + } + return Err(format!("Configured executable for {id} does not exist.")); + } + let names = if id == "project-node" { + vec!["node", "node.exe"] + } else { + vec![id] + }; + names + .iter() + .find_map(|name| lookup_on_path(name)) + .map(|path| path.to_string_lossy().into_owned()) + .ok_or_else(|| format!("No executable was found for toolchain {id}.")) +} + +fn resolve_command( + root: &str, + working_directory: &str, + command: &str, + runtime_paths: &BTreeMap, +) -> Result { + let command_path = Path::new(command); + if command_path.is_absolute() { + if command_path.is_file() { + return Ok(command_path.to_string_lossy().into_owned()); + } + return Err(format!("Could not find executable: {command}")); + } + if command_path.components().count() > 1 { + for base in [working_directory, root] { + let candidate = Path::new(base).join(command_path); + if candidate.is_file() { + return Ok(candidate.to_string_lossy().into_owned()); + } + } + } + let lower = command.to_ascii_lowercase(); + if matches!( + lower.as_str(), + "node" | "node.exe" | "npm" | "npm.cmd" | "pnpm" | "pnpm.cmd" | "yarn" | "yarn.cmd" + ) { + if let Some(selected) = runtime_paths + .get("project-node") + .and_then(|value| non_empty(value)) + { + let directory = absolute_path(root, &selected); + let directory = if directory.is_dir() { + directory + } else { + directory + .parent() + .unwrap_or_else(|| Path::new(root)) + .to_path_buf() + }; + for name in command_file_names(command) { + let direct = directory.join(&name); + if direct.is_file() { + return Ok(direct.to_string_lossy().into_owned()); + } + let nested = directory.join("bin").join(name); + if nested.is_file() { + return Ok(nested.to_string_lossy().into_owned()); + } + } + return Err(format!("Selected Node runtime does not contain {command}.")); + } + } + command_file_names(command) + .iter() + .find_map(|name| lookup_on_path(name)) + .map(|path| path.to_string_lossy().into_owned()) + .ok_or_else(|| format!("Could not find executable: {command}")) +} + +fn resolve_maven(root: &str, working_directory: &str, configured: &str) -> Result { + if let Some(configured) = non_empty(configured) { + if !configured.contains('/') && !configured.contains('\\') { + if let Some(path) = lookup_on_path(&configured) { + return Ok(path.to_string_lossy().into_owned()); + } + } + let path = absolute_path(root, &configured); + for candidate in [path.clone(), path.join("bin/mvn"), path.join("mvn")] { + if candidate.is_file() { + return Ok(candidate.to_string_lossy().into_owned()); + } + } + return Err("Maven executable path does not exist.".to_string()); + } + for directory in [working_directory, root] { + let wrapper = Path::new(directory).join("mvnw"); + if wrapper.is_file() + && Path::new(directory) + .join(".mvn/wrapper/maven-wrapper.properties") + .is_file() + { + return Ok(wrapper.to_string_lossy().into_owned()); + } + } + lookup_on_path("mvn") + .map(|path| path.to_string_lossy().into_owned()) + .ok_or_else(|| { + "No Maven executable was found. Edit this service configuration.".to_string() + }) +} + +fn command_file_names(command: &str) -> Vec { + if Path::new(command).extension().is_some() { + return vec![command.to_string()]; + } + vec![ + format!("{command}.exe"), + format!("{command}.cmd"), + format!("{command}.bat"), + command.to_string(), + ] +} + +fn lookup_on_path(name: &str) -> Option { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|directory| directory.join(name)) + .find(|candidate| candidate.is_file()) +} + +fn jdk_tool(home: &str, tool: &str) -> Option { + let path = Path::new(home).join("bin").join(tool); + path.is_file().then(|| path.to_string_lossy().into_owned()) +} + +fn resolve_working_directory(root: &str, relative: &str) -> Result { + let relative = relative.trim(); + if relative.is_empty() || relative == "." { + return Ok(root.to_string()); + } + let candidate = Path::new(relative); + if candidate.is_absolute() + || candidate + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err("Working directory must stay inside the project.".to_string()); + } + let path = Path::new(root).join(candidate); + if !path.is_dir() { + return Err(format!("Working directory does not exist: {relative}")); + } + Ok(path.to_string_lossy().into_owned()) +} + +fn resolve_paths(root: &str, values: &[String]) -> Result, String> { + values + .iter() + .map(|value| { + let path = Path::new(value); + if path.is_absolute() { + Ok(value.clone()) + } else { + if path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err("Java path entries must stay inside the project.".to_string()); + } + Ok(Path::new(root).join(path).to_string_lossy().into_owned()) + } + }) + .collect() +} + +fn apply_plan_environment( + environment: &mut BTreeMap, + values: &BTreeMap, + java_home: Option<&str>, + maven_java_home: Option<&str>, +) { + for (key, value) in values { + let resolved = match value { + Value::String(value) => Some(value.clone()), + Value::Object(object) => { + let toolchain = object.get("toolchain").and_then(Value::as_str); + let property = object + .get("property") + .and_then(Value::as_str) + .unwrap_or("home"); + if property != "home" { + None + } else { + match toolchain { + Some("project-maven") => maven_java_home.map(str::to_string), + _ => java_home.map(str::to_string), + } + } + } + _ => None, + }; + if let Some(value) = resolved { + environment.insert(key.clone(), value); + } + } + if let Some(home) = java_home { + environment.insert("JAVA_HOME".to_string(), home.to_string()); + } +} + +fn base_environment() -> BTreeMap { + std::env::vars().collect() +} + +fn prepend_executable_path(environment: &mut BTreeMap, executable: &str) { + let Some(parent) = Path::new(executable).parent() else { + return; + }; + let mut directories = vec![parent.to_path_buf()]; + if let Some(path) = environment + .get("PATH") + .map(String::as_str) + .or_else(|| environment.get("Path").map(String::as_str)) + { + directories.extend(std::env::split_paths(path)); + } + directories.dedup(); + if let Ok(path) = std::env::join_paths(directories) { + environment.insert("PATH".to_string(), path.to_string_lossy().into_owned()); + } +} + +fn absolute_path(root: &str, value: &str) -> PathBuf { + let path = Path::new(value); + if path.is_absolute() { + path.to_path_buf() + } else { + Path::new(root).join(path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::Instant; + + static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0); + + struct Fixture { + root: PathBuf, + manager: ProcessManager, + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = self.manager.stop("test", None); + let _ = fs::remove_dir_all(&self.root); + } + } + + fn fixture(name: &str) -> Fixture { + let root = std::env::temp_dir().join(format!( + "lithe-linux-process-{name}-{}-{}", + std::process::id(), + TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&root).expect("fixture"); + Fixture { + root, + manager: ProcessManager::new(), + } + } + + fn shell_step(root: &Path, script: &str, label: &str) -> ProcessStep { + ProcessStep { + label: label.to_string(), + command: ProcessCommand { + program: "/bin/sh".to_string(), + arguments: vec!["-c".to_string(), script.to_string()], + working_directory: root.to_string_lossy().into_owned(), + environment: std::env::vars().collect(), + }, + } + } + + fn wait_for_finished( + receiver: &mpsc::Receiver, + deadline: Duration, + ) -> Vec { + let start = Instant::now(); + let mut events = Vec::new(); + while start.elapsed() < deadline { + let remaining = deadline.saturating_sub(start.elapsed()); + match receiver.recv_timeout(remaining) { + Ok(event) => { + let finished = matches!(&event, ProcessEvent::Finished { .. }); + events.push(event); + if finished { + return events; + } + } + Err(mpsc::RecvTimeoutError::Timeout) => break, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + events + } + + #[test] + fn prelaunch_failure_stops_before_main_step() { + let fixture = fixture("prelaunch-failure"); + let marker = fixture.root.join("main-ran"); + let marker_text = marker.to_string_lossy().into_owned(); + let steps = vec![ + shell_step(&fixture.root, "exit 7", "compiler"), + shell_step( + &fixture.root, + &format!("printf done > '{marker_text}'"), + "main", + ), + ]; + let (sender, receiver) = mpsc::channel(); + let handle = fixture + .manager + .start("test", "execution-1", steps, sender) + .expect("start"); + let events = wait_for_finished(&receiver, Duration::from_secs(2)); + handle.join().expect("join"); + assert!(events.iter().any(|event| matches!( + event, + ProcessEvent::Finished { + exit_code: Some(7), + .. + } + ))); + assert!( + !marker.exists(), + "main step must not run after prelaunch failure" + ); + } + + #[test] + fn stop_terminates_the_complete_process_group() { + let fixture = fixture("process-group"); + let script = "sleep 30 & child=$!; printf '%s\\n' \"$child\"; wait"; + let steps = vec![shell_step(&fixture.root, script, "tree")]; + let (sender, receiver) = mpsc::channel(); + let handle = fixture + .manager + .start("test", "execution-2", steps, sender) + .expect("start"); + let mut child_pid = None; + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline && child_pid.is_none() { + let remaining = deadline.saturating_duration_since(Instant::now()); + match receiver.recv_timeout(remaining) { + Ok(ProcessEvent::Output { text, .. }) => { + if let Ok(pid) = text.trim().parse::() { + child_pid = Some(pid); + } + } + Ok(ProcessEvent::Finished { .. }) => break, + Ok(_) => {} + Err(mpsc::RecvTimeoutError::Timeout) + | Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + let child_pid = child_pid.expect("fake process must report its child pid"); + fixture + .manager + .request_stop("test", Some("execution-2")) + .unwrap(); + let events = wait_for_finished(&receiver, Duration::from_secs(2)); + handle.join().expect("join"); + assert!(events.iter().any(|event| matches!( + event, + ProcessEvent::Finished { + cancelled: true, + .. + } + ))); + let gone_deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < gone_deadline { + let result = unsafe { libc::kill(child_pid, 0) }; + if result == -1 { + break; + } + thread::park_timeout(Duration::from_millis(10)); + } + assert_eq!( + unsafe { libc::kill(child_pid, 0) }, + -1, + "child process survived group stop" + ); + } + + #[test] + fn executable_toolchain_and_tool_map_to_linux_paths() { + let fixture = fixture("toolchain-map"); + let jdk = fixture.root.join("jdk"); + fs::create_dir_all(jdk.join("bin")).unwrap(); + fs::write(jdk.join("bin/java"), "java fixture").unwrap(); + fs::write(jdk.join("bin/javac"), "javac fixture").unwrap(); + let maven = fixture.root.join("maven/bin"); + fs::create_dir_all(&maven).unwrap(); + fs::write(maven.join("mvn"), "maven fixture").unwrap(); + let node = fixture.root.join("node/bin"); + fs::create_dir_all(&node).unwrap(); + fs::write(node.join("node"), "node fixture").unwrap(); + fs::write(node.join("npm"), "npm fixture").unwrap(); + let paths = ToolchainPaths { + java_home_path: jdk.to_string_lossy().into_owned(), + maven_executable_path: fixture.root.join("maven").to_string_lossy().into_owned(), + runtime_executable_paths: BTreeMap::from([( + "project-node".to_string(), + fixture.root.join("node/bin").to_string_lossy().into_owned(), + )]), + ..ToolchainPaths::default() + }; + let plan = LaunchPlan { + executable: LaunchExecutable { + toolchain: Some("project-jdk".to_string()), + command: None, + tool: Some("javac".to_string()), + }, + arguments: vec!["-version".to_string()], + working_directory: ".".to_string(), + environment: BTreeMap::new(), + env: BTreeMap::new(), + pre_launch_steps: vec![PreLaunchStep { + executable: LaunchExecutable { + toolchain: Some("project-jdk".to_string()), + command: None, + tool: Some("javac".to_string()), + }, + arguments: vec!["-version".to_string()], + classpath: Vec::new(), + modulepath: Vec::new(), + }], + classpath: Vec::new(), + modulepath: Vec::new(), + }; + let (steps, main) = resolve_launch(&plan, &fixture.root.to_string_lossy(), &paths).unwrap(); + assert!(main.program.ends_with("jdk/bin/javac")); + assert_eq!(steps.len(), 2); + assert!(steps[0].command.program.ends_with("jdk/bin/javac")); + + let mut maven_plan = plan.clone(); + maven_plan.executable = LaunchExecutable { + toolchain: Some("project-maven".to_string()), + command: None, + tool: None, + }; + maven_plan.pre_launch_steps.clear(); + let (_, maven) = + resolve_launch(&maven_plan, &fixture.root.to_string_lossy(), &paths).unwrap(); + assert!(maven.program.ends_with("maven/bin/mvn")); + + let mut node_plan = plan.clone(); + node_plan.executable = LaunchExecutable { + toolchain: None, + command: Some("npm".to_string()), + tool: None, + }; + node_plan.pre_launch_steps.clear(); + let (_, npm) = resolve_launch(&node_plan, &fixture.root.to_string_lossy(), &paths).unwrap(); + assert!(npm.program.ends_with("node/bin/npm")); + } + + #[test] + fn javac_prelaunch_creates_its_output_directory() { + let fixture = fixture("javac-output"); + let jdk = fixture.root.join("jdk"); + fs::create_dir_all(jdk.join("bin")).unwrap(); + fs::write(jdk.join("bin/java"), "java fixture").unwrap(); + fs::write(jdk.join("bin/javac"), "javac fixture").unwrap(); + let paths = ToolchainPaths { + java_home_path: jdk.to_string_lossy().into_owned(), + ..ToolchainPaths::default() + }; + let plan = LaunchPlan { + executable: LaunchExecutable { + toolchain: Some("project-jdk".to_string()), + command: None, + tool: None, + }, + arguments: vec!["demo.App".to_string()], + working_directory: ".".to_string(), + environment: BTreeMap::new(), + env: BTreeMap::new(), + pre_launch_steps: vec![PreLaunchStep { + executable: LaunchExecutable { + toolchain: Some("project-jdk".to_string()), + command: None, + tool: Some("javac".to_string()), + }, + arguments: vec![ + "-d".to_string(), + ".lithe/run/classes/current-file".to_string(), + "src/App.java".to_string(), + ], + classpath: Vec::new(), + modulepath: Vec::new(), + }], + classpath: vec![".lithe/run/classes/current-file".to_string()], + modulepath: Vec::new(), + }; + resolve_launch(&plan, &fixture.root.to_string_lossy(), &paths).unwrap(); + assert!(fixture + .root + .join(".lithe/run/classes/current-file") + .is_dir()); + } +} diff --git a/linux/src/workbench/settings_dialog.rs b/linux/src/workbench/settings_dialog.rs index 8342ceac5..08edadfaa 100644 --- a/linux/src/workbench/settings_dialog.rs +++ b/linux/src/workbench/settings_dialog.rs @@ -20,8 +20,13 @@ use gpui_kit::{ StatefulInteractiveElement as _, Styled as _, Window, }; +use crate::core::CoreClient; use crate::settings::{self, Settings}; use crate::theme::ThemeColors; +use crate::workbench::run::{ + default_generated_configuration_id, list_java_sources, parse_resolved_configurations, + read_toolchain_paths, write_generated_documents, write_toolchain_paths, ToolchainPaths, +}; /// 设置分类,顺序与 Tauri `categories` 数组一致。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -134,11 +139,12 @@ impl SettingsCategory { } } -/// 对话框对外事件:关闭,以及设置已变更(宿主可据此同步其它视图)。 +/// 对话框对外事件:关闭、普通设置变更,以及 Run 文档/工具链变更。 #[derive(Debug, Clone)] pub enum SettingsEvent { Close, Changed, + RunConfigurationChanged, } /// 居中设置面板(宽 820px,高 620px)。 @@ -164,6 +170,8 @@ pub struct SettingsDialog { project_status: String, run_configs: Vec, run_status: String, + run_load_seq: u64, + client: CoreClient, /// AI 模型单行输入(provider 切换不重置,由用户显式修改)。 ai_model_input: Option>, /// Git 可执行路径单行输入。 @@ -199,6 +207,8 @@ impl SettingsDialog { project_status: String::new(), run_configs: Vec::new(), run_status: String::new(), + run_load_seq: 0, + client: CoreClient::new(), ai_model_input: None, git_exe_input: None, log_dir_input: None, @@ -207,19 +217,134 @@ impl SettingsDialog { } } + /// 切换工作区并使旧的 Run 配置加载结果失效。 + pub fn set_workspace_root(&mut self, root: String, cx: &mut Context) { + if self.workspace_root == root { + return; + } + self.workspace_root = root; + self.run_load_seq += 1; + self.run_configs.clear(); + self.run_status.clear(); + self.project_loaded_for.clear(); + self.project_status.clear(); + cx.notify(); + } + /// 切换分类并把 `lastSettingsTab` 写回磁盘,保证下次打开停在同一页。 pub fn set_category(&mut self, cat: SettingsCategory, cx: &mut Context) { self.active_category = cat; settings::update(cx, |s| s.last_settings_tab = cat.id().to_string()); + if cat == SettingsCategory::Run { + self.refresh_run_configs(false, cx); + } cx.notify(); } /// 打开对话框时按持久化的 `lastSettingsTab` 重置分类。 pub fn open(&mut self, cx: &mut Context) { self.active_category = SettingsCategory::from_id(&settings::get(cx).last_settings_tab); + if self.active_category == SettingsCategory::Run { + self.refresh_run_configs(false, cx); + } cx.notify(); } + fn refresh_run_configs(&mut self, generate: bool, cx: &mut Context) { + self.run_load_seq += 1; + let seq = self.run_load_seq; + let root = self.workspace_root.clone(); + let client = self.client.clone(); + self.run_status = if generate { + "生成中…".to_string() + } else { + "加载中…".to_string() + }; + cx.notify(); + cx.spawn(async move |this, cx| { + let result: Result, String> = async { + if generate { + let generated = client + .execute::( + &cx, + "runConfig.generate", + serde_json::json!({ + "root": root, + "paths": list_java_sources(&root), + "modulePaths": [] + }), + ) + .await?; + if !this + .update(cx, |view, _cx| view.run_load_seq == seq) + .unwrap_or(false) + { + return Err("stale run configuration request".to_string()); + } + let document = generated + .get("generated") + .cloned() + .unwrap_or_else(|| serde_json::json!({"version": 2, "configurations": []})); + let requirements = generated + .get("toolchainRequirements") + .cloned() + .unwrap_or_else(|| serde_json::json!({"version": 1, "toolchains": {}})); + write_generated_documents( + &root, + &document, + &requirements, + default_generated_configuration_id(&document).as_deref(), + )?; + } + let inspection = client + .execute::( + &cx, + "runConfig.inspect", + serde_json::json!({ "root": root, "checkFingerprint": true }), + ) + .await?; + if inspection.get("status").and_then(serde_json::Value::as_str) != Some("ready") { + return Err("Run configuration has not been generated".to_string()); + } + let resolved = client + .execute::( + &cx, + "runConfig.resolve", + serde_json::json!({ "root": root, "toolchainCandidates": [] }), + ) + .await?; + let parsed = parse_resolved_configurations(&resolved)?; + Ok(parsed + .configurations + .into_iter() + .map(|item| format!("{} ({})", item.name, item.provider)) + .collect()) + } + .await; + let _ = this.update(cx, |view, cx| { + if view.run_load_seq != seq { + return; + } + match result { + Ok(configs) => { + view.run_configs = configs; + view.run_status = if view.run_configs.is_empty() { + "暂无可运行配置".to_string() + } else { + String::new() + }; + if generate { + cx.emit(SettingsEvent::RunConfigurationChanged); + } + } + Err(error) => view.run_status = error, + } + cx.notify(); + }); + }) + .detach(); + } + /// 恢复本地占位项的默认值(不涉及持久化)。 fn reset_placeholders(&mut self) { self.git_policy = "ask".to_string(); @@ -233,6 +358,7 @@ impl SettingsDialog { self.project_status = String::new(); self.run_configs = Vec::new(); self.run_status = String::new(); + self.run_load_seq = self.run_load_seq.saturating_add(1); self.ai_model_input = None; self.git_exe_input = None; self.log_dir_input = None; @@ -1152,7 +1278,7 @@ impl SettingsDialog { fill( &mut self.project_jdk_input, if jdk.is_empty() { - which("java").unwrap_or_default() + which_java_home().unwrap_or_default() } else { jdk }, @@ -1284,6 +1410,9 @@ impl SettingsDialog { "保存失败".to_string() }; cx.emit(SettingsEvent::Changed); + if ok { + cx.emit(SettingsEvent::RunConfigurationChanged); + } cx.notify(); })), ), @@ -1292,13 +1421,8 @@ impl SettingsDialog { ) } - /// 运行配置:读写 `/.lithe/run/configurations.json` - ///(对齐 Mac `MacRunConfigurationStore` 的项目级配置),列表展示已有 - /// 配置,生成按钮追加默认 Java 配置。 + /// 运行配置:通过 Core inspect/resolve 读取,生成按钮走 Core generate。 fn render_run_content(&mut self, cx: &mut Context) -> impl IntoElement { - if self.run_configs.is_empty() && self.run_status.is_empty() { - self.run_configs = read_run_config_names(&self.workspace_root); - } let configs = self.run_configs.clone(); let status = self.run_status.clone(); let list = if configs.is_empty() { @@ -1354,16 +1478,7 @@ impl SettingsDialog { .to_string(), ) .on_click(cx.listener(|this, _event, _window, cx| { - let root = this.workspace_root.clone(); - let ok = append_default_run_config(&root); - this.run_configs = read_run_config_names(&root); - this.run_status = if ok { - "已生成默认配置".to_string() - } else { - "生成失败".to_string() - }; - cx.emit(SettingsEvent::Changed); - cx.notify(); + this.refresh_run_configs(true, cx); })), ), ), @@ -3033,6 +3148,15 @@ fn which(exe: &str) -> Option { None } +fn which_java_home() -> Option { + let executable = which("java")?; + let path = std::fs::canonicalize(&executable) + .unwrap_or_else(|_| std::path::PathBuf::from(&executable)); + path.parent() + .and_then(std::path::Path::parent) + .map(|home| home.to_string_lossy().into_owned()) +} + /// Linux 可用 shell 探测:仅收录真实存在的系统 shell(对齐 Tauri 终端面板 /// 的“探测 shells”行为,Windows 的 powershell/cmd/wsl 在 Linux 无意义)。 fn detect_linux_shells() -> Vec { @@ -3048,128 +3172,26 @@ fn detect_linux_shells() -> Vec { shells } -/// 项目 local.json 路径(`/.lithe/run/local.json`,对齐 Mac -/// `MacRunConfigurationStore` 的本机配置位置)。 -fn project_local_json(workspace_root: &str) -> std::path::PathBuf { - std::path::Path::new(workspace_root) - .join(".lithe") - .join("run") - .join("local.json") -} - -/// 读项目工具链(javaHome/mavenExecutable/mavenJavaHome 三键,缺失即空)。 +/// 读项目工具链;canonical 键优先,兼容历史 Linux 扁平键。 fn read_local_toolchain(workspace_root: &str) -> (String, String, String) { - let Ok(text) = std::fs::read_to_string(project_local_json(workspace_root)) else { - return (String::new(), String::new(), String::new()); - }; - let Ok(value) = serde_json::from_str::(&text) else { - return (String::new(), String::new(), String::new()); - }; - let toolchain = value.get("toolchain"); - let get = |key: &str| { - toolchain - .and_then(|t| t.get(key)) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string() - }; + let paths = read_toolchain_paths(workspace_root); ( - get("javaHome"), - get("mavenExecutable"), - get("mavenJavaHome"), + paths.java_home_path, + paths.maven_executable_path, + paths.maven_java_home_path, ) } -/// 写项目工具链(JSON merge,保留 local.json 其余键)。 +/// 写项目工具链;使用 canonical 键并保留 local.json 其它配置。 fn write_local_toolchain(workspace_root: &str, jdk: &str, maven: &str, maven_jdk: &str) -> bool { - let path = project_local_json(workspace_root); - if let Some(parent) = path.parent() { - if std::fs::create_dir_all(parent).is_err() { - return false; - } - } - let mut value: serde_json::Value = std::fs::read_to_string(&path) - .ok() - .and_then(|text| serde_json::from_str(&text).ok()) - .unwrap_or_else(|| serde_json::json!({})); - if !value.is_object() { - value = serde_json::json!({}); - } - let toolchain = value - .as_object_mut() - .expect("object checked") - .entry("toolchain") - .or_insert_with(|| serde_json::json!({})); - if let Some(map) = toolchain.as_object_mut() { - map.insert("javaHome".to_string(), serde_json::json!(jdk)); - map.insert("mavenExecutable".to_string(), serde_json::json!(maven)); - map.insert("mavenJavaHome".to_string(), serde_json::json!(maven_jdk)); - } - serde_json::to_string_pretty(&value) - .ok() - .and_then(|text| std::fs::write(&path, text).ok()) - .is_some() -} - -/// 运行配置文件路径(`/.lithe/run/configurations.json`)。 -fn run_configurations_path(workspace_root: &str) -> std::path::PathBuf { - std::path::Path::new(workspace_root) - .join(".lithe") - .join("run") - .join("configurations.json") -} - -/// 读运行配置名列表(兼容数组与 `{configurations:[]}` 两种形状)。 -fn read_run_config_names(workspace_root: &str) -> Vec { - let Ok(text) = std::fs::read_to_string(run_configurations_path(workspace_root)) else { - return Vec::new(); - }; - let Ok(value) = serde_json::from_str::(&text) else { - return Vec::new(); - }; - let list = if let Some(arr) = value.as_array() { - arr.clone() - } else if let Some(arr) = value.get("configurations").and_then(|v| v.as_array()) { - arr.clone() - } else { - return Vec::new(); - }; - list.iter() - .filter_map(|item| { - let name = item.get("name").and_then(|n| n.as_str()).unwrap_or("?"); - let kind = item.get("type").and_then(|t| t.as_str()).unwrap_or(""); - if kind.is_empty() { - Some(name.to_string()) - } else { - Some(format!("{name} ({kind})")) - } - }) - .collect() -} - -/// 追加一个默认 Java 运行配置(保留已有配置)。 -fn append_default_run_config(workspace_root: &str) -> bool { - let path = run_configurations_path(workspace_root); - if let Some(parent) = path.parent() { - if std::fs::create_dir_all(parent).is_err() { - return false; - } - } - let mut list: Vec = std::fs::read_to_string(&path) - .ok() - .and_then(|text| serde_json::from_str::(&text).ok()) - .and_then(|v| { - if let Some(arr) = v.as_array() { - Some(arr.clone()) - } else { - v.get("configurations").and_then(|c| c.as_array()).cloned() - } - }) - .unwrap_or_default(); - list.push(serde_json::json!({"name": "Run Main", "type": "java", "mainClass": "Main"})); - let value = serde_json::json!({"configurations": list}); - serde_json::to_string_pretty(&value) - .ok() - .and_then(|text| std::fs::write(&path, text).ok()) - .is_some() + write_toolchain_paths( + workspace_root, + &ToolchainPaths { + java_home_path: jdk.to_string(), + maven_executable_path: maven.to_string(), + maven_java_home_path: maven_jdk.to_string(), + ..ToolchainPaths::default() + }, + ) + .is_ok() } diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index 6d00bcdc0..bcbf39db8 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -3,6 +3,7 @@ //! 结构对齐 Tauri `MainLayout`:顶栏 / 多项目标签条 / 活动栏 + 侧边栏 + 编辑区 + //! 右侧插件活动栏 / 底部面板 / 状态栏;无项目时显示欢迎页,浮层由模态状态控制。 +use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::component::resizable::{h_resizable, resizable_panel, v_resizable, ResizableState}; use gpui_kit::component::{h_flex, v_flex, Sizable as _}; @@ -180,12 +181,18 @@ impl WorkbenchView { pane_editors.insert(initial_pane, editor.clone()); Self::wire_pane_editor(&pane_tree, cx.entity(), initial_pane, &editor, cx); let bottom_panel = cx.new(|cx| BottomPanelView::new(root.clone(), cx)); + bottom_panel.update(cx, |panel, cx| { + panel.set_run_editor(Some(editor.clone()), cx); + }); let status_bar = cx.new(|_cx| StatusBarView::new()); let search_everywhere = cx.new(|cx| SearchEverywhereModal::new(cx)); let quick_open = cx.new(|cx| QuickOpenModal::new(cx)); let go_to_line = cx.new(|cx| GoToLineModal::new(cx)); let command_palette = cx.new(|cx| CommandPaletteModal::new(cx)); let settings_dialog = cx.new(|cx| SettingsDialog::new(cx)); + settings_dialog.update(cx, |dialog, cx| { + dialog.set_workspace_root(root.clone(), cx); + }); let project_dialog = cx.new(|cx| ProjectDialog::new(cx)); let branch_manager_root = root.clone(); let branch_manager = cx.new(|cx| BranchManagerView::new(branch_manager_root, cx)); @@ -694,6 +701,12 @@ impl WorkbenchView { SettingsEvent::Changed => { cx.notify(); } + SettingsEvent::RunConfigurationChanged => { + let _ = this.bottom_panel.update(cx, |panel, cx| { + panel.reload_run_project(cx); + }); + cx.notify(); + } }, ); @@ -1010,7 +1023,9 @@ impl WorkbenchView { /// 打开底部 Run 页并运行选中配置:对齐 Tauri `openRunDecisionPane` + /// `runConfiguration`(页签可见 + 受管进程,不进交互终端)。 fn open_run_pane(&mut self, cx: &mut Context) { + let editor = self.active_editor(); let _ = self.bottom_panel.update(cx, |bp, cx| { + bp.set_run_editor(editor, cx); bp.set_tab(BottomTab::Run, cx); bp.run_selected_config(cx); }); @@ -1023,6 +1038,9 @@ impl WorkbenchView { } self.workspace_root = path.clone(); self.show_welcome = false; + let _ = self + .settings_dialog + .update(cx, |dialog, cx| dialog.set_workspace_root(path.clone(), cx)); // 同步工具栏当前项目行(仿分支同步写法)。 let _ = self.toolbar.update(cx, |tb, cx| { tb.set_workspace_root(path.clone(), cx); @@ -1040,6 +1058,7 @@ impl WorkbenchView { let _ = self.bottom_panel.update(cx, |bp, cx| { bp.set_working_dir(path.clone(), cx); }); + self.sync_run_editor(cx); // 右侧通知中心投递项目打开事件(对齐 Tauri 系统事件通知)。 let opened = crate::i18n::menu_text(cx, "notifications.projectOpened").to_string(); let _ = self.notifications.update(cx, |n, cx| { @@ -1114,15 +1133,11 @@ impl WorkbenchView { .and_then(|id| self.pane_editors.get(&id).cloned()) } - /// 新文件路由编辑器(`route_target()` 落 map,未命中回退 active; - /// 锁定窗格自动跳过由模型保证)。 - fn routed_editor(&self) -> Option> { - if let Some(id) = self.pane_tree.route_target() { - if let Some(ed) = self.pane_editors.get(&id).cloned() { - return Some(ed); - } - } - self.active_editor() + fn sync_run_editor(&mut self, cx: &mut Context) { + let editor = self.active_editor(); + let _ = self + .bottom_panel + .update(cx, |panel, cx| panel.set_run_editor(editor, cx)); } /// 编辑器文档变化:选择语言服务器、按需启动会话并同步全文。 @@ -1365,6 +1380,16 @@ impl WorkbenchView { .update(cx, |panel, cx| panel.set_diagnostics(entries, cx)); } + /// 新文件路由编辑器(`route_target()` 落 map,未命中回退 active; + /// 锁定窗格自动跳过由模型保证)。 + fn routed_editor(&self) -> Option> { + if let Some(id) = self.pane_tree.route_target() { + if let Some(ed) = self.pane_editors.get(&id).cloned() { + return Some(ed); + } + } + self.active_editor() + } /// 活动编辑器上执行动作(无活动窗格时忽略)。 fn with_active_editor( @@ -1443,6 +1468,7 @@ impl WorkbenchView { fn close_pane(&mut self, pane_id: PaneId, cx: &mut Context) { if self.pane_tree.close_leaf(pane_id) { self.pane_editors.remove(&pane_id); + self.sync_run_editor(cx); cx.notify(); } } @@ -1975,6 +2001,7 @@ impl WorkbenchView { if let Some(pid) = target_pane { let _ = this.update(cx, |this, cx| { this.pane_tree.set_active(pid); + this.sync_run_editor(cx); cx.notify(); }); } @@ -2370,13 +2397,17 @@ impl WorkbenchView { } } - /// 渲染单个窗格叶:对应 editor;整个叶包左键设 active;空标签叶居中 - /// 显示关闭窗格按钮(根叶关闭返回 `false` 时忽略)。 + /// 渲染单个窗格叶:对应 editor;整个叶包左键设 active。空拆分窗格保留 + /// 编辑器空态,并在右上角提供关闭操作;根窗格不可关闭,不显示该操作。 fn render_pane_leaf(&self, id: PaneId, cx: &mut Context) -> AnyElement { let Some(editor) = self.pane_editors.get(&id).cloned() else { return div().size_full().into_any_element(); }; let is_empty = editor.read(cx).tabs.is_empty(); + let can_close = !matches!( + self.pane_tree.root(), + Some(PaneNode::Leaf(root_id)) if *root_id == id + ); let base = v_flex() .size_full() .min_w_0() @@ -2386,25 +2417,28 @@ impl WorkbenchView { MouseButton::Left, cx.listener(move |this, _event: &MouseDownEvent, _window, cx| { this.pane_tree.set_active(id); + this.sync_run_editor(cx); cx.notify(); }), ); - if is_empty { - base.items_center() - .justify_center() - .child( + let pane = div().relative().size_full().child(editor); + let pane = if is_empty && can_close { + pane.child( + div().absolute().top_1().right_1().child( Button::new(format!("close-pane-{id}")) - .small() + .xsmall() .ghost() - .label(crate::i18n::menu_text(cx, "ui.close").to_string()) + .icon(IconName::Close) + .tooltip(crate::i18n::menu_text(cx, "ui.close")) .on_click(cx.listener(move |this, _event, _window, cx| { this.close_pane(id, cx); })), - ) - .into_any_element() + ), + ) } else { - base.child(editor).into_any_element() - } + pane + }; + base.child(pane).into_any_element() } fn render_sidebar_splitter(&self, cx: &mut Context) -> impl IntoElement { From c9f766e03f0861c5c2f7261705882d8cd7918aaa Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 17:20:47 +0800 Subject: [PATCH 056/109] =?UTF-8?q?feat(linux):=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E6=B4=BE=E7=94=9F=E7=9A=84=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E5=BF=AB=E6=8D=B7=E9=94=AE=E4=B8=8E=E5=8F=8C=E5=87=BB=20Shift?= =?UTF-8?q?=20=E6=90=9C=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux GPUI 工作台此前只有少量硬编码快捷键,且与菜单展示的快捷键不一致: 菜单写着 Ctrl+F / Ctrl+Shift+E / Ctrl+Shift+P 等,按下却无响应,原因是 `toolbar.rs` 的菜单表与 `view.rs` 的 `on_key_down` 各写了一份定义。 本次把「菜单表」确立为快捷键唯一真源: - 新增 `linux/src/keybindings.rs` - `KeyStrokeId`:归一化修饰键与主键名(`Ctrl+Shift+P` / `Alt+Up` / `F11`)。 - `KeymapIndex`:启动时从菜单表构建「按键 → 动作 id」索引,并保留无法 解析的写法供自检。 - `DoubleShiftRecognizer`:移植 Tauri `DoubleShiftGestureRecognizer`, 仅统计独立 Shift 的两次抬手,350ms 时间窗内触发;期间出现其它按键或 Ctrl/Alt/Super 会取消手势。 - `toolbar.rs` 新增 `menu_shortcut_entries()`:展平菜单快捷键表,跳过 `disabled` 条目并按菜单顺序去重,避免禁用项(如 `go.references` 的 Ctrl+B)抢走可用项按键。 - `view.rs` 按键监听改为查表分发:命中后编辑器动作经 `editor_direct` 直达(需要 `&mut Window`),其余统一交给既有 `handle_action`,与菜单项、 命令面板共用同一套动作分支;弹窗打开时不抢键,保证输入框正常打字。 - 新增 `.on_modifiers_changed` 监听:双击 Shift 打开全局搜索 (`view.global_search`),打开任意弹窗时重置手势状态,避免跨上下文拼接。 - 启动时自检快捷键表,出现不可解析写法或空表时输出到日志。 生效范围(42 条,全部与菜单展示一致):文件(新建/打开/保存/关闭/重开)、 编辑(撤销/重做/剪贴板/注释/复制行/删除行/上下移动行/查找/替换)、 视图(命令面板/活动栏/侧边栏/底部面板/全局搜索/诊断/资源管理器/Git/ 自动换行/缩放)、跳转(快速打开/跳转行/上下标签)、窗口(最小化/最大化/ 全屏)。双击 Shift 触发搜索与 Tauri/IntelliJ 行为对齐。 验证:`cargo check` 无警告;`cargo test --lib` 33 通过(含 10 条快捷键 与双击 Shift 单测,覆盖解析、冲突、禁用项过滤与时间窗边界)。 --- linux/src/keybindings.rs | 406 +++++++++++++++++++++++++++++++++ linux/src/lib.rs | 1 + linux/src/main.rs | 1 + linux/src/workbench/toolbar.rs | 26 +++ linux/src/workbench/view.rs | 191 +++++++++++----- 5 files changed, 571 insertions(+), 54 deletions(-) create mode 100644 linux/src/keybindings.rs diff --git a/linux/src/keybindings.rs b/linux/src/keybindings.rs new file mode 100644 index 000000000..8fcd96193 --- /dev/null +++ b/linux/src/keybindings.rs @@ -0,0 +1,406 @@ +//! 快捷键真源:把「按键 → 动作 id」集中成一张表,供按键分发与菜单展示共用。 +//! +//! 背景(对齐 Tauri `features/keymaps`):Tauri 侧以 `defaultKeymaps` 为唯一真源, +//! 菜单项从注册表读取展示文本,按键事件也从同一张表解析动作,两者不会漂移。 +//! Linux 侧原先把快捷键硬编码在 `view.rs` 的 `on_key_down` 里,又另在 +//! `toolbar.rs` 的菜单表写一遍展示文本,存在两处真源。 +//! +//! 本模块把「菜单表已有的快捷键展示文本」作为唯一输入,编译期生成按键索引, +//! 因此新增菜单项时:只要菜单写了快捷键,按键就自动生效,不需要改两份代码。 +//! +//! 不变量: +//! - 同一个按键序列在本表中最多映射一个动作 id; +//! - 动作 id 必须能在 `WorkbenchView::handle_action` 找到分支,否则视为无效绑定; +//! - 双击 Shift 不是普通按键绑定(需要跨事件计时),单独由 +//! [`DoubleShiftRecognizer`] 处理,见 `view.rs` 的按键监听。 + +use std::collections::HashMap; + +use crate::workbench::toolbar::menu_shortcut_entries; + +/// 双击 Shift 触发全局搜索的时间窗(秒)。 +/// +/// 对齐 Tauri `DOUBLE_SHIFT_THRESHOLD_SECONDS`(IntelliJ 同款手势)。 +pub const DOUBLE_SHIFT_THRESHOLD_SECONDS: f64 = 0.35; + +/// 双击 Shift 对应的动作 id(Tauri `searchEverywhere`)。 +pub const DOUBLE_SHIFT_ACTION: &str = "view.global_search"; + +/// 一条按键绑定:展示用快捷键文本 + 目标动作 id。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KeymapEntry { + /// 菜单里展示的写法(如 `Ctrl+Shift+P`),也是解析输入。 + pub shortcut: &'static str, + /// 稳定动作 id,与 `WorkbenchView::handle_action` 的分支一致。 + pub action: &'static str, +} + +/// 全局快捷键表,由菜单表派生,保证「菜单显示什么键,什么键就生效」。 +pub fn default_keymap() -> Vec { + menu_shortcut_entries() + .iter() + .map(|(shortcut, action)| KeymapEntry { shortcut, action }) + .collect() +} + +/// 解析后的按键索引:归一化按键序列 → 动作 id。 +/// +/// GPUI 侧查表用 [`KeyStrokeId`],避免每次按键都做字符串拼接。 +pub struct KeymapIndex { + by_id: HashMap, + /// 无法解析的展示文本(应当为空;保留用于诊断与测试断言)。 + invalid: Vec<&'static str>, +} + +impl KeymapIndex { + /// 从 [`default_keymap`] 构建索引。 + pub fn build() -> Self { + let mut by_id = HashMap::new(); + let mut invalid = Vec::new(); + for entry in default_keymap() { + match KeyStrokeId::parse(entry.shortcut) { + Some(id) => { + // 冲突时保留先出现的绑定,并按菜单顺序稳定覆盖: + // 菜单表本身若有重复键,后写会覆盖先写,这里显式记录。 + by_id.insert(id, entry.action); + } + None => invalid.push(entry.shortcut), + } + } + Self { by_id, invalid } + } + + /// 精确查表:修饰键与主键完全一致才算命中。 + pub fn lookup(&self, id: &KeyStrokeId) -> Option<&'static str> { + self.by_id.get(id).copied() + } + + /// 解析失败的展示文本(用于测试)。 + pub fn invalid_shortcuts(&self) -> &[&'static str] { + &self.invalid + } + + /// 已生效的绑定数量(用于测试)。 + pub fn len(&self) -> usize { + self.by_id.len() + } + + /// 是否为空表。 + pub fn is_empty(&self) -> bool { + self.by_id.is_empty() + } +} + +/// 归一化按键标识:修饰键集合 + 归一化主键名。 +/// +/// GPUI 的 `Keystroke.key` 对字母是小写、对符号是字面符号(如 `=`、`/`、`` ` ``), +/// 而菜单展示文本用 `Ctrl+=` 这类可读写法。这里把两侧都归一到同一形式。 +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct KeyStrokeId { + pub control: bool, + pub alt: bool, + pub shift: bool, + pub platform: bool, + /// 归一化主键:字母小写,`+`/`=`/`-` 等符号原样,功能键小写(`f11`)。 + pub key: String, +} + +impl KeyStrokeId { + /// 从菜单展示文本解析(如 `Ctrl+Shift+P`、`Alt+Up`、`F11`、`Ctrl+/`)。 + /// + /// 无法识别时返回 `None`,由调用方归入 `invalid` 报告。 + pub fn parse(shortcut: &str) -> Option { + let trimmed = shortcut.trim(); + if trimmed.is_empty() { + return None; + } + let mut control = false; + let mut alt = false; + let mut shift = false; + let mut platform = false; + let mut key: Option = None; + + for part in trimmed.split('+') { + if part.is_empty() { + // `Ctrl++`(加号本身)在菜单里写成 `Ctrl+=`,因此空段非法。 + return None; + } + match part { + "Ctrl" | "Control" | "CmdOrCtrl" => control = true, + "Alt" | "Option" => alt = true, + "Shift" => shift = true, + "Meta" | "Cmd" | "Command" | "Super" | "Win" => platform = true, + other => { + if key.is_some() { + // 多主键(含空格分段的序列)不由本表表达。 + return None; + } + key = Some(normalize_key(other)); + } + } + } + + let key = key?; + Some(Self { + control, + alt, + shift, + platform, + key, + }) + } + + /// 从 GPUI `KeyDownEvent` 的按键与修饰键状态构建。 + pub fn from_event(key: &str, modifiers: &gpui_kit::Modifiers) -> Self { + Self { + control: modifiers.control, + alt: modifiers.alt, + shift: modifiers.shift, + platform: modifiers.platform, + key: normalize_key(key), + } + } +} + +/// 主键名归一化:字母统一小写,其余原样;并把菜单里的可读名映射到 GPUI 名。 +fn normalize_key(key: &str) -> String { + match key { + "Up" => "up".to_string(), + "Down" => "down".to_string(), + "Left" => "left".to_string(), + "Right" => "right".to_string(), + "Enter" | "Return" => "enter".to_string(), + "Esc" | "Escape" => "escape".to_string(), + "Space" => "space".to_string(), + "Tab" => "tab".to_string(), + "Backspace" => "backspace".to_string(), + "Delete" => "delete".to_string(), + "Home" => "home".to_string(), + "End" => "end".to_string(), + "PageUp" => "pageup".to_string(), + "PageDown" => "pagedown".to_string(), + "`" | "~" => "`".to_string(), + "=" | "+" => "=".to_string(), + "-" | "_" => "-".to_string(), + other => other.to_ascii_lowercase(), + } +} + +/// 双击 Shift 手势识别器:移植 Tauri `DoubleShiftGestureRecognizer`。 +/// +/// 语义与 Tauri 一致: +/// - 只统计「独立按下 Shift」(按下时没有 Ctrl/Alt/Meta 同时按下); +/// - 两次独立 Shift 抬起的时间差落在 [`DOUBLE_SHIFT_THRESHOLD_SECONDS`] 内才算双击; +/// - 期间出现其它按键或非 Shift 修饰键会取消手势,避免误触。 +/// +/// 时间戳由调用方提供(GPUI 事件不带时间,`view.rs` 用 `Instant`)。 +#[derive(Debug, Default)] +pub struct DoubleShiftRecognizer { + shift_was_down: bool, + current_press_is_standalone: bool, + last_standalone_tap: Option, +} + +impl DoubleShiftRecognizer { + /// 新建识别器。 + pub fn new() -> Self { + Self::default() + } + + /// 普通按键按下:当前 Shift 按压不再算「独立」。 + pub fn handle_key_down(&mut self) { + self.current_press_is_standalone = false; + self.last_standalone_tap = None; + } + + /// 焦点切换/窗口失焦等场景重置,避免跨上下文的两次 Shift 被拼接。 + pub fn reset(&mut self) { + self.shift_was_down = false; + self.current_press_is_standalone = false; + self.last_standalone_tap = None; + } + + /// 修饰键状态变化。返回 `true` 表示识别到双击 Shift。 + /// + /// - `is_shift_down`:当前 Shift 是否按下; + /// - `has_other_modifiers`:是否有 Ctrl/Alt/Meta 同时按下; + /// - `timestamp`:秒级单调时间。 + pub fn handle_modifiers_changed( + &mut self, + is_shift_down: bool, + has_other_modifiers: bool, + timestamp: f64, + ) -> bool { + if is_shift_down && !self.shift_was_down { + self.current_press_is_standalone = !has_other_modifiers; + self.shift_was_down = true; + return false; + } + + if is_shift_down && self.shift_was_down { + if has_other_modifiers { + self.current_press_is_standalone = false; + self.last_standalone_tap = None; + } + return false; + } + + if !is_shift_down && self.shift_was_down { + self.shift_was_down = false; + let was_standalone = self.current_press_is_standalone; + self.current_press_is_standalone = false; + if !was_standalone || has_other_modifiers { + self.last_standalone_tap = None; + return false; + } + if let Some(last) = self.last_standalone_tap { + let delta = timestamp - last; + if delta >= 0.0 && delta < DOUBLE_SHIFT_THRESHOLD_SECONDS { + self.last_standalone_tap = None; + return true; + } + } + self.last_standalone_tap = Some(timestamp); + return false; + } + + if has_other_modifiers { + self.last_standalone_tap = None; + } + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_menu_shortcuts() { + let id = KeyStrokeId::parse("Ctrl+Shift+P").expect("parse"); + assert!(id.control && id.shift && !id.alt && !id.platform); + assert_eq!(id.key, "p"); + + let id = KeyStrokeId::parse("Alt+Up").expect("parse"); + assert!(id.alt); + assert_eq!(id.key, "up"); + + let id = KeyStrokeId::parse("F11").expect("parse"); + assert!(!id.control && id.key == "f11"); + + let id = KeyStrokeId::parse("Ctrl+/").expect("parse"); + assert_eq!(id.key, "/"); + + let id = KeyStrokeId::parse("Ctrl+=").expect("parse"); + assert_eq!(id.key, "="); + } + + #[test] + fn rejects_invalid_shortcuts() { + assert!(KeyStrokeId::parse("").is_none()); + assert!(KeyStrokeId::parse("Ctrl+A+B").is_none()); + assert!(KeyStrokeId::parse("Ctrl+").is_none()); + } + + #[test] + fn keymap_only_surfaces_menu_shortcuts() { + let index = KeymapIndex::build(); + assert!( + index.invalid_shortcuts().is_empty(), + "菜单快捷键存在无法解析的写法: {:?}", + index.invalid_shortcuts() + ); + assert!(!index.is_empty()); + } + + #[test] + fn keymap_lookup_matches_menu_labels() { + let index = KeymapIndex::build(); + let id = KeyStrokeId::parse("Ctrl+Shift+P").expect("parse"); + assert_eq!(index.lookup(&id), Some("view.command_palette")); + + let id = KeyStrokeId::parse("Ctrl+P").expect("parse"); + assert_eq!(index.lookup(&id), Some("view.quick_open")); + + let id = KeyStrokeId::parse("Ctrl+B").expect("parse"); + assert_eq!(index.lookup(&id), Some("view.toggle_activity_rail")); + + let id = KeyStrokeId::parse("Ctrl+E").expect("parse"); + assert_eq!(index.lookup(&id), Some("view.toggle_sidebar")); + + let id = KeyStrokeId::parse("Ctrl+Shift+F").expect("parse"); + assert_eq!(index.lookup(&id), Some("view.global_search")); + + let id = KeyStrokeId::parse("Alt+Z").expect("parse"); + assert_eq!(index.lookup(&id), Some("view.toggle_wrap")); + + let id = KeyStrokeId::parse("Ctrl+/ ").expect("parse"); + assert_eq!(index.lookup(&id), Some("edit.toggle_comment")); + + let id = KeyStrokeId::parse("Ctrl+T").expect("parse"); + assert_eq!(index.lookup(&id), Some("file.new_tab")); + + let id = KeyStrokeId::parse("Ctrl+W").expect("parse"); + assert_eq!(index.lookup(&id), Some("file.close_editor")); + + let id = KeyStrokeId::parse("F11").expect("parse"); + assert_eq!(index.lookup(&id), Some("window.fullscreen")); + } + + #[test] + fn disabled_menu_shortcuts_are_not_bound() { + let index = KeymapIndex::build(); + // `go.references`(禁用)与 `view.toggle_activity_rail` 共用 Ctrl+B: + // 禁用项不得抢走可用项的按键。 + let id = KeyStrokeId::parse("Ctrl+B").expect("parse"); + assert_ne!(index.lookup(&id), Some("go.references")); + // F5/F9 等调试快捷键后端未接入,不应绑定。 + assert_eq!(index.lookup(&KeyStrokeId::parse("F5").expect("parse")), None); + assert_eq!(index.lookup(&KeyStrokeId::parse("F9").expect("parse")), None); + } + + #[test] + fn double_shift_action_targets_global_search() { + assert_eq!(DOUBLE_SHIFT_ACTION, "view.global_search"); + } + + #[test] + fn double_shift_triggers_within_threshold() { + let mut r = DoubleShiftRecognizer::new(); + // 按下不触发,只有第二次独立 Shift 抬起且间隔在窗口内才触发。 + assert!(!r.handle_modifiers_changed(true, false, 1.0)); + assert!(!r.handle_modifiers_changed(false, false, 1.05)); + assert!(!r.handle_modifiers_changed(true, false, 1.1)); + assert!(r.handle_modifiers_changed(false, false, 1.2)); + } + + #[test] + fn double_shift_ignores_slow_taps() { + let mut r = DoubleShiftRecognizer::new(); + assert!(!r.handle_modifiers_changed(true, false, 1.0)); + assert!(!r.handle_modifiers_changed(false, false, 1.05)); + assert!(!r.handle_modifiers_changed(true, false, 2.0)); + assert!(!r.handle_modifiers_changed(false, false, 2.1)); + } + + #[test] + fn double_shift_cancelled_by_other_keys() { + let mut r = DoubleShiftRecognizer::new(); + assert!(!r.handle_modifiers_changed(true, false, 1.0)); + assert!(!r.handle_modifiers_changed(false, false, 1.05)); + // 中间敲了别的键,第二次 Shift 不应触发。 + r.handle_key_down(); + assert!(!r.handle_modifiers_changed(true, false, 1.1)); + assert!(!r.handle_modifiers_changed(false, false, 1.15)); + } + + #[test] + fn double_shift_cancelled_by_other_modifiers() { + let mut r = DoubleShiftRecognizer::new(); + assert!(!r.handle_modifiers_changed(true, false, 1.0)); + assert!(!r.handle_modifiers_changed(false, false, 1.05)); + // Shift 与 Ctrl 同按不算独立 Shift。 + assert!(!r.handle_modifiers_changed(true, true, 1.1)); + assert!(!r.handle_modifiers_changed(false, true, 1.15)); + } +} diff --git a/linux/src/lib.rs b/linux/src/lib.rs index 559e4cf94..04970fa31 100644 --- a/linux/src/lib.rs +++ b/linux/src/lib.rs @@ -1,5 +1,6 @@ pub mod core; pub mod i18n; +pub mod keybindings; pub mod lsp; pub mod settings; pub mod theme; diff --git a/linux/src/main.rs b/linux/src/main.rs index d89dd7899..ea953fb16 100644 --- a/linux/src/main.rs +++ b/linux/src/main.rs @@ -2,6 +2,7 @@ mod core; mod i18n; +mod keybindings; mod lsp; mod settings; mod theme; diff --git a/linux/src/workbench/toolbar.rs b/linux/src/workbench/toolbar.rs index 5a8e0d756..0336cdd28 100644 --- a/linux/src/workbench/toolbar.rs +++ b/linux/src/workbench/toolbar.rs @@ -373,6 +373,32 @@ const APP_MENUS: &[AppMenu] = &[ }, ]; +/// 菜单表里所有非空快捷键的 `(展示文本, 动作 id)` 展平结果,按菜单顺序稳定。 +/// +/// 这是快捷键的唯一真源:`keybindings` 模块据此生成按键索引, +/// 因此「菜单上写着什么快捷键」与「按什么键会触发」不会出现两份定义。 +/// +/// 只收录可用条目(`disabled` 的菜单项后端能力缺失,绑定后按键按下会静默 +/// 无响应,比未绑定更难排查)。同一按键被多个可用条目占用时,取菜单中 +/// 先出现的那个,保证结果与菜单自上而下的阅读顺序一致。 +pub fn menu_shortcut_entries() -> Vec<(&'static str, &'static str)> { + let mut out: Vec<(&'static str, &'static str)> = Vec::new(); + for menu in APP_MENUS { + for group in menu.groups { + for item in *group { + if item.shortcut.is_empty() || item.disabled { + continue; + } + if out.iter().any(|(shortcut, _)| *shortcut == item.shortcut) { + continue; + } + out.push((item.shortcut, item.action)); + } + } + } + out +} + /// 构造一个发出 `MenuAction(id)` 的菜单项。 /// /// 用 `PopupMenuItem::element` 自定义行:左侧标签占满(`text_xs`),右侧快捷键 diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index bcbf39db8..5463a5fbb 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -10,8 +10,9 @@ use gpui_kit::component::{h_flex, v_flex, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ div, px, AnyElement, App, AppContext as _, Context, Entity, FocusHandle, - InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, - MouseMoveEvent, MouseUpEvent, ParentElement as _, Render, Styled as _, Subscription, Window, + InteractiveElement as _, IntoElement, KeyDownEvent, ModifiersChangedEvent, MouseButton, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Render, Styled as _, + Subscription, Window, }; use std::collections::{HashMap, HashSet}; @@ -153,6 +154,13 @@ pub struct WorkbenchView { /// 欢迎页 pub welcome_screen: Entity, + /// 快捷键索引(菜单快捷键表 → 动作 id),启动时构建一次。 + keymap: crate::keybindings::KeymapIndex, + /// 双击 Shift 手势识别器(全局搜索)。 + double_shift: crate::keybindings::DoubleShiftRecognizer, + /// 双击 Shift 计时用的单调起点(进程内首次按键时惰性记录)。 + double_shift_clock_origin: Option, + focus_handle: FocusHandle, client: CoreClient, _subscriptions: Vec, @@ -863,6 +871,9 @@ impl WorkbenchView { project_dialog, branch_manager, welcome_screen, + keymap: crate::keybindings::KeymapIndex::build(), + double_shift: crate::keybindings::DoubleShiftRecognizer::new(), + double_shift_clock_origin: None, focus_handle, client: CoreClient::new(), _subscriptions: vec![ @@ -895,6 +906,18 @@ impl WorkbenchView { !crate::theme::ThemePalette::is_light(&settings::resolved_theme_id(s, false)) }; view.apply_resolved_theme(is_dark, cx); + // 快捷键表自检:菜单写了但无法解析的快捷键会让「菜单显示了键、按下却无效」 + // 变得很难排查,启动时直接报到日志(正常情况下为空)。若整张表为空, + // 说明菜单表与解析器已经脱钩,同样算错。 + if view.keymap.is_empty() || !view.keymap.invalid_shortcuts().is_empty() { + let bad = view.keymap.invalid_shortcuts().join(", "); + let msg = format!( + "[Keymap] {} shortcut(s) parsed, unparseable: {}", + view.keymap.len(), + bad + ); + view.append_log(&msg, cx); + } // 启动目录同样记入最近项目(对齐 Tauri 打开即记录)。 settings::record_recent_project(cx, &view.workspace_root.clone()); // 同步工具栏当前项目行(仿分支同步写法;构造时已传入,此处再确认一次)。 @@ -1077,6 +1100,9 @@ impl WorkbenchView { pub fn open_search_everywhere(&mut self, cx: &mut Context) { this_sync_files(self, cx); self.show_search_everywhere = true; + // 弹窗打开后 Shift 用于输入,手势状态必须清空, + // 避免关闭弹窗后把之前的一次 Shift 与下一次拼接成双击。 + self.double_shift.reset(); let _ = self.search_everywhere.update(cx, |search, cx| { search.reset(cx); }); @@ -1087,6 +1113,7 @@ impl WorkbenchView { pub fn open_quick_open(&mut self, cx: &mut Context) { this_sync_files(self, cx); self.show_quick_open = true; + self.double_shift.reset(); let _ = self.quick_open.update(cx, |qo, cx| { qo.reset(cx); }); @@ -1096,6 +1123,7 @@ impl WorkbenchView { /// 打开跳转到行弹窗 pub fn open_go_to_line(&mut self, cx: &mut Context) { self.show_go_to_line = true; + self.double_shift.reset(); let _ = self.go_to_line.update(cx, |modal, cx| { modal.reset(cx); }); @@ -1105,6 +1133,7 @@ impl WorkbenchView { /// 打开命令面板 pub fn open_command_palette(&mut self, cx: &mut Context) { self.show_command_palette = true; + self.double_shift.reset(); let _ = self.command_palette.update(cx, |cp, cx| { cp.reset(cx); }); @@ -1133,6 +1162,28 @@ impl WorkbenchView { .and_then(|id| self.pane_editors.get(&id).cloned()) } + /// 是否有模态浮层可见。可见时按键监听不抢快捷键,让弹窗输入框正常接收字符。 + fn modal_visible(&self) -> bool { + self.show_search_everywhere + || self.show_quick_open + || self.show_go_to_line + || self.show_command_palette + || self.show_settings_dialog + || self.show_project_dialog + || self.show_branch_manager + } + + /// 双击 Shift 用的单调秒级时钟。 + /// + /// 识别器只关心两次抬手的时间差,因此以「首次调用时刻」为零点, + /// 避免依赖墙钟(系统时间跳变会让手势失效)。 + fn double_shift_clock(&mut self) -> f64 { + let origin = *self + .double_shift_clock_origin + .get_or_insert_with(std::time::Instant::now); + origin.elapsed().as_secs_f64() + } + fn sync_run_editor(&mut self, cx: &mut Context) { let editor = self.active_editor(); let _ = self @@ -2108,7 +2159,6 @@ impl Render for WorkbenchView { .bg(ThemeColors::background()) .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { let key = event.keystroke.key.as_str(); - let modifiers = event.keystroke.modifiers; // 浮层优先:Esc 关闭最上层模态。 if key == "escape" { @@ -2129,60 +2179,93 @@ impl Render for WorkbenchView { return; } - if modifiers.control || modifiers.platform { - if modifiers.shift && key == "p" { - this.open_command_palette(cx); - return; - } - if modifiers.shift && key == "f" { - this.open_search_everywhere(cx); - return; - } - match key { - "p" => this.open_quick_open(cx), - "," => this.open_settings(cx), - "b" => this.toggle_sidebar(cx), - "`" => { - let _ = this.bottom_panel.update(cx, |bp, cx| { - bp.toggle_collapsed(cx); - }); - } - "j" => { - let _ = this.bottom_panel.update(cx, |bp, cx| { - bp.set_tab(BottomTab::Terminal, cx); - }); - } - // 行操作快捷键与菜单展示一致(上游编辑器不原生支持)。 - // 按键监听自带 `window`,直接下发,避免经句柄二次 - // `update` 的重入借用失败。 - "d" => this.editor_direct(window, cx, |ed, window, cx| { - ed.duplicate_line(window, cx); - }), - "/" => this.editor_direct(window, cx, |ed, window, cx| { - ed.toggle_comment(window, cx); - }), - _ => { - if modifiers.shift && (key == "k" || key == "K") { - this.editor_direct(window, cx, |ed, window, cx| { - ed.delete_line(window, cx); - }); - } - } - } - // Alt+Up/Down 移动行(`modifiers.alt`)。 - if modifiers.alt && (key == "up" || key == "down") { - if key == "up" { - this.editor_direct(window, cx, |ed, window, cx| { - ed.move_line_up(window, cx); - }); - } else { - this.editor_direct(window, cx, |ed, window, cx| { - ed.move_line_down(window, cx); - }); - } + // 弹窗打开时输入框要能正常打字:除 Esc 外不抢占快捷键, + // 与 Tauri 模态捕获按键的语义一致。 + if this.modal_visible() { + return; + } + + // 其它按键按下:本次 Shift 按压不再算「独立」,双击手势取消。 + // 注意 Linux 后端不为修饰键本身发 `KeyDownEvent`(X11 侧 + // `keysym.is_modifier_key()` 直接返回),因此这里只会收到 + // 真正的普通按键,Shift 的按下/抬起完全由 `ModifiersChanged` 驱动。 + this.double_shift.handle_key_down(); + + // 快捷键统一走菜单派生的按键表;命中后交给 `handle_action`, + // 与菜单项、命令面板共用同一套动作分支。 + let stroke = crate::keybindings::KeyStrokeId::from_event( + key, + &event.keystroke.modifiers, + ); + let Some(action_id) = this.keymap.lookup(&stroke) else { + return; + }; + // 编辑器动作需要 `&mut Window`,经 `editor_direct` 直达; + // 其余动作不重入窗口,统一由 `handle_action` 处理。 + match action_id { + "edit.duplicate_line" => this.editor_direct(window, cx, |ed, window, cx| { + ed.duplicate_line(window, cx); + }), + "edit.delete_line" => this.editor_direct(window, cx, |ed, window, cx| { + ed.delete_line(window, cx); + }), + "edit.move_up" => this.editor_direct(window, cx, |ed, window, cx| { + ed.move_line_up(window, cx); + }), + "edit.move_down" => this.editor_direct(window, cx, |ed, window, cx| { + ed.move_line_down(window, cx); + }), + "edit.toggle_comment" => this.editor_direct(window, cx, |ed, window, cx| { + ed.toggle_comment(window, cx); + }), + "edit.undo" => this.editor_direct(window, cx, |ed, window, cx| { + ed.undo(window, cx); + }), + "edit.redo" => this.editor_direct(window, cx, |ed, window, cx| { + ed.redo(window, cx); + }), + "edit.cut" => this.editor_direct(window, cx, |ed, window, cx| { + ed.cut(window, cx); + }), + "edit.paste" => this.editor_direct(window, cx, |ed, window, cx| { + ed.paste(window, cx); + }), + "edit.select_all" => this.editor_direct(window, cx, |ed, window, cx| { + ed.select_all(window, cx); + }), + "edit.copy" => this.with_active_editor(cx, |ed, cx| { + ed.copy(cx); + }), + "view.toggle_wrap" => this.editor_direct(window, cx, |ed, window, cx| { + ed.toggle_wrap(window, cx); + }), + "view.toggle_line_numbers" => { + this.editor_direct(window, cx, |ed, window, cx| { + ed.toggle_line_numbers(window, cx); + }) } + "view.toggle_whitespace" => this.editor_direct(window, cx, |ed, window, cx| { + ed.toggle_whitespace(window, cx); + }), + other => this.handle_action(other, cx), } })) + // 双击 Shift:GPUI 的修饰键变化事件包含按下/抬起两次, + // 由识别器按时间窗判定,命中后打开全局搜索。 + .on_modifiers_changed( + cx.listener(|this, event: &ModifiersChangedEvent, _window, cx| { + let timestamp = this.double_shift_clock(); + let modifiers = event.modifiers; + let has_other = modifiers.control || modifiers.alt || modifiers.platform; + if this.double_shift.handle_modifiers_changed( + modifiers.shift, + has_other, + timestamp, + ) { + this.handle_action(crate::keybindings::DOUBLE_SHIFT_ACTION, cx); + } + }), + ) .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| { if this.is_resizing_sidebar { let current_x = f32::from(event.position.x); From 72e2ef4d023c23bf62bc484d5d95138b23390098 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 17:21:12 +0800 Subject: [PATCH 057/109] =?UTF-8?q?feat(linux):=20=E6=8E=A5=E5=85=A5=20JDT?= =?UTF-8?q?=20LS=20=E7=9A=84=20Java=20=E9=A1=B9=E7=9B=AE=E5=87=86=E5=A4=87?= =?UTF-8?q?=E4=B8=8E=20Run=20=E7=9B=B4=E5=90=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux 第二阶段补齐 Java Main / Spring Boot 的 Core 直启链路,与 macOS/Windows 走同一条 Java 项目准备边界,不再依赖 Maven goal 启动。 - `linux/src/lsp.rs` - 解析 JDT LS 直启资源:优先 `LITHE_JDTLS_ROOT` / `LITHE_JDK_ROOT`, 默认工作区 `.artifacts/jdtls-linux` 与 `.artifacts/jdk-linux`; equinox launcher / debug plugin 按版本排序,资源目录存在但不完整时 返回可操作错误,外部 `jdtls` wrapper 仅作兼容回退。 - 提供 workspace fingerprint、`javaLaunch` 请求形状与入口 / classpath / build 结果的解析(不扫源码、不在 UI 拼 classpath)。 - 会话生命周期:Core `java` provider、`ServiceReady`、 `projectPreparation.blocksRun` 快照、过期 session/sequence 拒绝。 - `linux/src/workbench/bottom_panel.rs` - Maven-backed `java.main` 与带 Java 入口的 Spring Boot 先等待 session ready,再按 `javaEntrypoints` → `vscode.java.buildWorkspace` → `vscode.java.resolveClasspath` 顺序准备,把 JDT 返回的 `{mainClass, projectName, classPaths, modulePaths}` 作为 `javaLaunch` 交给 `runConfig.createLaunchPlan`。 - 构建失败保留 Core build diagnostics 并阻止主进程,可通过再次 Run 重试; 无 JDT 元数据时 framework goal 与独立 Java `javac -> java` 仍走既有计划。 - `view.rs` 持有唯一 `java` session,处理 workspace 失效与诊断聚合。 - 更新 Agent 笔记,记录第二阶段的 Linux 边界与验证方式。 验证:`cargo check` 无警告;`cargo test --lib` 40 通过(覆盖 JDT 资源排序、 载荷形状、入口/classpath/build 解析、provider 门禁、过期 session/sequence 与进程组停止);测试不依赖本机 Maven/JDK/网络。 --- ...-java-project-build-and-launch-boundary.md | 32 +- linux/src/lsp.rs | 1077 ++++++++++++++++- linux/src/workbench/bottom_panel.rs | 493 +++++++- linux/src/workbench/view.rs | 448 ++++++- 4 files changed, 1992 insertions(+), 58 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md b/.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md index 4339455f0..52ac0e159 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md +++ b/.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md @@ -36,12 +36,23 @@ classpath/module-path;随后 Run 模块只启动一次项目 JDK。Maven 仍 4. Rust Core 接收结构化 `javaLaunch`,生成 `project-jdk` 直启计划; 5. macOS/Windows 宿主按各自路径分隔符拼接参数并启动一个 JVM。 -Linux 第一阶段沿用同一条配置边界:`bottom_panel.rs` 只发 Core 命令和展示 -状态,`workbench/run/config.rs` 持久化 Core 生成结果、解析工具链与启动计划, -`workbench/run/process.rs` 独占 session、子进程组和输出事件。Linux 不读取旧 -`name/type/mainClass` 文档作为主路径,不再用源码正则补入口,也不在计划失败 -后伪造 `mvn compile`。Maven 项目里的 `java.main` 仍因缺少 JDT 返回的 -`javaLaunch` 明确失败,直到 Linux 接入同一 Java 项目准备能力。 +Linux 第二阶段已经接入同一 Java 项目准备能力:`linux/src/lsp.rs` 只解析 +JDT LS 直启资源、Core workspace fingerprint 和请求形状;Workbench 持有唯一的 +`java` Core session,并把 `ServiceReady` 与 `projectPreparation.blocksRun` 快照 +交给 Run。Linux 路径优先使用 `LITHE_JDTLS_ROOT` / `LITHE_JDK_ROOT`,默认从 +工作区 `.artifacts/jdtls-linux` 与 `.artifacts/jdk-linux` 解析;正式资源目录 +存在但不完整时直接报可操作错误,外部 `jdtls` wrapper 只作为兼容回退。 + +`bottom_panel.rs` 对 Maven-backed `java.main` 和带 Java 入口的 Spring Boot 配置 +先等待对应 session ready,再按 Core `javaEntrypoints` → +`vscode.java.buildWorkspace` → `vscode.java.resolveClasspath` 顺序准备目标, +最后把 JDT 返回的 `{mainClass, projectName, classPaths, modulePaths}` 作为 +`javaLaunch` 传给 `runConfig.createLaunchPlan`。Linux 不扫描源码决定入口,也 +不在 UI 拼接 classpath;构建失败保留 Core 的 build diagnostics,阻止主进程, +并通过再次 Run 重试。没有 JDT 元数据时,framework Maven goal 与独立 Java +`javac -> java` 仍沿用第一阶段 Core 计划,Maven-backed `java.main` 明确显示等待 +或准备失败。Linux 仍不读取旧 `name/type/mainClass` 文档作为主路径,不用源码 +正则补入口,也不在计划失败后伪造 `mvn compile`。 Run 和 Debug 共用同一套 Java 项目准备逻辑。配置中的 Maven 信息仍用于 JDT LS 导入、Profile、settings.xml 和项目模型;Maven 工具窗口、框架 goal、测试与显式 @@ -214,8 +225,10 @@ Run 控件,也不在 `java-run-launch` 或平台 adapter 再做一次 `blocksR Run/Debug 共用的 Store 测试覆盖同一次启动继续、取消、记忆选择和索引恢复。 - Linux:`cargo test --manifest-path linux/Cargo.toml --lib --bins` 覆盖 Core process/Spring Boot 计划、`javac -> java`、POSIX classpath、工具链映射、 - pre-launch 失败门禁、过期 sequence 与完整进程组停止;测试不依赖本机 - Maven/JDK/网络。 + JDT 资源排序与 payload、入口/classpath/build 结果解析、Core Java provider 门禁、 + 过期 session/sequence、pre-launch 失败门禁与完整进程组停止;测试不依赖本机 + Maven/JDK/网络。资源解析测试只创建临时目录和 fixture + JSON。 - 共享契约:`shared/contracts/rust-core-api.md` 与 `shared/contracts/application-boundary.md`,跨平台样例为 `shared/fixtures/lsp/java-build-report-v1.json`。 @@ -227,6 +240,7 @@ Run 控件,也不在 `java-run-launch` 或平台 adapter 再做一次 `blocksR `rust/lithe-core/src/lsp/interface/engine.rs` - macOS:`LanguageToolingSessionManager`、`AppModel+RunConfiguration`、`RunService` - Windows:`java-run-launch.ts`、`lsp-core-adapter.ts`、`run.store.ts` -- Linux:`linux/src/workbench/bottom_panel.rs`、`linux/src/workbench/run/` +- Linux:`linux/src/lsp.rs`、`linux/src/workbench/view.rs`、 + `linux/src/workbench/bottom_panel.rs`、`linux/src/workbench/run/` - 相关笔记: `.agents/notes/implemented/feature/2026-09-17-standalone-java-compile-then-run.md` diff --git a/linux/src/lsp.rs b/linux/src/lsp.rs index c9ebc6f4d..14f6b5cdb 100644 --- a/linux/src/lsp.rs +++ b/linux/src/lsp.rs @@ -5,7 +5,10 @@ //! 平台侧的语言/可执行文件探测、路径与 URI 规范化,以及事件投影。 use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use serde::Serialize; use serde_json::{json, Value}; use crate::workbench::bottom_panel::DiagnosticEntry; @@ -25,6 +28,86 @@ pub struct LanguageProvider { pub extensions: &'static [&'static str], } +/// Core 对 JDT LS 使用的 provider 标识;不要使用发行包 wrapper 的 `jdtls` 名称。 +pub const JAVA_PROVIDER_ID: &str = "java"; + +const JDTLS_EQUINOX_PREFIX: &str = "org.eclipse.equinox.launcher_"; +const JDTLS_DEBUG_PREFIX: &str = "com.microsoft.java.debug.plugin-"; +const JDTLS_TEST_LIST: &str = "extensions.txt"; +const MAX_BUILD_FILE_DEPTH: usize = 32; + +fn linux_configuration_name() -> &'static str { + match std::env::consts::ARCH { + "aarch64" | "arm" => "config_linux_arm", + _ => "config_linux", + } +} + +/// Linux 解析出的 JDT LS 直启资源。 +/// +/// 这些路径只属于 Linux 适配器;Core 负责把它们转换为 JVM 参数和 JDT 初始化选项。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JdtlsLaunchResources { + /// Equinox launcher JAR。 + pub launcher_jar_path: String, + /// Linux Eclipse configuration 目录。 + pub configuration_directory: String, + /// Lombok agent JAR。 + pub lombok_agent_path: String, + /// Java Debug Server bundle JAR。 + pub java_debug_bundle_path: String, + /// Java Test extension bundles,按稳定路径顺序提交。 + pub java_extension_bundle_paths: Vec, +} + +/// Linux 主机解析出的 Java 语言服务器启动计划。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JavaLspLaunch { + /// Core provider 必须是 `java`。 + pub provider_id: String, + /// LSP language id。 + pub language_id: String, + /// 直接启动时为 bundled Java,兼容 wrapper 时为 wrapper 路径。 + pub executable_path: String, + /// 外部 wrapper 的兼容参数;直启资源由 Core 适配。 + pub arguments: Vec, + /// JDT LS 使用的 Java 可执行文件。 + pub runtime_executable_path: Option, + /// JDT LS 直启资源;外部 wrapper 回退时不提交该字段。 + pub jdtls_launch_resources: Option, + /// JDT LS 的 JAVA_HOME。 + pub java_home_path: Option, + /// 用于 Core workspace fingerprint 的发行包版本。 + pub jdtls_version: String, +} + +/// 一条已由 Workbench 轮询收到的 Core LSP 请求结果。 +#[derive(Debug, Clone)] +pub struct LspOperationResult { + /// Core 事件所属的会话。 + pub session_id: String, + /// 成功结果;失败时为空。 + pub result: Option, + /// Core 返回的稳定错误码、消息和诊断详情。 + pub error: Option, +} + +/// Run 读取的 Java session 快照。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JavaLspStatus { + /// 当前 Core session;没有会话时为空。 + pub session_id: Option, + /// `idle`、`starting`、`initializing`、`ready`、`failed` 或 `stopped`。 + pub state: String, + /// 当前工作区最近一次 Java session 错误。 + pub error: Option, + /// Core 轮询返回的项目准备快照。 + pub project_preparation: Option, + /// 拒绝旧工作区回调的代数。 + pub generation: u64, +} + const RUST_ANALYZER: LanguageProvider = LanguageProvider { id: "rust-analyzer", executable: "rust-analyzer", @@ -66,7 +149,7 @@ const GOPLS: LanguageProvider = LanguageProvider { }; const JDTLS: LanguageProvider = LanguageProvider { - id: "jdtls", + id: "java", executable: "jdtls", arguments: &[], language_id: "java", @@ -131,7 +214,757 @@ pub fn find_in_path(name: &str) -> Option { None } -/// 把相对工作区路径解析成绝对路径;已经是绝对路径时原样返回。 +/// 解析 Linux Java 语言服务器资源。 +/// +/// 正式运行路径优先使用工作区 `.artifacts` 或环境覆盖;只有正式目录不存在时, +/// 才允许回退到外部 `jdtls` wrapper。正式目录存在但资源不完整时直接返回错误, +/// 避免悄悄退回另一套 JDT 安装。 +pub fn resolve_java_lsp_launch(root: &str) -> Result { + let workspace = Path::new(root); + let explicit_jdtls_root = std::env::var_os("LITHE_JDTLS_ROOT") + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()); + let jdtls_root = explicit_jdtls_root + .clone() + .unwrap_or_else(|| workspace.join(".artifacts").join("jdtls-linux")); + let explicit_jdk_root = std::env::var_os("LITHE_JDK_ROOT") + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()); + let jdk_root = explicit_jdk_root + .clone() + .unwrap_or_else(|| workspace.join(".artifacts").join("jdk-linux")); + + if jdtls_root.exists() { + let resources = resolve_direct_jdtls_resources(&jdtls_root)?; + let java_executable = resolve_java_executable(&jdk_root).ok_or_else(|| { + format!( + "Java language-server runtime is missing. Set LITHE_JDK_ROOT or provide {}/bin/java.", + jdk_root.display() + ) + })?; + let java_home = Path::new(&java_executable) + .parent() + .and_then(Path::parent) + .map(|path| path.to_string_lossy().into_owned()); + return Ok(JavaLspLaunch { + provider_id: JAVA_PROVIDER_ID.to_string(), + language_id: JAVA_PROVIDER_ID.to_string(), + executable_path: java_executable.clone(), + arguments: Vec::new(), + runtime_executable_path: Some(java_executable), + jdtls_launch_resources: Some(resources), + java_home_path: java_home, + jdtls_version: read_jdtls_version(&jdtls_root), + }); + } + + if explicit_jdtls_root.is_some() { + return Err(format!( + "JDT LS resource root does not exist: {}. Set LITHE_JDTLS_ROOT to a complete JDT LS installation.", + jdtls_root.display() + )); + } + + resolve_wrapper_java_lsp_launch(&jdk_root) +} + +fn resolve_direct_jdtls_resources(root: &Path) -> Result { + let launcher_jar_path = first_sorted_file(root.join("plugins"), JDTLS_EQUINOX_PREFIX, ".jar")? + .ok_or_else(|| { + format!( + "JDT LS launcher is missing under {}. Expected {}*.jar.", + root.join("plugins").display(), + JDTLS_EQUINOX_PREFIX + ) + })?; + let configuration_directory = root.join(linux_configuration_name()); + if !configuration_directory.is_dir() { + return Err(format!( + "JDT LS Linux configuration is missing: {}", + configuration_directory.display() + )); + } + let lombok_agent_path = root.join("lombok").join("lombok.jar"); + if !lombok_agent_path.is_file() { + return Err(format!( + "JDT LS Lombok agent is missing: {}", + lombok_agent_path.display() + )); + } + let java_debug_bundle_path = + first_sorted_file(root.join("java-debug"), JDTLS_DEBUG_PREFIX, ".jar")?.ok_or_else( + || { + format!( + "JDT LS Java Debug bundle is missing under {}.", + root.join("java-debug").display() + ) + }, + )?; + let java_extension_bundle_paths = resolve_java_test_bundles(root)?; + Ok(JdtlsLaunchResources { + launcher_jar_path: path_string(&launcher_jar_path), + configuration_directory: path_string(&configuration_directory), + lombok_agent_path: path_string(&lombok_agent_path), + java_debug_bundle_path: path_string(&java_debug_bundle_path), + java_extension_bundle_paths: java_extension_bundle_paths + .iter() + .map(|path| path_string(path)) + .collect(), + }) +} + +fn resolve_java_test_bundles(root: &Path) -> Result, String> { + let directory = root.join("java-test").join("extensions"); + if !directory.is_dir() { + return Err(format!( + "JDT LS Java Test extensions are missing: {}", + directory.display() + )); + } + let list_path = root.join("java-test").join(JDTLS_TEST_LIST); + let mut bundles = match fs::read_to_string(&list_path) { + Ok(text) => { + let mut values = Vec::new(); + for name in text.lines().map(str::trim).filter(|name| !name.is_empty()) { + if name.contains(['/', '\\']) || name == ".." { + return Err(format!( + "JDT LS Java Test bundle list contains a path instead of a file name: {name}" + )); + } + let path = directory.join(name); + if !path.is_file() { + return Err(format!( + "JDT LS Java Test bundle is missing: {}", + path.display() + )); + } + values.push(path); + } + values + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let entries = fs::read_dir(&directory).map_err(|error| { + format!( + "Failed to inspect JDT LS Java Test bundles at {}: {error}", + directory.display() + ) + })?; + entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("jar") + }) + .collect() + } + Err(error) => { + return Err(format!( + "Failed to read JDT LS Java Test bundle list at {}: {error}", + list_path.display() + )) + } + }; + bundles.sort(); + bundles.dedup(); + if bundles.is_empty() { + return Err(format!( + "JDT LS Java Test extension directory contains no bundles: {}", + directory.display() + )); + } + Ok(bundles) +} + +fn first_sorted_file( + directory: PathBuf, + prefix: &str, + suffix: &str, +) -> Result, String> { + let entries = match fs::read_dir(&directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "Failed to inspect JDT LS resources at {}: {error}", + directory.display() + )) + } + }; + let mut files = entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.is_file() + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(prefix) && name.ends_with(suffix)) + }) + .collect::>(); + files.sort(); + Ok(files.into_iter().next()) +} + +fn resolve_java_executable(jdk_root: &Path) -> Option { + let candidate = jdk_root.join("bin").join("java"); + candidate.is_file().then(|| path_string(&candidate)) +} + +fn resolve_wrapper_java_lsp_launch(jdk_root: &Path) -> Result { + let mut candidates = Vec::new(); + if let Some(executable) = std::env::var_os("LITHE_JDTLS_EXECUTABLE") { + candidates.push(PathBuf::from(executable)); + } + if let Some(home) = std::env::var_os("JDTLS_HOME") { + let home = PathBuf::from(home); + candidates.push(home.join("bin").join("jdtls")); + candidates.push(home.join("jdtls")); + } + for name in ["jdtls", "jdtls.py"] { + if let Some(executable) = find_in_path(name) { + candidates.push(PathBuf::from(executable)); + } + } + let executable = candidates + .into_iter() + .find(|candidate| candidate.is_file()) + .ok_or_else(|| { + "JDT LS is not installed. Set LITHE_JDTLS_ROOT to the bundled resources or LITHE_JDTLS_EXECUTABLE to a compatible wrapper." + .to_string() + })?; + let runtime = resolve_java_executable(jdk_root) + .or_else(|| { + std::env::var_os("JAVA_HOME").and_then(|home| { + let path = PathBuf::from(home).join("bin").join("java"); + path.is_file().then(|| path_string(&path)) + }) + }) + .or_else(|| find_in_path("java")); + let java_home = runtime + .as_deref() + .and_then(|path| Path::new(path).parent()) + .and_then(Path::parent) + .map(|path| path.to_string_lossy().into_owned()); + Ok(JavaLspLaunch { + provider_id: JAVA_PROVIDER_ID.to_string(), + language_id: JAVA_PROVIDER_ID.to_string(), + executable_path: path_string(&executable), + arguments: Vec::new(), + runtime_executable_path: runtime, + jdtls_launch_resources: None, + java_home_path: java_home, + jdtls_version: "external-wrapper".to_string(), + }) +} + +fn read_jdtls_version(root: &Path) -> String { + fs::read_to_string(root.join("manifest.json")) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .and_then(|value| { + value + .get("version") + .and_then(Value::as_str) + .map(str::to_string) + }) + .filter(|version| !version.trim().is_empty()) + .unwrap_or_else(|| "direct-resources".to_string()) +} + +fn path_string(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +/// 生成带 JDT LS 资源和 Core 所需上下文的 `lsp.startServer` 载荷。 +pub fn java_start_payload( + launch: &JavaLspLaunch, + root: &str, + cache_directory: &str, + workspace_fingerprint: Option<&str>, + maven_context: Option<&Value>, +) -> Value { + let mut payload = json!({ + "providerId": launch.provider_id, + "executablePath": launch.executable_path, + "arguments": launch.arguments, + "rootUri": file_uri(root, root), + "workingDirectory": absolute_path(root, root), + "runtimeExecutablePath": launch.runtime_executable_path, + "initializeTimeoutMilliseconds": 30_000, + "requestTimeoutMilliseconds": 30_000, + "shutdownTimeoutMilliseconds": 2_000, + "cacheDirectory": cache_directory, + }); + if let Some(resources) = &launch.jdtls_launch_resources { + payload["jdtlsLaunchResources"] = + serde_json::to_value(resources).expect("resources encode"); + } + if let Some(java_home) = &launch.java_home_path { + payload["environment"] = json!({ "JAVA_HOME": java_home }); + } + if let Some(fingerprint) = workspace_fingerprint.filter(|value| !value.is_empty()) { + payload["workspaceFingerprint"] = json!(fingerprint); + } + if let Some(context) = maven_context { + payload["mavenContext"] = context.clone(); + } + payload +} + +/// 为 JDT LS 提供最小且可序列化的 Maven 上下文。 +/// +/// Linux 不从 Maven 命令行或源码推断项目模型;Core 会用这个上下文配置 JDT 的 +/// settings、Profile 和递归模块导入。缺少机器本地 Maven 选择时保留空值。 +pub fn maven_context_for_workspace(root: &str) -> Option { + let workspace = Path::new(root); + let reactor = workspace.ancestors().find_map(|directory| { + directory + .strip_prefix(workspace) + .ok() + .filter(|_| directory.join("pom.xml").is_file()) + .map(|_| directory) + })?; + let reactor_path = reactor + .strip_prefix(workspace) + .ok() + .map(|path| path.to_string_lossy().replace('\\', "/")) + .filter(|path| !path.is_empty()) + .unwrap_or_else(|| ".".to_string()); + let local_repository = std::env::var_os("LITHE_MAVEN_LOCAL_REPOSITORY") + .map(PathBuf::from) + .filter(|path| path.is_absolute()); + let settings = std::env::var_os("LITHE_MAVEN_SETTINGS") + .map(PathBuf::from) + .filter(|path| path.is_absolute()); + let java_home = std::env::var_os("LITHE_JDK_ROOT") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .or_else(|| { + let bundled = workspace.join(".artifacts").join("jdk-linux"); + bundled + .join("bin") + .join("java") + .is_file() + .then_some(bundled) + }); + let maven_executable = std::env::var_os("LITHE_MAVEN_EXECUTABLE") + .map(PathBuf::from) + .filter(|path| path.is_absolute()); + Some(json!({ + "version": 1, + "reactorPath": reactor_path, + "profiles": Vec::::new(), + "settingsPath": settings.map(|path| path_string(&path)), + "localRepositoryPath": local_repository.map(|path| path_string(&path)), + "skipTests": false, + "mavenExecutablePath": maven_executable.map(|path| path_string(&path)), + "javaHomePath": java_home.map(|path| path_string(&path)), + })) +} + +/// 构造 Core `java.jdtWorkspaceFingerprint` 的平台观察载荷。 +pub fn workspace_fingerprint_request(root: &str, jdtls_version: &str) -> Value { + let workspace = Path::new(root); + let mut build_files = Vec::new(); + let mut direct_maven_modules = Vec::new(); + collect_build_observations( + workspace, + workspace, + 0, + &mut build_files, + &mut direct_maven_modules, + ); + build_files.sort_by(|left, right| left["path"].as_str().cmp(&right["path"].as_str())); + direct_maven_modules.sort(); + direct_maven_modules.dedup(); + json!({ + "buildFiles": build_files, + "directMavenModules": direct_maven_modules, + "jdtlsVersion": jdtls_version, + }) +} + +fn collect_build_observations( + root: &Path, + directory: &Path, + depth: usize, + build_files: &mut Vec, + modules: &mut Vec, +) { + if depth > MAX_BUILD_FILE_DEPTH { + return; + } + let Ok(entries) = fs::read_dir(directory) else { + return; + }; + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().into_owned(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_dir() { + if name.starts_with('.') || matches!(name.as_str(), "target" | "build" | "node_modules") + { + continue; + } + collect_build_observations(root, &path, depth + 1, build_files, modules); + continue; + } + if !file_type.is_file() || !is_build_file(&name) { + continue; + } + let Ok(relative) = path.strip_prefix(root) else { + continue; + }; + let Ok(metadata) = entry.metadata() else { + continue; + }; + let modified = metadata + .modified() + .ok() + .and_then(|value| value.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|value| value.as_millis() as u64) + .unwrap_or(0); + build_files.push(json!({ + "path": relative.to_string_lossy().replace('\\', "/"), + "modifiedUnixMilliseconds": modified, + "sizeBytes": metadata.len(), + })); + if name == "pom.xml" { + collect_maven_modules(&path, root, modules); + } + } +} + +fn is_build_file(name: &str) -> bool { + matches!( + name, + "pom.xml" + | "build.gradle" + | "build.gradle.kts" + | "settings.gradle" + | "settings.gradle.kts" + | "gradle.properties" + ) +} + +fn collect_maven_modules(path: &Path, root: &Path, output: &mut Vec) { + let Ok(text) = fs::read_to_string(path) else { + return; + }; + let mut remaining = text.as_str(); + while let Some(start) = remaining.find("') else { + break; + }; + let value = &after[open_end + 1..]; + let Some(close) = value.find("") else { + break; + }; + let module = value[..close].trim(); + if !module.is_empty() && module != "." && !module.contains("..") { + let module_path = path.parent().unwrap_or(root).join(module); + if let Ok(relative) = module_path.strip_prefix(root) { + let relative = relative.to_string_lossy().replace('\\', "/"); + if !relative.contains('/') && relative != "." { + output.push(relative); + } + } + } + remaining = &value[close + "".len()..]; + } +} + +/// 判断 Run 是否可以消费 Java 准备结果。 +/// +/// Core 的 `ServiceReady` 只表示协议和项目导入完成;Maven Profile 等准备任务 +/// 仍由 `projectPreparation.blocksRun` 门禁。Core 允许非阻塞的部分 Profile +/// 失败继续进入目标项目构建,Linux 不在 UI 侧另造一套状态机。 +pub fn java_lsp_allows_run(status: &JavaLspStatus) -> bool { + if status.state != "ready" { + return false; + } + status + .project_preparation + .as_ref() + .and_then(|value| value.get("blocksRun")) + .and_then(Value::as_bool) + .is_some_and(|value| !value) +} + +/// Core Java 入口发现请求。 +pub fn java_entrypoints_request(session_id: &str) -> Value { + json!({ + "sessionId": session_id, + "operation": "javaEntrypoints", + }) +} + +/// Core `vscode.java.buildWorkspace` 请求。 +pub fn java_build_request( + session_id: &str, + source_path: &str, + main_class: &str, + project_name: Option<&str>, +) -> Value { + let arguments = serde_json::to_string(&json!({ + "mainClass": main_class, + "projectName": project_name, + "filePath": source_path, + "isFullBuild": false, + })) + .expect("Java build command encodes"); + json!({ + "sessionId": session_id, + "operation": "executeCommand", + "command": { + "title": "Build Java Workspace", + "command": "vscode.java.buildWorkspace", + "arguments": [arguments], + }, + }) +} + +/// Core `vscode.java.resolveClasspath` 请求。 +pub fn java_classpath_request( + session_id: &str, + main_class: &str, + project_name: Option<&str>, +) -> Value { + json!({ + "sessionId": session_id, + "operation": "executeCommand", + "command": { + "title": "Resolve Java Runtime Classpath", + "command": "vscode.java.resolveClasspath", + "arguments": [main_class, project_name.unwrap_or(""), "runtime"], + }, + }) +} + +/// 校验 Core 的 Java 入口结果并保留其稳定 workspace-relative 结构。 +pub fn parse_java_entrypoints(value: &Value) -> Result { + let value = unwrap_command_value(value); + let entries = value + .get("entries") + .and_then(Value::as_array) + .ok_or_else(|| "Java language service returned no entry-point list.".to_string())?; + let schema_version = value + .get("schemaVersion") + .and_then(Value::as_u64) + .ok_or_else(|| "Java entry-point result has no schema version.".to_string())?; + if schema_version != 1 { + return Err("Unsupported Java entry-point schema version.".to_string()); + } + let mut entries = entries.clone(); + entries.sort_by(|left, right| { + left.get("sourcePath") + .and_then(Value::as_str) + .cmp(&right.get("sourcePath").and_then(Value::as_str)) + .then_with(|| { + left.get("mainClass") + .and_then(Value::as_str) + .cmp(&right.get("mainClass").and_then(Value::as_str)) + }) + }); + entries.dedup(); + let normalized = json!({ + "schemaVersion": schema_version, + "entries": entries, + "diagnostics": value.get("diagnostics").cloned().unwrap_or_else(|| json!([])), + }); + Ok(normalized) +} + +/// 从 Core 的入口列表选择配置对应的唯一目标,不读取 Java 源码。 +pub fn select_java_entrypoint( + value: &Value, + source_path: &str, + configured_main_class: Option<&str>, +) -> Result { + let entries = value + .get("entries") + .and_then(Value::as_array) + .ok_or_else(|| "Java entry-point result has no entries.".to_string())?; + let source_matches = entries + .iter() + .filter(|entry| entry.get("sourcePath").and_then(Value::as_str) == Some(source_path)) + .collect::>(); + let selected = if source_matches.len() == 1 { + source_matches[0].clone() + } else { + source_matches + .into_iter() + .find(|entry| { + configured_main_class.is_some_and(|main| { + entry.get("mainClass").and_then(Value::as_str) == Some(main) + }) + }) + .cloned() + .ok_or_else(|| { + format!( + "Java language service could not identify one launch target for {source_path}." + ) + })? + }; + if selected.get("mainClass").and_then(Value::as_str).is_none() { + return Err("Java entry-point result is missing mainClass.".to_string()); + } + Ok(selected) +} + +/// 校验 Core `vscode.java.buildWorkspace` 的成功结果。 +pub fn parse_java_build_result(value: &Value) -> Result<(), String> { + let value = unwrap_command_value(value); + let status = value + .as_i64() + .or_else(|| value.get("value").and_then(Value::as_i64)); + if status == Some(1) { + Ok(()) + } else { + Err("Java language service returned an unexpected project build status.".to_string()) + } +} + +/// 解析 Core `vscode.java.resolveClasspath` 的 `[modulePaths, classPaths]`。 +pub fn parse_java_classpath(value: &Value) -> Result<(Vec, Vec), String> { + let value = unwrap_command_value(value); + let paths = value.as_array().ok_or_else(|| { + "Java language service returned an invalid runtime classpath.".to_string() + })?; + if paths.len() != 2 { + return Err( + "Java language service returned an invalid runtime classpath shape.".to_string(), + ); + } + let module_paths = string_array(paths[0].clone()) + .ok_or_else(|| "Java module paths must be an array of strings.".to_string())?; + let class_paths = string_array(paths[1].clone()) + .ok_or_else(|| "Java class paths must be an array of strings.".to_string())?; + if module_paths.is_empty() && class_paths.is_empty() { + return Err("Java language service returned no runtime paths.".to_string()); + } + Ok((module_paths, class_paths)) +} + +/// 组合 JDT 返回的目标和路径;Linux 不在这里拼接 classpath 字符串。 +pub fn java_launch_payload(entrypoint: &Value, classpath: &Value) -> Result { + let main_class = entrypoint + .get("mainClass") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "Java entry-point result is missing mainClass.".to_string())?; + let (module_paths, class_paths) = parse_java_classpath(classpath)?; + Ok(json!({ + "mainClass": main_class, + "projectName": entrypoint.get("projectName").cloned().unwrap_or(Value::Null), + "classPaths": class_paths, + "modulePaths": module_paths, + })) +} + +fn unwrap_command_value(value: &Value) -> &Value { + value.get("value").unwrap_or(value) +} + +fn string_array(value: Value) -> Option> { + value + .as_array()? + .iter() + .map(|item| item.as_str().map(str::to_string)) + .collect() +} + +/// 从一次 `lsp.pollEvents` 响应中提取属于指定会话的请求结果。 +pub fn operation_results_from_poll( + value: &Value, + session_id: &str, +) -> Vec<(String, LspOperationResult)> { + value + .get("events") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|event| { + event.get("type").and_then(Value::as_str) == Some("requestCompleted") + && event.get("sessionId").and_then(Value::as_str) == Some(session_id) + }) + .filter_map(|event| { + let operation_id = event + .get("operationId") + .and_then(Value::as_str)? + .to_string(); + let error = event.get("error").and_then(format_lsp_error); + Some(( + operation_id.clone(), + LspOperationResult { + session_id: session_id.to_string(), + result: event.get("result").cloned(), + error, + }, + )) + }) + .collect() +} + +fn format_lsp_error(error: &Value) -> Option { + let code = error + .get("code") + .and_then(Value::as_str) + .unwrap_or("lspError"); + let message = error + .get("message") + .and_then(Value::as_str) + .unwrap_or("Language-server request failed."); + let mut text = format!("{code}: {message}"); + if let Some(details) = error + .get("details") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + { + text.push_str(&format!(" ({details})")); + } + if let Some(report) = error.get("javaBuildReport") { + text.push_str(&format!( + " [javaBuildReport={}]", + serde_json::to_string(report).unwrap_or_else(|_| "{}".to_string()) + )); + } + Some(text) +} + +/// 读取当前 Java session 的可观察生命周期状态。 +#[allow(dead_code)] +pub fn java_lifecycle_state(value: &Value) -> Option { + java_lifecycle_state_for(value, None) +} + +/// 读取指定 Java session 的最新生命周期状态。 +pub fn java_lifecycle_state_for(value: &Value, session_id: Option<&str>) -> Option { + value + .get("events") + .and_then(Value::as_array)? + .iter() + .rev() + .find_map(|event| { + let session_matches = session_id.is_none_or(|session_id| { + event.get("sessionId").and_then(Value::as_str) == Some(session_id) + }); + (event.get("type").and_then(Value::as_str) == Some("stateChanged") + && event.get("providerId").and_then(Value::as_str) == Some(JAVA_PROVIDER_ID) + && session_matches) + .then(|| { + event + .get("state") + .and_then(Value::as_str) + .map(str::to_string) + }) + .flatten() + }) +} + pub fn absolute_path(root: &str, path: &str) -> String { let candidate = std::path::Path::new(path); if candidate.is_absolute() { @@ -208,16 +1041,11 @@ pub fn start_payload( root: &str, cache_directory: &str, ) -> Value { - let mut arguments: Vec = provider + let arguments: Vec = provider .arguments .iter() .map(|value| value.to_string()) .collect(); - if provider.id == "jdtls" { - // jdtls 要求显式 data 目录;configuration 目录由发行包脚本补齐。 - arguments.push("-data".to_string()); - arguments.push(cache_directory.to_string()); - } json!({ "providerId": provider.id, "executablePath": executable, @@ -252,6 +1080,7 @@ pub fn poll_payload(session_id: &str) -> Value { } /// 语言服务器退出后 Core 只允许在收到终态事件后销毁会话。 +#[allow(dead_code)] pub fn session_finished(value: &Value) -> bool { value .get("events") @@ -266,6 +1095,24 @@ pub fn session_finished(value: &Value) -> bool { .unwrap_or(false) } +/// 只接受指定 session 的终态事件,避免混合 fixture 或旧 session 结束新 session。 +pub fn session_finished_for(value: &Value, session_id: &str) -> bool { + value + .get("events") + .and_then(Value::as_array) + .map(|events| { + events.iter().any(|event| { + event.get("sessionId").and_then(Value::as_str) == Some(session_id) + && event.get("type").and_then(Value::as_str) == Some("stateChanged") + && matches!( + event.get("state").and_then(Value::as_str), + Some("stopped" | "failed") + ) + }) + }) + .unwrap_or(false) +} + /// 把一次 poll 的诊断事件投影为面板条目;无 URI 或工作区外的事件被丢弃。 pub fn diagnostics_from_poll(value: &Value, root: &str) -> HashMap> { let mut by_file: HashMap> = HashMap::new(); @@ -321,6 +1168,19 @@ pub fn diagnostics_from_poll(value: &Value, root: &str) -> HashMap HashMap> { + let mut filtered = value.clone(); + if let Some(events) = filtered.get_mut("events").and_then(Value::as_array_mut) { + events.retain(|event| event.get("sessionId").and_then(Value::as_str) == Some(session_id)); + } + diagnostics_from_poll(&filtered, root) +} + fn severity_name(severity: Option) -> String { match severity { Some(1) => "error".to_string(), @@ -355,6 +1215,10 @@ mod tests { provider_for_path("src/App.tsx").map(|p| p.id), Some("typescript-language-server") ); + assert_eq!( + provider_for_path("src/App.java").map(|p| p.id), + Some("java") + ); assert_eq!(provider_for_path("README.md").map(|p| p.id), None); } @@ -388,4 +1252,201 @@ mod tests { assert_eq!(entries[0].line, 4); assert_eq!(entries[0].column, 7); } + + fn fixture(name: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "lithe-linux-lsp-{name}-{}-{}", + std::process::id(), + name.len() + )); + let _ = fs::remove_dir_all(&path); + fs::create_dir_all(&path).expect("fixture"); + path + } + + fn direct_jdtls_fixture(root: &Path) -> JdtlsLaunchResources { + let plugins = root.join("plugins"); + let debug = root.join("java-debug"); + let extensions = root.join("java-test").join("extensions"); + fs::create_dir_all(&plugins).unwrap(); + fs::create_dir_all(root.join(linux_configuration_name())).unwrap(); + fs::create_dir_all(root.join("lombok")).unwrap(); + fs::create_dir_all(&debug).unwrap(); + fs::create_dir_all(&extensions).unwrap(); + fs::write(plugins.join("org.eclipse.equinox.launcher_2.jar"), b"jar").unwrap(); + fs::write(plugins.join("org.eclipse.equinox.launcher_1.jar"), b"jar").unwrap(); + fs::write(root.join("lombok").join("lombok.jar"), b"jar").unwrap(); + fs::write(debug.join("com.microsoft.java.debug.plugin-2.jar"), b"jar").unwrap(); + fs::write(debug.join("com.microsoft.java.debug.plugin-1.jar"), b"jar").unwrap(); + fs::write(extensions.join("z.jar"), b"jar").unwrap(); + fs::write(extensions.join("a.jar"), b"jar").unwrap(); + fs::write( + root.join("java-test").join("extensions.txt"), + "z.jar\na.jar\n", + ) + .unwrap(); + resolve_direct_jdtls_resources(root).expect("direct resources") + } + + #[test] + fn direct_resources_are_sorted_and_payload_uses_java_provider() { + let root = fixture("resources"); + let jdtls = root.join("jdtls"); + let jdk = root.join("jdk"); + fs::create_dir_all(jdk.join("bin")).unwrap(); + fs::write(jdk.join("bin").join("java"), b"java").unwrap(); + let resources = direct_jdtls_fixture(&jdtls); + assert!(resources + .launcher_jar_path + .ends_with("org.eclipse.equinox.launcher_1.jar")); + assert!(resources + .java_debug_bundle_path + .ends_with("com.microsoft.java.debug.plugin-1.jar")); + assert_eq!( + resources.java_extension_bundle_paths, + vec![ + path_string(&jdtls.join("java-test/extensions/a.jar")), + path_string(&jdtls.join("java-test/extensions/z.jar")), + ] + ); + let launch = JavaLspLaunch { + provider_id: JAVA_PROVIDER_ID.to_string(), + language_id: JAVA_PROVIDER_ID.to_string(), + executable_path: path_string(&jdk.join("bin/java")), + arguments: Vec::new(), + runtime_executable_path: Some(path_string(&jdk.join("bin/java"))), + jdtls_launch_resources: Some(resources), + java_home_path: Some(path_string(&jdk)), + jdtls_version: "1.61.0".to_string(), + }; + let payload = java_start_payload( + &launch, + &root.to_string_lossy(), + "/tmp/lithe-jdt-cache", + Some("build=fingerprint"), + Some(&json!({"version": 1, "reactorPath": "."})), + ); + assert_eq!(payload["providerId"], "java"); + assert_eq!( + payload["runtimeExecutablePath"], + path_string(&jdk.join("bin/java")) + ); + assert_eq!( + payload["jdtlsLaunchResources"]["launcherJarPath"], + path_string(&jdtls.join("plugins/org.eclipse.equinox.launcher_1.jar")) + ); + assert_eq!( + payload["jdtlsLaunchResources"]["configurationDirectory"], + path_string(&jdtls.join(linux_configuration_name())) + ); + assert_eq!( + payload["jdtlsLaunchResources"]["lombokAgentPath"], + path_string(&jdtls.join("lombok/lombok.jar")) + ); + assert_eq!( + payload["jdtlsLaunchResources"]["javaDebugBundlePath"], + path_string(&jdtls.join("java-debug/com.microsoft.java.debug.plugin-1.jar")) + ); + assert_eq!( + payload["jdtlsLaunchResources"]["javaExtensionBundlePaths"], + serde_json::json!([ + path_string(&jdtls.join("java-test/extensions/a.jar")), + path_string(&jdtls.join("java-test/extensions/z.jar")) + ]) + ); + assert_eq!(payload["workspaceFingerprint"], "build=fingerprint"); + assert_eq!(payload["mavenContext"]["reactorPath"], "."); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn missing_direct_resources_return_an_actionable_error() { + let root = fixture("missing-resources"); + let jdtls = root.join("jdtls"); + fs::create_dir_all(&jdtls).unwrap(); + let error = resolve_direct_jdtls_resources(&jdtls).expect_err("missing launcher"); + assert!(error.contains("launcher") && error.contains("jdtls")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn parses_java_launch_results_without_building_a_classpath() { + let entrypoints = parse_java_entrypoints(&json!({ + "schemaVersion": 1, + "entries": [ + {"sourcePath": "src/App.java", "mainClass": "demo.App", "projectName": "app"}, + {"sourcePath": "src/Other.java", "mainClass": "demo.Other"} + ] + })) + .expect("entrypoints"); + let selected = select_java_entrypoint(&entrypoints, "src/App.java", Some("demo.App")) + .expect("selected"); + let launch = java_launch_payload( + &selected, + &json!({"value": [[ "modules/app" ], ["classes/app", "classes/lib"]]}), + ) + .expect("launch"); + assert_eq!(launch["mainClass"], "demo.App"); + assert_eq!(launch["projectName"], "app"); + assert_eq!(launch["classPaths"][1], "classes/lib"); + assert!(launch.get("classpath").is_none()); + } + + #[test] + fn parses_java_build_result_and_rejects_non_success_status() { + assert!(parse_java_build_result(&json!({"value": 1})).is_ok()); + assert!(parse_java_build_result(&json!({"value": 2})).is_err()); + } + + #[test] + fn java_run_gate_requires_core_project_preparation_ready() { + let mut status = JavaLspStatus { + session_id: Some("java-1".to_string()), + state: "ready".to_string(), + error: None, + project_preparation: Some(json!({"status": "loading", "blocksRun": true})), + generation: 1, + }; + assert!(!java_lsp_allows_run(&status)); + status.project_preparation = Some(json!({"status": "ready", "blocksRun": false})); + assert!(java_lsp_allows_run(&status)); + status.project_preparation = Some(json!({"status": "ready", "blocksRun": true})); + assert!(!java_lsp_allows_run(&status)); + status.project_preparation = Some(json!({"status": "failed", "blocksRun": false})); + assert!(java_lsp_allows_run(&status)); + } + + #[test] + fn operation_results_reject_events_from_another_session() { + let value = json!({ + "events": [ + { + "type": "requestCompleted", + "sessionId": "old", + "operationId": "op-old", + "result": {"value": 1} + }, + { + "type": "requestCompleted", + "sessionId": "current", + "operationId": "op-current", + "error": { + "code": "javaBuildCompilationErrors", + "message": "compilation failed", + "details": "markerScope=launchTarget", + "javaBuildReport": {"recovery": "none"} + } + } + ] + }); + let results = operation_results_from_poll(&value, "current"); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, "op-current"); + assert!(results[0] + .1 + .error + .as_deref() + .unwrap() + .contains("javaBuildCompilationErrors")); + } } diff --git a/linux/src/workbench/bottom_panel.rs b/linux/src/workbench/bottom_panel.rs index be61351f9..f8f015e78 100644 --- a/linux/src/workbench/bottom_panel.rs +++ b/linux/src/workbench/bottom_panel.rs @@ -1,4 +1,5 @@ use std::sync::mpsc; +use std::time::{Duration, Instant}; use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants as _}; @@ -9,10 +10,11 @@ use gpui_kit::EventEmitter; use gpui_kit::{ div, px, AnyElement, AppContext as _, Context, Entity, FontWeight, InteractiveElement as _, IntoElement, ParentElement as _, Render, ScrollHandle, StatefulInteractiveElement as _, - Styled as _, Window, + Styled as _, WeakEntity, Window, }; use crate::core::CoreClient; +use crate::lsp; use crate::theme::ThemeColors; use crate::workbench::editor::EditorView; use crate::workbench::run::{ @@ -22,6 +24,7 @@ use crate::workbench::run::{ ProcessEvent, ProcessManager, RunConfigItem, }; use crate::workbench::terminal::TerminalView; +use crate::workbench::view::WorkbenchView; /// 运行历史上限。 const MAX_RUN_HISTORY: usize = 50; @@ -142,6 +145,8 @@ pub struct BottomPanelView { pub(crate) maven_running: bool, /// core 客户端(`reload_run_project` / `createLaunchPlan` 经它走 core JSON 命令)。 client: CoreClient, + /// 工作台持有的 Java LSP owner;使用弱引用避免父子 Entity 循环。 + workbench: Option>, /// 所有 Run/Maven 子进程的 session/execution 所有者。 processes: ProcessManager, /// Maven 启动序号,丢弃过期 `launchPlan` 结果。 @@ -162,6 +167,269 @@ pub struct BottomPanelView { impl EventEmitter for BottomPanelView {} +/// 等待 Workbench 轮询到指定 Core LSP 操作结果,并拒绝 workspace/session 替换。 +async fn wait_for_lsp_operation( + workbench: &Entity, + session_id: &str, + operation_id: &str, + timeout: Duration, + run_guard: Option<(&Entity, u64, u64)>, + cx: &mut gpui_kit::AsyncApp, +) -> Result { + let deadline = Instant::now() + timeout; + loop { + if let Some((owner, reload_seq, execution_seq)) = run_guard { + if !owner.read_with(cx, |view, _app| { + view.is_current_run_execution(reload_seq, execution_seq) + }) { + return Err( + "Run was replaced or cancelled before Java preparation completed.".to_string(), + ); + } + } + let status = workbench.read_with(cx, |workbench, _app| workbench.java_lsp_status()); + if status.session_id.as_deref() != Some(session_id) { + return Err( + "Java language-server session was replaced before the request completed." + .to_string(), + ); + } + if status.state == "failed" || status.state == "stopped" { + return Err(status.error.unwrap_or_else(|| { + "Java language-server session stopped before the request completed.".to_string() + })); + } + if let Some(result) = workbench.update(cx, |workbench, _cx| { + workbench.take_lsp_operation_result(session_id, operation_id) + }) { + return result; + } + if Instant::now() >= deadline { + return Err(format!( + "Java language-server request {operation_id} timed out after {} seconds.", + timeout.as_secs() + )); + } + cx.background_executor() + .timer(Duration::from_millis(100)) + .await; + } +} + +async fn wait_for_java_entrypoints( + workbench: &Entity, + client: &CoreClient, + timeout: Duration, + cx: &mut gpui_kit::AsyncApp, +) -> Result, String> { + workbench.update(cx, |workbench, cx| workbench.ensure_java_lsp(cx)); + let deadline = Instant::now() + timeout; + loop { + let status = workbench.read_with(cx, |workbench, _app| workbench.java_lsp_status()); + if status.state == "failed" || status.state == "stopped" { + return Ok(None); + } + if lsp::java_lsp_allows_run(&status) { + let Some(session_id) = status.session_id else { + return Err("Java language service became ready without a session id.".to_string()); + }; + let operation = client + .execute::( + cx, + "lsp.request", + lsp::java_entrypoints_request(&session_id), + ) + .await?; + let operation_id = operation + .get("operationId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + "Java entrypoint request did not return an operationId.".to_string() + })?; + let result = wait_for_lsp_operation( + workbench, + &session_id, + operation_id, + Duration::from_secs(35), + None, + cx, + ) + .await?; + return lsp::parse_java_entrypoints(&result).map(Some); + } + if Instant::now() >= deadline { + return Err( + "Java language service did not reach a runnable project state before the configuration deadline." + .to_string(), + ); + } + cx.background_executor() + .timer(Duration::from_millis(200)) + .await; + } +} + +async fn prepare_java_run_launch( + workbench: &Entity, + client: &CoreClient, + item: &RunConfigItem, + root: &str, + run_guard: (&Entity, u64, u64), + cx: &mut gpui_kit::AsyncApp, +) -> Result<(serde_json::Value, String), String> { + let source_path = item + .source + .as_deref() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "Java run configuration is missing its source path.".to_string())?; + let configured_main = item.main_class.as_deref(); + let source_key = std::path::Path::new(source_path) + .strip_prefix(root) + .map(|path| path.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|_| source_path.to_string()); + let source_for_build = lsp::absolute_path(root, source_path); + workbench.update(cx, |workbench, cx| workbench.ensure_java_lsp(cx)); + let ready_deadline = Instant::now() + Duration::from_secs(10 * 60); + let session_id = loop { + if !run_guard.0.read_with(cx, |view, _app| { + view.is_current_run_execution(run_guard.1, run_guard.2) + }) { + return Err( + "Run was replaced or cancelled before Java preparation completed.".to_string(), + ); + } + let status = workbench.read_with(cx, |workbench, _app| workbench.java_lsp_status()); + if status.state == "failed" || status.state == "stopped" { + return Err(status + .error + .unwrap_or_else(|| "Java language service is not ready.".to_string())); + } + if lsp::java_lsp_allows_run(&status) { + if let Some(session_id) = status.session_id { + break session_id; + } + } + if Instant::now() >= ready_deadline { + return Err( + "Java language service did not reach ServiceReady before the run deadline." + .to_string(), + ); + } + cx.background_executor() + .timer(Duration::from_millis(200)) + .await; + }; + + let entrypoints_operation = client + .execute::( + cx, + "lsp.request", + lsp::java_entrypoints_request(&session_id), + ) + .await?; + let entrypoints_operation_id = entrypoints_operation + .get("operationId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "Java entrypoint request did not return an operationId.".to_string())?; + let entrypoints = lsp::parse_java_entrypoints( + &wait_for_lsp_operation( + workbench, + &session_id, + entrypoints_operation_id, + Duration::from_secs(35), + Some(run_guard), + cx, + ) + .await?, + )?; + let entrypoint = lsp::select_java_entrypoint(&entrypoints, &source_key, configured_main)?; + let main_class = entrypoint + .get("mainClass") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "Java entrypoint result is missing mainClass.".to_string())?; + let project_name = entrypoint + .get("projectName") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + + let build_operation = client + .execute::( + cx, + "lsp.request", + lsp::java_build_request( + &session_id, + &source_for_build, + main_class, + project_name.as_deref(), + ), + ) + .await?; + let build_operation_id = build_operation + .get("operationId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "Java build request did not return an operationId.".to_string())?; + let build_result = wait_for_lsp_operation( + workbench, + &session_id, + build_operation_id, + Duration::from_secs(10 * 60), + Some(run_guard), + cx, + ) + .await; + let build_error = match &build_result { + Ok(value) => lsp::parse_java_build_result(value).err(), + Err(error) => Some(error.clone()), + }; + + let classpath_operation = client + .execute::( + cx, + "lsp.request", + lsp::java_classpath_request(&session_id, main_class, project_name.as_deref()), + ) + .await?; + let classpath_operation_id = classpath_operation + .get("operationId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "Java classpath request did not return an operationId.".to_string())?; + let classpath = match wait_for_lsp_operation( + workbench, + &session_id, + classpath_operation_id, + Duration::from_secs(35), + Some(run_guard), + cx, + ) + .await + { + Ok(value) => value, + Err(error) => { + if let Some(build_error) = &build_error { + return Err(format!( + "Java project build failed: {build_error}; runtime classpath resolution failed: {error}" + )); + } + return Err(error); + } + }; + let launch = match lsp::java_launch_payload(&entrypoint, &classpath) { + Ok(launch) => launch, + Err(error) => { + if let Some(build_error) = &build_error { + return Err(format!( + "Java project build failed: {build_error}; runtime classpath result was invalid: {error}" + )); + } + return Err(error); + } + }; + if let Some(error) = build_error { + return Err(format!("Java project build failed: {error}")); + } + Ok((launch, session_id)) +} + impl BottomPanelView { pub fn new(working_dir: String, cx: &mut Context) -> Self { let terminal = cx.new(|cx| TerminalView::new(working_dir.clone(), cx)); @@ -192,6 +460,7 @@ impl BottomPanelView { maven_output: Vec::new(), maven_running: false, client: CoreClient::new(), + workbench: None, processes: ProcessManager::new(), maven_seq: 0, last_maven_goal: None, @@ -359,6 +628,12 @@ impl BottomPanelView { cx.notify(); } + /// 设置工作台 Java LSP owner。Run 不直接创建第二个 JDT session。 + pub fn set_workbench(&mut self, workbench: Entity, cx: &mut Context) { + self.workbench = Some(workbench.downgrade()); + cx.notify(); + } + /// 重探 Run 工程:先 inspect,再按需 generate,随后持久化并 resolve。 pub fn reload_run_project(&mut self, cx: &mut Context) { self.run_seq += 1; @@ -378,6 +653,7 @@ impl BottomPanelView { let client = self.client.clone(); let root = self.working_dir.clone(); + let workbench = self.workbench.as_ref().and_then(WeakEntity::upgrade); cx.spawn(async move |this, cx| { let inspection = client .execute::( @@ -423,15 +699,44 @@ impl BottomPanelView { }; if status != "ready" { let paths = list_java_sources(&root); + let java_entrypoints = if paths.is_empty() { + None + } else if let Some(workbench) = workbench.as_ref() { + match wait_for_java_entrypoints( + workbench, + &client, + Duration::from_secs(10 * 60), + cx, + ) + .await + { + Ok(value) => value, + Err(error) => { + let _ = this.update(cx, |view, cx| { + if view.run_seq == seq { + view.run_state = RunProjectState::Failed(error); + cx.notify(); + } + }); + return; + } + } + } else { + None + }; + let mut generate_payload = serde_json::json!({ + "root": root, + "paths": paths, + "modulePaths": [] + }); + if let Some(entrypoints) = java_entrypoints { + generate_payload["javaEntrypoints"] = entrypoints; + } let generated = client .execute::( &cx, "runConfig.generate", - serde_json::json!({ - "root": root, - "paths": paths, - "modulePaths": [] - }), + generate_payload, ) .await; let generated = match generated { @@ -615,14 +920,26 @@ impl BottomPanelView { item.name ), ); + if Self::needs_java_project_preparation(&item) { + push_run_line( + &mut self.run_output, + run_ui_text( + cx, + "等待 Java 语言服务完成项目准备…", + "Waiting for Java language service preparation…", + ), + ); + } cx.notify(); let launch_seq = self.run_seq; let launch_execution_seq = execution_seq; let client = self.client.clone(); let root = self.working_dir.clone(); let editor = self.run_editor.clone(); + let workbench = self.workbench.as_ref().and_then(WeakEntity::upgrade); let processes = self.processes.clone(); cx.spawn(async move |this, cx| { + let run_owner = this.upgrade().expect("Run owner must remain alive while preparing"); if let Some(editor) = editor.as_ref() { let save = editor.update(cx, |editor, cx| editor.save_active_task(cx)); if let Err(error) = save.await { @@ -661,12 +978,70 @@ impl BottomPanelView { let maven_context = item .uses_maven() .then(|| maven_context_for_configuration(&item, &toolchains)); + let java_launch = if Self::needs_java_project_preparation(&item) { + match workbench.as_ref() { + Some(workbench) => { + prepare_java_run_launch( + workbench, + &client, + &item, + &root, + (&run_owner, launch_seq, launch_execution_seq), + cx, + ) + .await + .map(Some) + } + None => Err( + "Java language service is unavailable because the workbench owner is missing." + .to_string(), + ), + } + } else { + Ok(None) + }; + let java_launch = match java_launch { + Ok(value) => value, + Err(error) => { + let _ = this.update(cx, |view, cx| { + if view.is_current_run_execution(launch_seq, launch_execution_seq) { + view.run_running = false; + view.run_exit_code = Some(1); + view.run_diagnostics.push(error.clone()); + view.run_diagnostics.dedup(); + push_run_line(&mut view.run_output, error); + cx.notify(); + } + }); + return; + } + }; + if let (Some((_, session_id)), Some(workbench)) = + (java_launch.as_ref(), workbench.as_ref()) + { + let current_session = workbench + .read_with(cx, |workbench, _app| workbench.java_lsp_status()) + .session_id; + if current_session.as_deref() != Some(session_id.as_str()) { + let error = "Java language-server session changed before launch planning."; + let _ = this.update(cx, |view, cx| { + if view.is_current_run_execution(launch_seq, launch_execution_seq) { + view.run_running = false; + view.run_exit_code = Some(1); + view.run_diagnostics.push(error.to_string()); + push_run_line(&mut view.run_output, error.to_string()); + cx.notify(); + } + }); + return; + } + } let payload = create_launch_plan_request( &root, &item, current_file.as_deref(), maven_context.as_ref(), - None, + java_launch.as_ref().map(|(value, _)| value), ); let plan = client .execute::( @@ -1078,6 +1453,23 @@ impl BottomPanelView { } } + /// 判断配置是否必须由 JDT/Core 准备项目运行时路径。 + pub fn needs_java_project_preparation(item: &RunConfigItem) -> bool { + let has_target = item + .source + .as_deref() + .is_some_and(|source| !source.trim().is_empty()) + && item + .main_class + .as_deref() + .is_some_and(|main| !main.trim().is_empty()); + has_target + && (item.toolchains.contains_key("maven") + || item.provider == "spring-boot.maven" + || item.provider == "maven.module") + && (item.provider == "java.main" || item.provider == "spring-boot.maven") + } + fn is_current_run_execution(&self, reload_seq: u64, execution_seq: u64) -> bool { sequence_is_current(self.run_seq, reload_seq) && sequence_is_current(self.run_execution_seq, execution_seq) @@ -1818,3 +2210,90 @@ fn git_log_failed_text(cx: &gpui_kit::App) -> &'static str { "Failed to load git log" } } + +#[cfg(test)] +mod tests { + use super::*; + + fn item( + provider: &str, + source: Option<&str>, + main_class: Option<&str>, + module: Option<&str>, + ) -> RunConfigItem { + let mut extensions = serde_json::Map::new(); + if let Some(source) = source { + extensions.insert("java".to_string(), serde_json::json!({ "source": source })); + } + let mut maven = serde_json::Map::new(); + if let Some(module) = module { + maven.insert("module".to_string(), serde_json::json!(module)); + } + if let Some(main_class) = main_class { + maven.insert("mainClass".to_string(), serde_json::json!(main_class)); + } + extensions.insert("maven".to_string(), serde_json::Value::Object(maven)); + let mut toolchains = serde_json::Map::new(); + toolchains.insert("java".to_string(), serde_json::json!("project-jdk")); + if module.is_some() || provider.contains("maven") { + toolchains.insert("maven".to_string(), serde_json::json!("project-maven")); + } + let value = serde_json::json!({ + "id": "test", + "name": "Test", + "provider": provider, + "toolchains": toolchains, + "extensions": extensions, + }); + RunConfigItem::from_value(&value).expect("run config") + } + + #[test] + fn only_maven_backed_java_and_spring_targets_require_jdt() { + assert!(BottomPanelView::needs_java_project_preparation(&item( + "java.main", + Some("src/App.java"), + Some("demo.App"), + Some(".") + ))); + assert!(BottomPanelView::needs_java_project_preparation(&item( + "spring-boot.maven", + Some("src/App.java"), + Some("demo.App"), + Some(".") + ))); + assert!(!BottomPanelView::needs_java_project_preparation(&item( + "quarkus.maven", + Some("src/App.java"), + Some("demo.App"), + Some(".") + ))); + assert!(!BottomPanelView::needs_java_project_preparation(&item( + "java.main", + Some("src/App.java"), + Some("demo.App"), + None + ))); + assert!(!BottomPanelView::needs_java_project_preparation(&item( + "java.main", + None, + Some("demo.App"), + Some(".") + ))); + + let standalone = RunConfigItem::from_value(&serde_json::json!({ + "id": "standalone", + "name": "Standalone", + "provider": "java.main", + "toolchains": {"java": "project-jdk"}, + "extensions": { + "java": {"source": "src/App.java"}, + "maven": {"module": ".", "mainClass": "demo.App"} + } + })) + .expect("standalone config"); + assert!(!BottomPanelView::needs_java_project_preparation( + &standalone + )); + } +} diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index 5463a5fbb..4b5fa14e3 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -47,6 +47,8 @@ use crate::workbench::tab_menu::{SplitCallback, ToggleLockCallback}; use crate::workbench::toolbar::{ToolbarEvent, ToolbarView}; use crate::workbench::welcome_screen::{WelcomeEvent, WelcomeScreenView}; +const MAX_LSP_OPERATION_RESULTS: usize = 256; + /// 右侧工具窗口当前视图,对齐 Tauri `activeRightSidebarView` ///(`notifications` / `maven` / 扩展)。`None` 即隐藏,不持久化,重启丢失。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -127,6 +129,16 @@ pub struct WorkbenchView { lsp_pending_documents: HashMap>, /// LSP:工作区相对路径 → 该文件最新诊断,供底部面板聚合展示。 lsp_diagnostics: HashMap>, + /// 工作区代数;切换项目后旧 LSP 结果不能写回新工作区。 + lsp_workspace_generation: u64, + /// Java session 的展示状态;Run 只通过 Workbench 读取它。 + java_lsp_state: String, + /// Java session 最近一次启动或轮询错误。 + java_lsp_error: Option, + /// Core 轮询返回的 Java 项目准备快照。 + java_lsp_project_preparation: Option, + /// 已完成但尚未由 Run 取走的 Core LSP 请求结果。 + lsp_operation_results: HashMap, /// 侧边栏面板 (Files / Git / Search) pub sidebar: Entity, /// 多窗格布局模型(`Split{Horizontal}` 左右并排,`{Vertical}` 上下堆叠) @@ -189,8 +201,10 @@ impl WorkbenchView { pane_editors.insert(initial_pane, editor.clone()); Self::wire_pane_editor(&pane_tree, cx.entity(), initial_pane, &editor, cx); let bottom_panel = cx.new(|cx| BottomPanelView::new(root.clone(), cx)); + let workbench_entity = cx.entity(); bottom_panel.update(cx, |panel, cx| { panel.set_run_editor(Some(editor.clone()), cx); + panel.set_workbench(workbench_entity.clone(), cx); }); let status_bar = cx.new(|_cx| StatusBarView::new()); let search_everywhere = cx.new(|cx| SearchEverywhereModal::new(cx)); @@ -852,6 +866,11 @@ impl WorkbenchView { lsp_document_versions: HashMap::new(), lsp_pending_documents: HashMap::new(), lsp_diagnostics: HashMap::new(), + lsp_workspace_generation: 0, + java_lsp_state: "idle".to_string(), + java_lsp_error: None, + java_lsp_project_preparation: None, + lsp_operation_results: HashMap::new(), toolbar, activity_rail, plugin_rail, @@ -1055,10 +1074,46 @@ impl WorkbenchView { cx.notify(); } + fn lsp_invalidate_workspace(&mut self, cx: &mut Context) { + self.lsp_workspace_generation = self.lsp_workspace_generation.wrapping_add(1); + let sessions = self.lsp_sessions.drain().collect::>(); + self.lsp_starting.clear(); + self.lsp_polling.clear(); + self.lsp_document_versions.clear(); + self.lsp_pending_documents.clear(); + self.lsp_diagnostics.clear(); + self.lsp_operation_results.clear(); + self.java_lsp_state = "idle".to_string(); + self.java_lsp_error = None; + self.java_lsp_project_preparation = None; + self.sync_diagnostics_panel(cx); + for (_provider_id, session) in sessions { + let client = self.client.clone(); + cx.spawn(async move |_this, cx| { + let _ = client + .execute::<_, serde_json::Value>( + &cx, + "lsp.stopServer", + serde_json::json!({ "sessionId": session }), + ) + .await; + let _ = client + .execute::<_, serde_json::Value>( + &cx, + "lsp.destroyServer", + serde_json::json!({ "sessionId": session }), + ) + .await; + }) + .detach(); + } + } + fn open_project_path(&mut self, path: String, cx: &mut Context) { if self.show_project_dialog { self.show_project_dialog = false; } + self.lsp_invalidate_workspace(cx); self.workspace_root = path.clone(); self.show_welcome = false; let _ = self @@ -1191,25 +1246,95 @@ impl WorkbenchView { .update(cx, |panel, cx| panel.set_run_editor(editor, cx)); } + /// 返回 Run 所见的 Java session 状态。 + pub fn java_lsp_status(&self) -> lsp::JavaLspStatus { + lsp::JavaLspStatus { + session_id: self.lsp_sessions.get(lsp::JAVA_PROVIDER_ID).cloned(), + state: self.java_lsp_state.clone(), + error: self.java_lsp_error.clone(), + generation: self.lsp_workspace_generation, + project_preparation: self.java_lsp_project_preparation.clone(), + } + } + + /// 确保当前工作区拥有唯一的 Java Core session。 + pub fn ensure_java_lsp(&mut self, cx: &mut Context) { + if self.lsp_sessions.contains_key(lsp::JAVA_PROVIDER_ID) + || self.lsp_starting.contains(lsp::JAVA_PROVIDER_ID) + { + return; + } + let root = self.workspace_root.clone(); + let launch = match lsp::resolve_java_lsp_launch(&root) { + Ok(launch) => launch, + Err(error) => { + self.lsp_push_error(lsp::JAVA_PROVIDER_ID, &error, cx); + return; + } + }; + self.lsp_start_java_server(launch, root, cx); + } + + /// 取走一个属于指定 session 的请求结果;旧 session 的结果永远不能被 Run 消费。 + pub fn take_lsp_operation_result( + &mut self, + session_id: &str, + operation_id: &str, + ) -> Option> { + let key = format!("{session_id}\u{0}{operation_id}"); + let result = self.lsp_operation_results.remove(&key)?; + if result.session_id != session_id { + return None; + } + Some(match result.error { + Some(error) => Err(error), + None => result + .result + .ok_or_else(|| "Language-server request returned no result.".to_string()), + }) + } + /// 编辑器文档变化:选择语言服务器、按需启动会话并同步全文。 - /// 未知语言或本机无对应可执行文件时静默跳过,不影响编辑。 + /// Java 文档使用 Core `java` provider 和平台解析的 JDT LS 资源。 fn lsp_on_document_changed(&mut self, path: String, text: String, cx: &mut Context) { - let Some((provider, executable)) = lsp::resolve_provider(&path) else { + let Some(provider) = lsp::provider_for_path(&path) else { return; }; let root = self.workspace_root.clone(); let uri = lsp::file_uri(&root, &path); let language_id = lsp::language_id_for_path(&path, provider).to_string(); + if provider.id == lsp::JAVA_PROVIDER_ID { + if let Some(session) = self.lsp_sessions.get(provider.id).cloned() { + self.lsp_sync_document(&session, uri, language_id, text, cx); + } else { + self.queue_pending_document(provider.id, uri, language_id, text); + self.ensure_java_lsp(cx); + } + return; + } + + let Some((_, executable)) = lsp::resolve_provider(&path) else { + return; + }; if let Some(session) = self.lsp_sessions.get(provider.id).cloned() { self.lsp_sync_document(&session, uri, language_id, text, cx); return; } + self.queue_pending_document(provider.id, uri, language_id, text); + self.lsp_start_server(provider, executable, root, cx); + } - // 会话未就绪:先暂存最新全文,会话 ready 后统一 flush。 + fn queue_pending_document( + &mut self, + provider_id: &str, + uri: String, + language_id: String, + text: String, + ) { let pending = self .lsp_pending_documents - .entry(provider.id.to_string()) + .entry(provider_id.to_string()) .or_default(); if let Some(existing) = pending.iter_mut().find(|document| document.uri == uri) { existing.text = text; @@ -1220,10 +1345,9 @@ impl WorkbenchView { text, }); } - self.lsp_start_server(provider, executable, root, cx); } - /// 启动一个语言服务器会话;同一 provider 同时只允许一个启动任务。 + /// 启动一个非 Java 语言服务器会话;同一 provider 同时只允许一个启动任务。 fn lsp_start_server( &mut self, provider: LanguageProvider, @@ -1231,36 +1355,207 @@ impl WorkbenchView { root: String, cx: &mut Context, ) { - if self.lsp_starting.contains(provider.id) || self.lsp_sessions.contains_key(provider.id) { + let provider_id = provider.id.to_string(); + if self.lsp_starting.contains(&provider_id) || self.lsp_sessions.contains_key(&provider_id) + { return; } - self.lsp_starting.insert(provider.id.to_string()); + self.lsp_starting.insert(provider_id.clone()); + let cache_directory = format!("{root}/.lithe/lsp/{provider_id}"); + let payload = lsp::start_payload(provider, &executable, &root, &cache_directory); + self.spawn_lsp_start(provider_id, payload, cx); + } + fn lsp_start_java_server( + &mut self, + launch: lsp::JavaLspLaunch, + root: String, + cx: &mut Context, + ) { + let provider_id = lsp::JAVA_PROVIDER_ID.to_string(); + if self.lsp_starting.contains(&provider_id) || self.lsp_sessions.contains_key(&provider_id) + { + return; + } + self.lsp_starting.insert(provider_id.clone()); + self.java_lsp_state = "starting".to_string(); + self.java_lsp_error = None; + self.java_lsp_project_preparation = None; + self.lsp_diagnostics.remove("__lsp__java"); + self.sync_diagnostics_panel(cx); let client = self.client.clone(); - let cache_directory = format!("{root}/.lithe/lsp/{}", provider.id); - let payload = lsp::start_payload(provider, &executable, &root, &cache_directory); + let generation = self.lsp_workspace_generation; + let cache_directory = format!("{root}/.lithe/lsp/java"); + let fingerprint_request = lsp::workspace_fingerprint_request(&root, &launch.jdtls_version); + let maven_context = lsp::maven_context_for_workspace(&root); cx.spawn(async move |this, cx| { + let fingerprint = match client + .execute::( + &cx, + "java.jdtWorkspaceFingerprint", + fingerprint_request, + ) + .await + { + Ok(value) => value + .get("workspaceFingerprint") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + Err(error) => { + let _ = this.update(cx, |this, cx| { + if this.lsp_workspace_generation == generation { + this.lsp_starting.remove(lsp::JAVA_PROVIDER_ID); + this.lsp_push_error(lsp::JAVA_PROVIDER_ID, &error, cx); + } + }); + return; + } + }; + let Some(fingerprint) = fingerprint else { + let _ = this.update(cx, |this, cx| { + if this.lsp_workspace_generation == generation { + this.lsp_starting.remove(lsp::JAVA_PROVIDER_ID); + this.lsp_push_error( + lsp::JAVA_PROVIDER_ID, + "java.jdtWorkspaceFingerprint returned no workspace fingerprint", + cx, + ); + } + }); + return; + }; + let payload = lsp::java_start_payload( + &launch, + &root, + &cache_directory, + Some(&fingerprint), + maven_context.as_ref(), + ); let result = client .execute::<_, serde_json::Value>(&cx, "lsp.startServer", payload) .await; - let _ = this.update(cx, |this, cx| { - this.lsp_starting.remove(provider.id); - match result { - Ok(value) => match lsp::parse_session_id(&value) { - Some(session) => { - this.lsp_sessions - .insert(provider.id.to_string(), session.clone()); - this.lsp_start_poll(provider.id, session.clone(), cx); - this.lsp_flush_pending(provider.id, &session, cx); - } - None => { - this.lsp_push_error(provider.id, "lsp.startServer 未返回 sessionId", cx) - } - }, - Err(error) => this.lsp_push_error(provider.id, &error, cx), + let stale_session = result.as_ref().ok().and_then(lsp::parse_session_id); + let accepted = this + .update(cx, |this, cx| { + if this.lsp_workspace_generation != generation { + return false; + } + this.lsp_starting.remove(lsp::JAVA_PROVIDER_ID); + match result { + Ok(value) => match lsp::parse_session_id(&value) { + Some(session) => { + this.lsp_sessions + .insert(lsp::JAVA_PROVIDER_ID.to_string(), session.clone()); + this.java_lsp_state = value + .get("state") + .and_then(serde_json::Value::as_str) + .unwrap_or("initializing") + .to_string(); + this.java_lsp_error = None; + this.lsp_start_poll( + lsp::JAVA_PROVIDER_ID.to_string(), + session.clone(), + generation, + cx, + ); + this.lsp_flush_pending(lsp::JAVA_PROVIDER_ID, &session, cx); + } + None => this.lsp_push_error( + lsp::JAVA_PROVIDER_ID, + "lsp.startServer 未返回 sessionId", + cx, + ), + }, + Err(error) => this.lsp_push_error(lsp::JAVA_PROVIDER_ID, &error, cx), + } + cx.notify(); + true + }) + .unwrap_or(false); + if !accepted { + if let Some(session) = stale_session { + let _ = client + .execute::<_, serde_json::Value>( + &cx, + "lsp.stopServer", + serde_json::json!({ "sessionId": session }), + ) + .await; + let _ = client + .execute::<_, serde_json::Value>( + &cx, + "lsp.destroyServer", + serde_json::json!({ "sessionId": session }), + ) + .await; } - cx.notify(); - }); + } + }) + .detach(); + } + + fn spawn_lsp_start( + &mut self, + provider_id: String, + payload: serde_json::Value, + cx: &mut Context, + ) { + let client = self.client.clone(); + let generation = self.lsp_workspace_generation; + cx.spawn(async move |this, cx| { + let result = client + .execute::<_, serde_json::Value>(&cx, "lsp.startServer", payload) + .await; + let stale_session = result.as_ref().ok().and_then(lsp::parse_session_id); + let accepted = this + .update(cx, |this, cx| { + if this.lsp_workspace_generation != generation { + return false; + } + this.lsp_starting.remove(&provider_id); + match result { + Ok(value) => match lsp::parse_session_id(&value) { + Some(session) => { + this.lsp_sessions + .insert(provider_id.clone(), session.clone()); + this.lsp_start_poll( + provider_id.clone(), + session.clone(), + generation, + cx, + ); + this.lsp_flush_pending(&provider_id, &session, cx); + } + None => this.lsp_push_error( + &provider_id, + "lsp.startServer 未返回 sessionId", + cx, + ), + }, + Err(error) => this.lsp_push_error(&provider_id, &error, cx), + } + cx.notify(); + true + }) + .unwrap_or(false); + if !accepted { + if let Some(session) = stale_session { + let _ = client + .execute::<_, serde_json::Value>( + &cx, + "lsp.stopServer", + serde_json::json!({ "sessionId": session }), + ) + .await; + let _ = client + .execute::<_, serde_json::Value>( + &cx, + "lsp.destroyServer", + serde_json::json!({ "sessionId": session }), + ) + .await; + } + } }) .detach(); } @@ -1291,7 +1586,9 @@ impl WorkbenchView { cx: &mut Context, ) { let client = self.client.clone(); + let session_id = session.to_string(); let key = format!("{session}\u{0}{uri}"); + let generation = self.lsp_workspace_generation; let payload = lsp::sync_payload(session, &uri, &language_id, &text); cx.spawn(async move |this, cx| { let result = client @@ -1302,11 +1599,18 @@ impl WorkbenchView { .get("documentVersion") .and_then(serde_json::Value::as_i64); let _ = this.update(cx, |this, _cx| { + if this.lsp_workspace_generation != generation + || !this + .lsp_sessions + .values() + .any(|current| current == &session_id) + { + return; + } match version { Some(version) => { this.lsp_document_versions.insert(key, version); } - // 保留会话-文档映射,便于后续清理时识别归属。 None => { this.lsp_document_versions.entry(key).or_insert(0); } @@ -1320,8 +1624,9 @@ impl WorkbenchView { /// 为会话启动唯一的事件轮询循环,直至服务器停止或命令失败。 fn lsp_start_poll( &mut self, - provider_id: &'static str, + provider_id: String, session: String, + generation: u64, cx: &mut Context, ) { if !self.lsp_polling.insert(session.clone()) { @@ -1337,23 +1642,85 @@ impl WorkbenchView { .await; let finished = match result { Ok(value) => { - let diagnostics = lsp::diagnostics_from_poll(&value, &root); - let finished = lsp::session_finished(&value); - let provider = provider_id.to_string(); + let diagnostics = lsp::diagnostics_from_poll_for(&value, &root, &session); + let finished = lsp::session_finished_for(&value, &session); + let results = lsp::operation_results_from_poll(&value, &session); + let java_state = lsp::java_lifecycle_state_for(&value, Some(&session)); + let preparation = value.get("projectPreparation").cloned(); + let provider = provider_id.clone(); let session_key = session.clone(); let _ = this.update(cx, |this, cx| { + if this.lsp_workspace_generation != generation + || this.lsp_sessions.get(&provider).map(String::as_str) + != Some(session_key.as_str()) + { + return; + } if !diagnostics.is_empty() { this.lsp_apply_diagnostics(diagnostics, cx); } + for (operation_id, result) in results { + let key = format!("{session_key}\u{0}{operation_id}"); + if !this.lsp_operation_results.contains_key(&key) + && this.lsp_operation_results.len() >= MAX_LSP_OPERATION_RESULTS + { + if let Some(evicted) = + this.lsp_operation_results.keys().next().cloned() + { + this.lsp_operation_results.remove(&evicted); + } + } + this.lsp_operation_results.insert(key, result); + } + if provider == lsp::JAVA_PROVIDER_ID { + if let Some(state) = java_state.clone() { + this.java_lsp_state = state; + if this.java_lsp_state == "ready" { + this.java_lsp_error = None; + } + } + this.java_lsp_project_preparation = preparation; + } if finished { + let java_failed = provider == lsp::JAVA_PROVIDER_ID + && java_state.as_deref() == Some("failed"); this.lsp_forget_session(&provider, &session_key, cx); + if java_failed { + this.java_lsp_state = "failed".to_string(); + this.java_lsp_error = Some( + "Java language-server session reported a terminal failure." + .to_string(), + ); + } } + cx.notify(); }); finished } - Err(_) => true, + Err(error) => { + let provider = provider_id.clone(); + let session_key = session.clone(); + let _ = this.update(cx, |this, cx| { + if this.lsp_workspace_generation != generation + || this.lsp_sessions.get(&provider).map(String::as_str) + != Some(session_key.as_str()) + { + return; + } + this.lsp_forget_session(&provider, &session_key, cx); + this.lsp_push_error(&provider, &error, cx); + }); + true + } }; if finished { + let _ = client + .execute::<_, serde_json::Value>( + &cx, + "lsp.destroyServer", + serde_json::json!({ "sessionId": session }), + ) + .await; break; } cx.background_executor() @@ -1361,7 +1728,9 @@ impl WorkbenchView { .await; } let _ = this.update(cx, |this, _cx| { - this.lsp_polling.remove(&session); + if this.lsp_workspace_generation == generation { + this.lsp_polling.remove(&session); + } }); }) .detach(); @@ -1369,11 +1738,18 @@ impl WorkbenchView { /// 会话终止后的清理:移除会话、轮询标记、文档版本与错误占位。 fn lsp_forget_session(&mut self, provider_id: &str, session: &str, cx: &mut Context) { + if self.lsp_sessions.get(provider_id).map(String::as_str) != Some(session) { + return; + } self.lsp_sessions.remove(provider_id); self.lsp_polling.remove(session); let prefix = format!("{session}\u{0}"); self.lsp_document_versions .retain(|key, _| !key.starts_with(&prefix)); + if provider_id == lsp::JAVA_PROVIDER_ID { + self.java_lsp_state = "stopped".to_string(); + self.java_lsp_project_preparation = None; + } let error_key = format!("__lsp__{provider_id}"); if self.lsp_diagnostics.remove(&error_key).is_some() { self.sync_diagnostics_panel(cx); @@ -1402,6 +1778,10 @@ impl WorkbenchView { /// 启动/同步失败时在诊断面板留一条错误记录,便于定位缺失的服务器。 fn lsp_push_error(&mut self, provider_id: &str, message: &str, cx: &mut Context) { + if provider_id == lsp::JAVA_PROVIDER_ID { + self.java_lsp_state = "failed".to_string(); + self.java_lsp_error = Some(message.to_string()); + } let entry = DiagnosticEntry { severity: "error".to_string(), file_path: provider_id.to_string(), From b566790ac6c834581e9a373211c5c6a555175e2b Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 17:34:08 +0800 Subject: [PATCH 058/109] =?UTF-8?q?feat(linux):=20=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=A4=8D=E7=94=A8=20alacritty=5Fterminal=20?= =?UTF-8?q?=E5=BC=95=E6=93=8E=E5=B9=B6=E8=A1=A5=E9=BD=90=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重写 linux/src/workbench/terminal.rs,去掉自绘的固定 120x30 终端,改为 只做「上游引擎 -> GPUI 元素」的投影,终端语义全部交给 alacritty_terminal。 功能点: - 随窗缩放:容器尺寸变化时 resize 网格并同步 PTY 行列数,全屏 TUI 能拿到 正确窗口大小;上游 reflow 由 Term::resize 完成。 - 回滚滚动:滚轮驱动 Term::scroll_display,可查看历史输出。 - 鼠标选择:左键单击/双击/三击分别是 simple/semantic/lines 选区,拖动更新; 选区命中换算叠加回滚偏移,上滚后仍命中同一行。 - 复制粘贴:Ctrl+Shift+C/V 走 Term::selection_to_string 与系统剪贴板。 - 终端内搜索:Ctrl+Shift+F 打开搜索栏,用 term::search::RegexSearch + Term::search_next 上/下一个命中,命中滚入视口并复用 selection 高亮。 - 等宽对齐:单元格宽用上游 em_layout_width 按字号实测,鼠标命中所用 CellMetrics、网格 cols/rows、PTY 尺寸三者共用同一来源。 - 光标:聚焦时反色块,含行尾之后与空行两处补块。 其余: - i18n 新增 terminal.searchPlaceholder 文案键。 - 新增 Agent Note 记录「GPUI 无可复用完整终端控件、只复用引擎层」的边界。 - 新增 12 个测试:坐标/内边距/回滚偏移换算、256 色与加粗解算,以及引擎 联通性(解析入网格、display_offset、selection 导出、resize 列数)。 验证:cargo build/test 通过(lib 52 项);verify-test-stability 通过; verify-agent-notes 通过(36 篇)。 --- ...-09-25-linux-gpui-terminal-engine-reuse.md | 111 ++ linux/src/i18n.rs | 1 + linux/src/workbench/terminal.rs | 957 +++++++++++++++--- 3 files changed, 942 insertions(+), 127 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-25-linux-gpui-terminal-engine-reuse.md diff --git a/.agents/notes/implemented/architecture/2026-09-25-linux-gpui-terminal-engine-reuse.md b/.agents/notes/implemented/architecture/2026-09-25-linux-gpui-terminal-engine-reuse.md new file mode 100644 index 000000000..b52b9f419 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-25-linux-gpui-terminal-engine-reuse.md @@ -0,0 +1,111 @@ +# Agent 笔记:Linux GPUI 终端的引擎复用边界 + +状态:已实现 + +## 先说结论 + +Linux 原生 GPUI 版终端不自己实现终端语义,只复用成熟上游 `alacritty_terminal` +作为“终端引擎”(负责解析 ANSI 转义序列、维护字符网格、回滚、选择、搜索), +再由本仓库把它投影成 GPUI 元素。原因是 GPUI 生态不存在可复用的成熟终端 +控件,唯一成熟且可合法分发的上游是引擎层。开发者要记住:**终端行为(滚动、 +选择、搜索、随窗缩放、reflow)只能调用 `alacritty_terminal` 的既有 API, +禁止在 `terminal.rs` 里手写第二套终端语义。** + +## 问题 + +Linux 版工作台使用 GPUI Kit 构建,底部面板需要一个嵌入式终端。 +Windows 版同类能力由前端 xterm.js 承担,xterm.js 是完整的成熟终端控件 +(自带网格渲染、滚动、选择、搜索)。GPUI 没有 DOM,无法复用 xterm.js。 + +早期实现虽然在依赖里引了 `alacritty_terminal`,但只把它当解析器使用: +渲染固定 120×30、不随窗缩放,且完全没接上游已经提供的回滚滚动、 +文本选择、复制、搜索 API。结果是终端“能跑但不好用”,并被迫在文件头 +声明“多标签、搜索、复制粘贴、随窗缩放暂不支持”。 + +## 决策 + +### 复用边界 + +- `alacritty_terminal` 0.26 是 Linux 终端的唯一引擎。解析、网格状态、 + 回滚缓冲、选择模型、正则搜索、resize/reflow 全部由它提供。 +- `linux/src/workbench/terminal.rs` 只做三件事: + 1. 用 `portable-pty` 起 PTY 会话并转发字节; + 2. 调用上游 API(`Term::resize`、`Term::scroll_display`、 + `Selection`、`RegexSearch`、`Term::renderable_content`); + 3. 把网格投影成 GPUI 元素并处理鼠标/键盘事件。 +- 渲染层实现成“固定字符单元格”排版:单元格宽度由上游 + `WindowTextSystem::em_layout_width` 按当前字号实测,行高按字号倍率换算。 + 鼠标坐标换算、PTY 行列数、网格 `cols/rows` 三者必须使用同一套 + `CellMetrics`,否则选择与点击会出现半格偏移。 + +### 正确做法 + +- 需要滚动时调用 `term.scroll_display(Scroll::Delta / Bottom)`,不要 + 自己维护偏移量。 +- 需要复制时调用 `term.selection_to_string()`,不要自己遍历网格拼字符串。 +- 需要搜索时用 `term::search::RegexSearch` + `Term::search_next`,不要 + 自己写匹配循环。 +- 容器尺寸变化时用 `Term::resize(TermDims)` 并把 `cols/rows` 同步给 PTY; + reflow 由上游完成。 +- 鼠标命中用 `viewport_to_grid_point(局部坐标, CellMetrics, TermDims, display_offset)` + 换算,回滚偏移必须叠加,保证上滚后仍命中用户看到的同一行。 + +### 不要这样做 + +- 不要在 `terminal.rs` 里手写 ANSI 解析、换行、光标移动或滚动缓冲。 +- 不要固定网格行列数;固定尺寸会让全屏 TUI(如 vim、top)拿不到正确 + 窗口大小,也无法随窗缩放。 +- 不要用渲染文本的视觉长度代替单元格数换算鼠标列号;宽字符和样式分段 + 会让两者不一致。 +- 不要为了“加个功能”把终端语义下沉到 `bottom_panel.rs` 或 `view.rs`; + 宿主只负责切换标签、转发命令和生命周期。 + +## 考虑过的备选方案 + +### 备选方案一:引入 GPUI 生态的终端控件 + +如果存在成熟的 GPUI 终端控件,可以像 xterm.js 一样整体复用,工作量最小。 +但实际盘点后不存在:本地 Cargo registry 与可访问上游都没有 +`gpui-term`、`gpui_term`、`terminal-gpui` 这类 crate;Alacritty 自身的 +渲染在 `alacritty` 二进制 crate 中,基于 OpenGL/winit,与 GPUI 的 +`Element`/`Render` 体系不兼容,无法直接嵌入。 + +### 备选方案二:复用 `viml` / `wezterm-term` / `termwiz` 引擎 + +这些是其他终端模拟器的引擎,成熟度也够。但它们与 `alacritty_terminal` +能力重叠,且现有实现已经在用 `alacritty_terminal`。为同一目标引入第二个 +引擎会增加依赖体积和双份语义,收益为零,因此不采用。 + +### 备选方案三:继续维护现有固定尺寸实现 + +改动最小,但无法满足“和 Windows/Tauri 终端一致”的产品目标:固定 +120×30、无回滚、无选择、无搜索、无缩放,属于明确的能力缺口,不是可接受 +的长期状态。 + +## 后果 + +- 收益:滚动、选择、复制粘贴、搜索、随窗缩放、reflow 全部由上游保证, + 本仓库代码量显著下降,缺陷面收敛到“投影与事件接线”。 +- 收益:单元格度量、命中换算、PTY 尺寸三处共用同一来源,跨窗口大小与 + 字体缩放行为一致。 +- 代价:渲染仍需自绘(上游无 GPUI 渲染器),固定单元格排版要求等宽 + 字体,非等宽字体下对齐会退化。 +- 代价:多标签、横向分屏、超链接点击、图形协议(Sixel/Kitty)仍未接入, + 需后续按需扩展;这些属于产品编排,不属于引擎边界。 + +## 验证 + +- `cargo test --manifest-path linux/Cargo.toml -p lithe-linux --lib` + 覆盖:`viewport_to_grid_point` 的边界/内边距/回滚偏移换算、256 色与 + 加粗高亮解算,以及引擎联通性(解析输出入网格、回滚 `display_offset`、 + 选择导出字符串、`resize` 改变列数)。 +- `cargo build --manifest-path linux/Cargo.toml -p lithe-linux` 通过。 +- `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh` 通过。 + +## 适用范围 + +- `linux/src/workbench/terminal.rs`:Linux 终端的引擎调用、渲染投影与事件接线。 +- `linux/src/i18n.rs`:终端搜索栏等新增文案键。 +- 不适用于 Windows 产品;Windows 终端由 + `windows/tauri/src/features/terminal/`(xterm.js)与 + `windows/tauri/crates/terminal/`(`portable-pty`)承担。 diff --git a/linux/src/i18n.rs b/linux/src/i18n.rs index 1750e59b3..821e5a585 100644 --- a/linux/src/i18n.rs +++ b/linux/src/i18n.rs @@ -166,6 +166,7 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { ), "terminal.cleared" => ("终端已清空。", "Terminal cleared."), "terminal.unavailable" => ("PTY 不可用", "PTY unavailable"), + "terminal.searchPlaceholder" => ("在终端中查找", "Find in terminal"), "workbench.settings" => ("设置", "Settings"), "workbench.sourceControl" => ("源代码管理", "Source Control"), "fileExplorer.searchFiles" => ("搜索文件", "Search files"), diff --git a/linux/src/workbench/terminal.rs b/linux/src/workbench/terminal.rs index 471b03b72..90b3f51a3 100644 --- a/linux/src/workbench/terminal.rs +++ b/linux/src/workbench/terminal.rs @@ -1,36 +1,61 @@ -//! Linux 原生 PTY 终端:`portable-pty` 跑 shell,`alacritty_terminal` -//!(Alacritty 网格)做真机解析与状态,键盘字符模式直输(对齐 IDEA -//! 内嵌终端的交互:历史、补全、方向键、中断均由 PTY 行规程处理)。 +//! Linux 原生 PTY 终端:`portable-pty` 跑 shell,`alacritty_terminal`(Alacritty +//! 网格引擎)做解析与状态,键盘字符模式直输(对齐 IDEA 内嵌终端的交互:历史、 +//! 补全、方向键、中断均由 PTY 行规程处理)。 //! -//! 渲染取网格可见区,同样式字符合并展示;聚焦时在光标处画反色块。 -//! 适配声明:多标签、新终端、终端内搜索、复制粘贴、滚到末尾跟随、 -//! PTY 随窗缩放暂不支持(缺选择/滚动控制,采固定 120x30 跟随显示)。 +//! 渲染层不自行实现终端语义,只做网格到 GPUI 元素的投影:可见区按固定字符单元格 +//! 排版,同样式字符合并为一段。选择、回滚、搜索、随窗缩放全部调用 +//! `alacritty_terminal` 的既有 API(`Term::scroll_display`、`Selection`、 +//! `RegexSearch`、`Term::resize` 与网格 reflow),不重复实现上游能力。 +//! +//! 适配声明:多标签、横向分屏、超链接点击、图形协议(Sixel/Kitty)暂不支持。 +//! +//! Note: 引擎复用边界与禁止手写终端语义的原因见 +//! `.agents/notes/implemented/architecture/2026-09-25-linux-gpui-terminal-engine-reuse.md`。 use alacritty_terminal::event::VoidListener; -use alacritty_terminal::grid::Dimensions; +use alacritty_terminal::grid::{Dimensions, Scroll}; +use alacritty_terminal::index::{Column, Direction, Line, Point as GridPoint, Side}; +use alacritty_terminal::selection::{Selection, SelectionType}; use alacritty_terminal::term::cell::Flags; +use alacritty_terminal::term::search::{Match, RegexSearch}; use alacritty_terminal::term::{Config, Term, TermMode}; -use alacritty_terminal::vte::ansi::{Color as AlacColor, NamedColor, Processor, Rgb}; -use gpui_kit::component::scroll::ScrollableElement as _; -use gpui_kit::component::{h_flex, v_flex}; +use alacritty_terminal::vte::ansi::{Color as AlacColor, CursorShape, NamedColor, Processor, Rgb}; +use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::input::{Input, InputEvent, InputState}; +use gpui_kit::component::{h_flex, v_flex, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, Context, FocusHandle, FontWeight, InteractiveElement as _, IntoElement, KeyDownEvent, - ParentElement as _, Render, Rgba, StatefulInteractiveElement as _, Styled as _, Window, + div, px, App, AppContext as _, Bounds, ClipboardItem, Context, FocusHandle, FontWeight, + InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, + MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, Rgba, ScrollDelta, + ScrollWheelEvent, StatefulInteractiveElement as _, Styled as _, Window, }; use portable_pty::{native_pty_system, CommandBuilder, PtySize}; +use std::cell::Cell; use std::io::{Read, Write}; +use std::rc::Rc; use std::sync::{mpsc, Arc, Mutex}; use std::thread; use crate::settings; use crate::theme::ThemeColors; -/// PTY 固定尺寸(随窗缩放暂不支持)。 -const TERMINAL_COLS: usize = 120; -const TERMINAL_ROWS: usize = 30; +/// 初始网格尺寸(首帧布局完成前使用,随后按容器实测尺寸 resize)。 +const INITIAL_COLS: usize = 80; +const INITIAL_ROWS: usize = 24; + +/// 网格最小尺寸,避免退化到 0 列触发上游断言。 +const MIN_COLS: usize = 2; +const MIN_ROWS: usize = 1; + +/// 行高相对字号的倍率(等宽终端常用 1.4)。 +const LINE_HEIGHT_RATIO: f32 = 1.4; + +/// 网格内容内边距(左上角偏移,鼠标命中换算需扣除)。 +const GRID_PADDING: f32 = 4.0; /// 网格尺寸(`Term::new` 与 `resize` 共用)。 +#[derive(Debug, Clone, Copy)] struct TermDims { cols: usize, rows: usize, @@ -119,6 +144,34 @@ fn resolve_color(color: AlacColor, bold: bool, default: Rgba) -> Rgba { } } +/// 容器内局部坐标(已扣除容器原点)→ 网格点。 +/// +/// 先扣除网格内边距,再按单元格尺寸取整;越界返回 `None`。视口行需叠加 +/// 回滚偏移才是网格行(上游 `viewport_to_point` 语义),这样上滚后鼠标 +/// 选择仍命中用户看到的同一行。 +fn viewport_to_grid_point( + local_x: f32, + local_y: f32, + cell: CellMetrics, + size: TermDims, + display_offset: usize, +) -> Option { + let local_x = local_x - GRID_PADDING; + let local_y = local_y - GRID_PADDING; + if local_x < 0.0 || local_y < 0.0 { + return None; + } + + let col = (local_x / cell.width).floor() as usize; + let viewport_line = (local_y / cell.height).floor() as usize; + if col >= size.cols || viewport_line >= size.rows { + return None; + } + + let line = Line(viewport_line as i32 - display_offset as i32); + Some(GridPoint::new(line, Column(col))) +} + /// Linux 原生 PTY 会话 pub struct TerminalSession { writer: Arc>>, @@ -194,8 +247,7 @@ impl TerminalSession { Ok(()) } - /// PTY 缩放(随窗缩放接入后调用,当前固定尺寸保留接口)。 - #[allow(dead_code)] + /// PTY 缩放:容器尺寸变化时调用,使 shell / 全屏 TUI 拿到最新行列数。 pub fn resize(&self, cols: u16, rows: u16) { if let Ok(master) = self.master.lock() { let _ = master.resize(PtySize { @@ -208,30 +260,102 @@ impl TerminalSession { } } +/// 单元格尺寸(像素),渲染与鼠标命中都用它换算行列。 +#[derive(Debug, Clone, Copy)] +struct CellMetrics { + width: f32, + height: f32, +} + +/// 终端内搜索状态:查询串、上游正则缓存与命中计数。 +struct SearchState { + query: String, + regex: Option, + /// 当前命中序号(1 起,0 表示无命中)。 + current: usize, + /// 命中总数;上游按需搜索,不预先全量统计,未统计时为 0。 + total: usize, +} + +impl SearchState { + fn new() -> Self { + Self { + query: String::new(), + regex: None, + current: 0, + total: 0, + } + } + + fn clear(&mut self) { + self.query.clear(); + self.regex = None; + self.current = 0; + self.total = 0; + } +} + /// 终端视图组件:点击聚焦后键盘字符模式直输(IDEA 式交互)。 +/// +/// 网格引擎与交互能力全部来自上游 `alacritty_terminal`:本结构只负责调用 +/// 它的 resize/scroll/selection/search API,并把结果投影成 GPUI 元素。 pub struct TerminalView { pub session: Option, pub working_dir: String, term: Term, processor: Processor, focus_handle: FocusHandle, + /// 最近一次已知的网格尺寸(行列),用于判断是否需要 resize。 + size: TermDims, + /// 单元格像素尺寸;每次 render 按当前字号实测写入。 + cell: CellMetrics, + /// 网格内容区边界(窗口坐标);由 `on_children_prepainted` 写入。 + grid_bounds: Rc>>>, + /// 正在拖拽选择时的选区类型。 + dragging: Option, + /// 终端内搜索状态。 + search: SearchState, + /// 搜索栏输入框实体(懒创建)。 + search_input: Option>, + /// 搜索输入框 Change 订阅。 + search_subscription: Option, + /// 请求在下一帧展开搜索栏(键盘事件没有 `Window`,输入框需在 render 创建)。 + search_requested: bool, } impl TerminalView { pub fn new(working_dir: String, cx: &mut Context) -> Self { + let scrollback = settings::get(cx).terminal_scrollback.max(1); let mut view = Self { session: None, working_dir, term: Term::new( - Config::default(), + Config { + scrolling_history: scrollback, + ..Config::default() + }, &TermDims { - cols: TERMINAL_COLS, - rows: TERMINAL_ROWS, + cols: INITIAL_COLS, + rows: INITIAL_ROWS, }, VoidListener, ), processor: Processor::new(), focus_handle: cx.focus_handle(), + size: TermDims { + cols: INITIAL_COLS, + rows: INITIAL_ROWS, + }, + cell: CellMetrics { + width: 8.0, + height: 18.0, + }, + grid_bounds: Rc::new(Cell::new(None)), + dragging: None, + search: SearchState::new(), + search_input: None, + search_subscription: None, + search_requested: false, }; view.attach_session(cx); view @@ -241,8 +365,8 @@ impl TerminalView { fn attach_session(&mut self, cx: &mut Context) { let shell = Self::resolve_shell(cx); match TerminalSession::new( - TERMINAL_COLS as u16, - TERMINAL_ROWS as u16, + self.size.cols as u16, + self.size.rows as u16, &self.working_dir, &shell, ) { @@ -300,7 +424,7 @@ impl TerminalView { /// 解析生效 shell:设置 `terminalDefaultShellId` 非空即用(名称经 /// `PATH` 或 `/bin` 解析为路径),为空回退 `$SHELL`。 - fn resolve_shell(cx: &gpui_kit::App) -> String { + fn resolve_shell(cx: &App) -> String { let id = settings::get(cx) .terminal_default_shell_id .trim() @@ -347,18 +471,184 @@ impl TerminalView { /// 用现有工作目录重建 PTY 会话与网格(失败则置空并通知)。 pub fn respawn(&mut self, cx: &mut Context) { self.session = None; + let scrollback = settings::get(cx).terminal_scrollback.max(1); self.term = Term::new( - Config::default(), - &TermDims { - cols: TERMINAL_COLS, - rows: TERMINAL_ROWS, + Config { + scrolling_history: scrollback, + ..Config::default() }, + &self.size, VoidListener, ); self.processor = Processor::new(); + self.search.clear(); + self.dragging = None; self.attach_session(cx); } + /// 依据容器实测尺寸同步网格与 PTY(上游 reflow 由 `Term::resize` 完成)。 + fn sync_size(&mut self, bounds: Bounds, cx: &mut Context) { + let width = f32::from(bounds.size.width) - GRID_PADDING * 2.0; + let height = f32::from(bounds.size.height) - GRID_PADDING * 2.0; + if width <= 0.0 || height <= 0.0 { + return; + } + + let cols = ((width / self.cell.width).floor() as usize).max(MIN_COLS); + let rows = ((height / self.cell.height).floor() as usize).max(MIN_ROWS); + if cols == self.size.cols && rows == self.size.rows { + return; + } + + self.size = TermDims { cols, rows }; + self.term.resize(self.size); + if let Some(session) = &self.session { + session.resize(cols as u16, rows as u16); + } + cx.notify(); + } + + /// 窗口坐标 → 网格点(含回滚偏移);越界返回 `None`。 + fn grid_point_at(&self, position: gpui_kit::Point) -> Option { + let bounds = self.grid_bounds.get()?; + let display_offset = self.term.grid().display_offset(); + viewport_to_grid_point( + f32::from(position.x) - f32::from(bounds.origin.x), + f32::from(position.y) - f32::from(bounds.origin.y), + self.cell, + self.size, + display_offset, + ) + } + + /// 开始一次选择(单击 simple / 双击 semantic / 三击 lines)。 + fn begin_selection(&mut self, point: GridPoint, ty: SelectionType) { + self.term.selection = Some(Selection::new(ty, point, Side::Left)); + } + + /// 更新拖拽中的选择终点。 + fn update_selection(&mut self, point: GridPoint) { + if let Some(selection) = self.term.selection.as_mut() { + selection.update(point, Side::Right); + } + } + + /// 复制当前选择到系统剪贴板;无选择返回 false。 + fn copy_selection(&mut self, cx: &mut App) -> bool { + let Some(text) = self.term.selection_to_string() else { + return false; + }; + if text.is_empty() { + return false; + } + cx.write_to_clipboard(ClipboardItem::new_string(text)); + true + } + + /// 从系统剪贴板粘贴到 PTY(读不到或为空则忽略)。 + fn paste_clipboard(&mut self, cx: &mut App) { + let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else { + return; + }; + if let Some(session) = &self.session { + let _ = session.write_bytes(text.as_bytes()); + } + } + + /// 搜索串变化:重建上游正则并跳到首个命中。 + fn set_search_query(&mut self, query: String, cx: &mut Context) { + self.search.query = query; + self.search.current = 0; + self.search.total = 0; + self.search.regex = if self.search.query.is_empty() { + None + } else { + RegexSearch::new(&self.search.query).ok() + }; + self.search_step(true, cx); + } + + /// 跳到下一个/上一个命中。`forward` 决定方向。 + fn search_step(&mut self, forward: bool, cx: &mut Context) { + let Some(regex) = self.search.regex.as_mut() else { + cx.notify(); + return; + }; + if self.search.query.is_empty() { + cx.notify(); + return; + } + + let direction = if forward { + Direction::Right + } else { + Direction::Left + }; + let origin = self.term.grid().cursor.point; + let found: Option = + self.term + .search_next(regex, origin, direction, Side::Left, None); + + if let Some(found) = found { + self.search.current = if forward { + self.search.current.saturating_add(1).max(1) + } else { + self.search.current.saturating_sub(1).max(1) + }; + // 把命中滚入视口并选中,复用上游 selection 作为高亮来源。 + let start = *found.start(); + let end = *found.end(); + self.term.scroll_to_point(start); + let mut selection = Selection::new(SelectionType::Simple, start, Side::Left); + selection.update(end, Side::Right); + self.term.selection = Some(selection); + } else { + self.search.current = 0; + } + cx.notify(); + } + + /// 关闭搜索栏并清除搜索态选择。 + fn close_search(&mut self, cx: &mut Context) { + self.search.clear(); + self.term.selection = None; + self.search_requested = false; + // 丢弃输入框实体即释放搜索栏;下次打开重建空值输入框。 + self.search_subscription = None; + self.search_input = None; + cx.notify(); + } + + /// 搜索栏是否可见。 + fn search_visible(&self) -> bool { + self.search_requested || self.search_input.is_some() + } + + /// 懒创建搜索输入框并订阅 Change 事件,值同步到搜索状态。 + fn ensure_search_input( + slot: &mut Option>, + subscription: &mut Option, + window: &mut Window, + cx: &mut Context, + ) -> gpui_kit::Entity { + if let Some(entity) = slot { + return entity.clone(); + } + let entity = cx.new(|cx| InputState::new(window, cx)); + let value_source = entity.clone(); + *subscription = Some(cx.subscribe( + &entity, + move |this: &mut Self, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + let value = value_source.read(cx).value().to_string(); + this.set_search_query(value, cx); + } + }, + )); + *slot = Some(entity.clone()); + entity + } + /// 应用光标键模式:程序用 `\x1bO` 系,否则 `\x1b[` 系。 fn cursor_key(&self, normal: &[u8], app: &[u8]) -> Vec { if self.term.mode().contains(TermMode::APP_CURSOR) { @@ -370,9 +660,32 @@ impl TerminalView { /// 键盘直输:可打印字符原样写 PTY,控制组合与功能键转义序列, /// 未知键放行(IDEA 快捷键不断)。返回是否消费。 - fn handle_key(&mut self, event: &KeyDownEvent) -> bool { + fn handle_key(&mut self, event: &KeyDownEvent, cx: &mut App) -> bool { let key = event.keystroke.key.as_str(); let mods = &event.keystroke.modifiers; + + // 终端内复制/粘贴/搜索:Ctrl+Shift+C / Ctrl+Shift+V / Ctrl+Shift+F。 + // 放在最前,避免被下面的 Ctrl 控制字符分支吞掉。 + if mods.control && mods.shift && !mods.alt && !mods.platform { + match key.to_lowercase().as_str() { + "c" => { + self.copy_selection(cx); + return true; + } + "v" => { + self.paste_clipboard(cx); + return true; + } + "f" => { + // 输入框需要 `Window`,此处只登记请求,render 阶段创建。 + // 刷新由调用方 `on_key_down` 统一触发。 + self.search_requested = true; + return true; + } + _ => {} + } + } + let bytes: Vec = if mods.control && !mods.alt && !mods.platform { // Ctrl+字母 → 控制字符(Ctrl+C 中断等)。 let lower = key.to_lowercase(); @@ -443,15 +756,38 @@ impl TerminalView { impl Render for TerminalView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let focused = self.focus_handle.is_focused(window); + + // 单元格基准宽度:等宽字体下用上游 `em_layout_width` 实测(随字号变化)。 + let font_size = px(crate::settings::get(cx).terminal_font_size); + let font = gpui_kit::Font { + family: "monospace".into(), + ..Default::default() + }; + let font_id = window.text_system().resolve_font(&font); + let cell_width = + f32::from(window.text_system().em_layout_width(font_id, font_size)).max(1.0); + let line_height = f32::from(font_size) * LINE_HEIGHT_RATIO; + self.cell = CellMetrics { + width: cell_width, + height: line_height, + }; + + // 用上一帧 prepaint 记录的容器边界同步网格与 PTY;尺寸变化时 + // `sync_size` 内部 resize 上游网格并通知 PTY。 + if let Some(bounds) = self.grid_bounds.get() { + self.sync_size(bounds, cx); + } + + // 可见区按行分组(`display_iter` 即当前视口,已含回滚偏移)。 + let content = self.term.renderable_content(); + let selection = content.selection; let default_fg = ThemeColors::foreground(); let default_bg = ThemeColors::background(); + let selection_color = ThemeColors::accent(); - // 可见区按行分组(`display_iter` 即当前视口)。 - let content = self.term.renderable_content(); - let mut rows: Vec<(i32, Vec<(usize, char, AlacColor, AlacColor, bool)>)> = Vec::new(); + let mut rows: Vec<(i32, Vec)> = Vec::new(); for indexed in content.display_iter { let line = indexed.point.line.0; - let col = indexed.point.column.0; if rows.last().map(|(number, _)| *number) != Some(line) { rows.push((line, Vec::new())); } @@ -462,21 +798,76 @@ impl Render for TerminalView { std::mem::swap(&mut fg, &mut bg); } let bold = cell.flags.contains(Flags::BOLD); + let selected = selection + .as_ref() + .is_some_and(|range| range.contains(indexed.point)); rows.last_mut() .expect("rows non-empty after push") .1 - .push((col, cell.c, fg, bg, bold)); + .push(TermCell { + c: cell.c, + fg, + bg, + bold, + selected, + }); } let (cursor_line, cursor_col, cursor_visible) = ( content.cursor.point.line.0, content.cursor.point.column.0, focused - && !matches!( - content.cursor.shape, - alacritty_terminal::vte::ansi::CursorShape::Hidden - ), + && !matches!(content.cursor.shape, CursorShape::Hidden) + && self.term.grid().display_offset() == 0, ); + let grid = div() + .flex_1() + .w_full() + .min_h_0() + .overflow_hidden() + .pt(px(GRID_PADDING)) + .pl(px(GRID_PADDING)) + .text_size(font_size) + .font_family("monospace") + .children(rows.into_iter().map(|(line, cells)| { + render_term_row( + line, + &cells, + cursor_visible && line == cursor_line, + cursor_col, + default_fg, + default_bg, + selection_color, + cell_width, + line_height, + ) + })); + + // 搜索输入框懒创建 + 一次性订阅 Change(键盘请求在 render 落地)。 + let search_input = self.search_visible().then(|| { + Self::ensure_search_input( + &mut self.search_input, + &mut self.search_subscription, + window, + cx, + ) + }); + + // prepaint 记录网格容器边界,供鼠标坐标换算与 resize。 + // `on_children_prepainted` 给出的是子元素边界,因此外层包一层只有一个 + // 子元素的容器,`bounds.first()` 就是网格容器自身的实测边界。 + let bounds_slot = self.grid_bounds.clone(); + let grid_with_bounds = div() + .flex_1() + .w_full() + .min_h_0() + .on_children_prepainted(move |bounds, _window, _cx| { + if let Some(first) = bounds.first() { + bounds_slot.set(Some(*first)); + } + }) + .child(grid); + v_flex() .size_full() .bg(ThemeColors::background()) @@ -487,131 +878,443 @@ impl Render for TerminalView { window.focus(&this.focus_handle, cx); })) .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { - if this.handle_key(event) { + if this.handle_key(event, cx) { cx.stop_propagation(); cx.notify(); } })) + // 鼠标:按下定位并记录选区起点,拖动更新,抬起结束。 + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, event: &MouseDownEvent, _window, cx| { + let Some(point) = this.grid_point_at(event.position) else { + return; + }; + let ty = match event.click_count { + 0 | 1 => SelectionType::Simple, + 2 => SelectionType::Semantic, + _ => SelectionType::Lines, + }; + this.begin_selection(point, ty); + this.dragging = Some(ty); + cx.notify(); + }), + ) + .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| { + if this.dragging.is_none() { + return; + } + if let Some(point) = this.grid_point_at(event.position) { + this.update_selection(point); + cx.notify(); + } + })) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _event: &MouseUpEvent, _window, cx| { + this.dragging = None; + cx.notify(); + }), + ) + // 滚轮:直接驱动上游滚动缓冲。 + .on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _window, cx| { + let lines = match event.delta { + ScrollDelta::Lines(point) => f64::from(point.y), + ScrollDelta::Pixels(point) => f64::from(point.y) / this.cell.height as f64, + }; + let delta = lines.round() as i32; + if delta != 0 { + this.term.scroll_display(Scroll::Delta(delta)); + cx.notify(); + } + })) + .when_some(search_input, |el, input| { + el.child(self.render_search_bar(input, cx)) + }) + .child(grid_with_bounds) + } +} + +impl TerminalView { + /// 渲染搜索栏(终端内搜索,对齐 Tauri 终端搜索条)。 + fn render_search_bar( + &self, + input: gpui_kit::Entity, + cx: &mut Context, + ) -> impl IntoElement { + let counter = if self.search.query.is_empty() { + String::new() + } else { + format!( + "{}/{}", + self.search.current, + self.search.total.max(self.search.current) + ) + }; + h_flex() + .w_full() + .flex_shrink_0() + .items_center() + .gap_2() + .px_2() + .py_1() + .bg(ThemeColors::bg_tab_bar()) + .border_b_1() + .border_color(ThemeColors::border()) .child( - // 输出网格(控制栏由底部窗格头负责,不再自带)。 div() .flex_1() - .w_full() - .min_h_0() - .overflow_y_scrollbar() - .p_2() + .child(Input::new(&input).small().cleanable(true)), + ) + .child( + div() .text_xs() - .text_size(px(crate::settings::get(cx).terminal_font_size)) - .font_family("monospace") - .children(rows.into_iter().map(|(line, cells)| { - render_term_row( - line, - &cells, - cursor_visible && line == cursor_line, - cursor_col, - default_fg, - default_bg, - ) + .text_color(ThemeColors::text_muted()) + .child(counter), + ) + .child( + Button::new("terminal-search-prev") + .small() + .ghost() + .icon(gpui_kit::assets::IconName::ChevronUp) + .on_click(cx.listener(|this, _event, _window, cx| { + this.search_step(false, cx); + })), + ) + .child( + Button::new("terminal-search-next") + .small() + .ghost() + .icon(gpui_kit::assets::IconName::ChevronDown) + .on_click(cx.listener(|this, _event, _window, cx| { + this.search_step(true, cx); + })), + ) + .child( + Button::new("terminal-search-close") + .small() + .ghost() + .icon(gpui_kit::assets::IconName::X) + .on_click(cx.listener(|this, _event, _window, cx| { + this.close_search(cx); })), ) } } -/// 渲染网格一行:同样式字符合并为一段;聚焦时在光标处画反色块。 +/// 一个网格单元格的渲染数据(有序遍历 `display_iter` 时收集)。 +#[derive(Clone, Copy)] +struct TermCell { + c: char, + fg: AlacColor, + bg: AlacColor, + bold: bool, + selected: bool, +} + +/// 渲染网格一行:同样式字符合并为一段;选中字符用选区底色;聚焦时在光标处 +/// 画反色块。每段用固定像素宽度(`字符数 × cell_width`)保证等宽对齐, +/// 鼠标命中换算依赖同一套单元格尺寸。 +#[allow(clippy::too_many_arguments)] fn render_term_row( line: i32, - cells: &[(usize, char, AlacColor, AlacColor, bool)], + cells: &[TermCell], cursor_here: bool, cursor_col: usize, default_fg: Rgba, default_bg: Rgba, + selection_color: Rgba, + cell_width: f32, + line_height: f32, ) -> gpui_kit::AnyElement { - // 合并同样式段(宽字符占位格视为空格参与合并)。 - let mut spans: Vec<(AlacColor, AlacColor, bool, String)> = Vec::new(); - for (col, c, fg, bg, bold) in cells { - let _ = col; - let key = (*fg, *bg, *bold); - if let Some((sfg, sbg, sbold, text)) = spans.last_mut() { - if (*sfg, *sbg, *sbold) == key { - text.push(*c); - continue; - } - } - spans.push((*fg, *bg, *bold, c.to_string())); - } - let total: usize = spans - .iter() - .map(|(_, _, _, text)| text.chars().count()) - .sum(); - // 光标超出文本末尾时在行尾补空格块。 - let trailing = cursor_here && cursor_col >= total; - let mut row_el = h_flex() + let mut row = h_flex() .id(format!("term-row-{line}")) .w_full() + .h(px(line_height)) .items_center() - .flex_wrap() - .text_xs() .font_family("monospace"); - if spans.is_empty() && !trailing { - return row_el - .child(div().child(" ".to_string())) - .into_any_element(); - } - let mut col = 0; - let mut cursor_drawn = false; - for (fg, bg, bold, text) in &spans { - let fg_resolved = resolve_color(*fg, *bold, default_fg); - let bg_resolved = resolve_color(*bg, false, default_bg); - let chunk = |text: String| { - div() - .text_color(fg_resolved) - .when(*bg != AlacColor::Named(NamedColor::Background), |el| { - el.bg(bg_resolved) - }) - .when(*bold, |el| el.font_weight(FontWeight::BOLD)) - .child(text) - }; - let span_len = text.chars().count(); - // 光标块整行只画一次:完全在光标前/后的段原样输出。 - if trailing || col + span_len <= cursor_col || cursor_drawn { - row_el = row_el.child(chunk(text.clone())); - col += span_len; + if cells.is_empty() { + // 空行:只有光标落在首格时画块,否则保底一个空格占位。 + if cursor_here && cursor_col == 0 { + row = row.child( + div() + .flex_shrink_0() + .w(px(cell_width)) + .text_color(default_bg) + .bg(default_fg) + .child(" ".to_string()), + ); + } else { + row = row.child(div().child(" ")); + } + return row.into_any_element(); + } + + // 逐格构建:同样式连续字符合并成段,光标位单独成块。 + let mut index = 0usize; + while index < cells.len() { + let cell = cells[index]; + let is_cursor = cursor_here && index == cursor_col; + + if is_cursor { + // 光标块:反色(前景当底、背景当字),宽一格。 + row = row.child( + div() + .flex_shrink_0() + .w(px(cell_width)) + .text_color(default_bg) + .bg(resolve_color(cell.fg, cell.bold, default_fg)) + .child(cell.c.to_string()), + ); + index += 1; continue; } - let mut before = String::new(); - let mut cursor_char: Option = None; - let mut after = String::new(); - for c in text.chars() { - if col < cursor_col { - before.push(c); - } else if cursor_char.is_none() { - cursor_char = Some(c); - } else { - after.push(c); + + // 合并同 (fg,bg,bold,selected) 的后续字符。 + let mut text = String::new(); + text.push(cell.c); + let mut len = 1usize; + while index + len < cells.len() { + let next = cells[index + len]; + if cursor_here && index + len == cursor_col { + break; } - col += 1; - } - if !before.is_empty() { - row_el = row_el.child(chunk(before)); + if next.fg != cell.fg + || next.bg != cell.bg + || next.bold != cell.bold + || next.selected != cell.selected + { + break; + } + text.push(next.c); + len += 1; } - row_el = row_el.child( + + let fg = resolve_color(cell.fg, cell.bold, default_fg); + let bg = resolve_color(cell.bg, false, default_bg); + row = row.child( div() - .text_color(default_bg) - .bg(fg_resolved) - .child(cursor_char.unwrap_or(' ').to_string()), + .flex_shrink_0() + .w(px(cell_width * len as f32)) + .text_color(fg) + .when(cell.selected, |el| el.bg(selection_color)) + .when( + !cell.selected && cell.bg != AlacColor::Named(NamedColor::Background), + |el| el.bg(bg), + ) + .when(cell.bold, |el| el.font_weight(FontWeight::BOLD)) + .child(text), ); - cursor_drawn = true; - if !after.is_empty() { - row_el = row_el.child(chunk(after)); - } + index += len; } - if trailing { - row_el = row_el.child( + + // 光标超出已给出单元格(行尾之后):补一个反色空格块。 + if cursor_here && cursor_col >= cells.len() { + row = row.child( div() + .flex_shrink_0() + .w(px(cell_width)) .text_color(default_bg) .bg(default_fg) .child(" ".to_string()), ); } - row_el.into_any_element() + + row.into_any_element() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dims(cols: usize, rows: usize) -> TermDims { + TermDims { cols, rows } + } + + fn cell() -> CellMetrics { + CellMetrics { + width: 8.0, + height: 16.0, + } + } + + /// 网格左上角第一格:局部坐标需扣除内边距。 + #[test] + fn maps_top_left_corner_to_origin_cell() { + let point = viewport_to_grid_point(GRID_PADDING, GRID_PADDING, cell(), dims(80, 24), 0) + .expect("top-left must hit a cell"); + assert_eq!(point.line.0, 0); + assert_eq!(point.column.0, 0); + } + + /// 内边距内的坐标不算命中,避免选中出现偏移半格。 + #[test] + fn rejects_point_inside_padding() { + assert!( + viewport_to_grid_point(GRID_PADDING - 0.5, GRID_PADDING, cell(), dims(80, 24), 0) + .is_none() + ); + assert!( + viewport_to_grid_point(GRID_PADDING, GRID_PADDING - 0.5, cell(), dims(80, 24), 0) + .is_none() + ); + } + + /// 列/行按单元格尺寸取整:第 n 格覆盖 [n*w, (n+1)*w)。 + #[test] + fn rounds_down_to_containing_cell() { + let point = viewport_to_grid_point( + GRID_PADDING + cell().width * 3.0 + 1.0, + GRID_PADDING + cell().height * 2.0 + 1.0, + cell(), + dims(80, 24), + 0, + ) + .expect("inside grid"); + assert_eq!(point.column.0, 3); + assert_eq!(point.line.0, 2); + } + + /// 越出网格列/行的坐标不命中。 + #[test] + fn rejects_out_of_bounds() { + let cols = 10; + let rows = 5; + let beyond_cols = viewport_to_grid_point( + GRID_PADDING + cell().width * cols as f32, + GRID_PADDING, + cell(), + dims(cols, rows), + 0, + ); + assert!(beyond_cols.is_none()); + let beyond_rows = viewport_to_grid_point( + GRID_PADDING, + GRID_PADDING + cell().height * rows as f32, + cell(), + dims(cols, rows), + 0, + ); + assert!(beyond_rows.is_none()); + } + + /// 回滚偏移把视口行还原成网格行:上滚 3 行后,视口首行是网格 -3 行。 + #[test] + fn applies_scrollback_offset_to_viewport_line() { + let point = viewport_to_grid_point(GRID_PADDING, GRID_PADDING, cell(), dims(80, 24), 3) + .expect("top-left with scrollback"); + assert_eq!(point.line.0, -3); + assert_eq!(point.column.0, 0); + } + + /// 256 色查表:0-15 取基础色,16 起为立方体,232 起为灰阶。 + #[test] + fn palette_256_covers_base_cube_and_grayscale() { + assert_eq!(palette_256(0), ANSI_PALETTE[0]); + assert_eq!(palette_256(15), ANSI_PALETTE[15]); + assert_eq!(palette_256(16), (0, 0, 0)); + assert_eq!(palette_256(231), (255, 255, 255)); + assert_eq!(palette_256(232), (8, 8, 8)); + assert_eq!(palette_256(255), (238, 238, 238)); + } + + /// 加粗时 0-7 基础色提升到 8-15 高亮 variant(标准终端行为)。 + #[test] + fn bold_promotes_base_colors_to_bright_variants() { + let bold = resolve_color(AlacColor::Named(NamedColor::Red), true, Rgba::default()); + let normal = resolve_color(AlacColor::Named(NamedColor::Red), false, Rgba::default()); + assert_eq!(bold, rgba_of(ANSI_PALETTE[9])); + assert_eq!(normal, rgba_of(ANSI_PALETTE[1])); + assert_ne!(bold, normal); + } + + /// 引擎联通性:喂入带 ANSI 换行的字节后,可见区出现对应文本。 + /// 这条守住 render 路径依赖的上游解析/网格行为。 + #[test] + fn engine_parses_output_into_grid() { + let mut term = Term::new(Config::default(), &dims(20, 4), VoidListener); + let mut processor: Processor = Processor::new(); + processor.advance(&mut term, b"hello\r\nworld"); + let content = term.renderable_content(); + let text: String = content.display_iter.map(|indexed| indexed.cell.c).collect(); + assert!( + text.contains("hello"), + "first line must contain hello: {text:?}" + ); + assert!( + text.contains("world"), + "second line must contain world: {text:?}" + ); + } + + /// 引擎联通性:写入超过屏高的行后可用上游 API 滚出显示偏移。 + #[test] + fn engine_scrollback_exposes_display_offset() { + let mut term = Term::new( + Config { + scrolling_history: 100, + ..Config::default() + }, + &dims(20, 2), + VoidListener, + ); + let mut processor: Processor = Processor::new(); + processor.advance(&mut term, b"one\r\ntwo\r\nthree\r\nfour"); + assert_eq!(term.grid().display_offset(), 0); + term.scroll_display(Scroll::Delta(2)); + assert_eq!(term.grid().display_offset(), 2); + term.scroll_display(Scroll::Bottom); + assert_eq!(term.grid().display_offset(), 0); + } + + /// 引擎联通性:选择一块文本后可经上游导出字符串(复制链路)。 + #[test] + fn engine_selection_to_string_roundtrips() { + let mut term = Term::new(Config::default(), &dims(20, 4), VoidListener); + let mut processor: Processor = Processor::new(); + processor.advance(&mut term, b"abcdef"); + let mut selection = Selection::new( + SelectionType::Simple, + GridPoint::new(Line(0), Column(0)), + Side::Left, + ); + selection.update(GridPoint::new(Line(0), Column(2)), Side::Right); + term.selection = Some(selection); + let text = term.selection_to_string().expect("selection must export"); + assert!(text.starts_with("abc"), "selection text: {text:?}"); + } + + /// 引擎联通性:resize 改变列数(随窗缩放依赖上游 reflow)。 + #[test] + fn engine_resize_updates_columns() { + use alacritty_terminal::grid::Dimensions as _; + let mut term = Term::new(Config::default(), &dims(80, 24), VoidListener); + assert_eq!(term.columns(), 80); + term.resize(dims(100, 30)); + assert_eq!(term.columns(), 100); + assert_eq!(term.screen_lines(), 30); + } + + /// 默认前景/背景取主题色;加粗不改变已高于 7 的索引颜色。 + #[test] + fn default_colors_follow_theme_and_bold_ignored_above_index_seven() { + let theme = Rgba { + r: 0.25, + g: 0.5, + b: 0.75, + a: 1.0, + }; + assert_eq!( + resolve_color(AlacColor::Named(NamedColor::Foreground), false, theme), + theme + ); + assert_eq!( + resolve_color(AlacColor::Named(NamedColor::Background), true, theme), + theme + ); + assert_eq!( + resolve_color(AlacColor::Indexed(9), true, theme), + rgba_of(ANSI_PALETTE[9]) + ); + } } From 4c54ecaf6d11e6ecb603238052b8df193086d654 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 17:34:27 +0800 Subject: [PATCH 059/109] =?UTF-8?q?feat(linux):=20=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=87=8C=E7=9A=84=E9=85=8D=E8=89=B2=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设置 → 常规 → 外观 里同时有「配色主题」和「外观模式」两行,但 Linux 只有 lithe-dark / lithe-light 两个主题,前者与后者的浅色/深色选项完全重复, 且「外观模式」已额外覆盖「跟随系统」,因此删掉冗余的「配色主题」行。 - `settings_dialog.rs`:移除 `general-theme` 下拉行(含其 commit_theme 分支逻辑,该逻辑已由外观模式行覆盖)。 - `i18n.rs`:删除不再使用的 `settings.mac.colorTheme` 文案; `settings.mac.appearanceDescription` 改为只描述外观模式 (原文案「选择配色主题,并设置是否跟随系统外观」已不适用)。 验证:`cargo check` 无警告;`cargo test --lib` 52 通过。 --- linux/src/i18n.rs | 6 ++-- linux/src/workbench/settings_dialog.rs | 38 -------------------------- 2 files changed, 2 insertions(+), 42 deletions(-) diff --git a/linux/src/i18n.rs b/linux/src/i18n.rs index 821e5a585..38b478628 100644 --- a/linux/src/i18n.rs +++ b/linux/src/i18n.rs @@ -166,7 +166,6 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { ), "terminal.cleared" => ("终端已清空。", "Terminal cleared."), "terminal.unavailable" => ("PTY 不可用", "PTY unavailable"), - "terminal.searchPlaceholder" => ("在终端中查找", "Find in terminal"), "workbench.settings" => ("设置", "Settings"), "workbench.sourceControl" => ("源代码管理", "Source Control"), "fileExplorer.searchFiles" => ("搜索文件", "Search files"), @@ -355,11 +354,10 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { "ui.save" => ("保存", "Save"), // ---- 设置对话框内容:常规(与 Tauri macos-settings-panels.tsx 对齐) ---- "settings.mac.appearance" => ("外观", "Appearance"), - "settings.mac.colorTheme" => ("配色主题", "Color theme"), "settings.mac.appearanceMode" => ("外观模式", "Appearance mode"), "settings.mac.appearanceDescription" => ( - "选择配色主题,并设置是否跟随系统外观。", - "Choose a color theme and whether Lithe follows the system appearance.", + "选择界面外观,可固定为浅色或深色,或跟随系统。", + "Choose the interface appearance: light, dark, or follow the system.", ), "settings.mac.followSystem" => ("跟随系统", "Follow System"), "settings.mac.light" => ("浅色", "Light"), diff --git a/linux/src/workbench/settings_dialog.rs b/linux/src/workbench/settings_dialog.rs index 08edadfaa..7d2d1c5c1 100644 --- a/linux/src/workbench/settings_dialog.rs +++ b/linux/src/workbench/settings_dialog.rs @@ -981,47 +981,9 @@ impl SettingsDialog { v_flex() .w_full() .gap_3() - .child(self.render_row( - crate::i18n::menu_text(cx, "settings.mac.colorTheme").to_string(), - None, - self.render_dropdown( - "general-theme", - s.theme.clone(), - 160.0, - vec![ - ( - "lithe-dark", - crate::i18n::menu_text(cx, "settings.mac.dark") - .to_string(), - ), - ( - "lithe-light", - crate::i18n::menu_text(cx, "settings.mac.light") - .to_string(), - ), - ], - cx, - |this, theme_id, cx| { - // 跟随系统时写入对应的自动主题,否则直接切换主题。 - let sync = settings::get(cx).sync_system_theme; - this.commit_theme(cx, |s| { - if sync { - if crate::theme::ThemePalette::is_light(theme_id) { - s.auto_theme_light = theme_id.to_string(); - } else { - s.auto_theme_dark = theme_id.to_string(); - } - } else { - s.theme = theme_id.to_string(); - } - }) - }, - ), - )) .child( self.render_row( crate::i18n::menu_text(cx, "settings.mac.appearanceMode") - .to_string() .to_string(), Some( crate::i18n::menu_text( From 2281aba56c2b03b15e1d0bdc20130bec7f09a5c7 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 17:55:48 +0800 Subject: [PATCH 060/109] =?UTF-8?q?fix(linux):=20=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=8A=8A=20current=5Fdir=20=E5=BD=93?= =?UTF-8?q?=E4=BD=9C=E5=B7=A5=E4=BD=9C=E5=8C=BA=EF=BC=8C=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E4=B8=8A=E6=AC=A1=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象:第一次打开文件夹正确,第二次启动时工作区变成另一个目录。 原因:`WorkbenchView::new` 把 `std::env::current_dir()`(用户敲命令时的 shell 目录)直接当成工作区,导致: - 每次启动打开的是「启动目录」而不是「上次打开的项目」; - 在仓库根运行 `./target/debug/lithe-linux` 时,工作区被设成仓库根; - 该目录还会被无条件写进最近项目,污染列表。 修复:启动工作区改为显式优先级 - `lithe-linux [path]`:参数存在且为目录 → 用参数; - 否则取最近项目里第一个仍存在的目录(即上次打开的项目); - 都没有 → 空工作区,显示欢迎页(不再回退到 current_dir)。 其余配套: - `sidebar.rs`:空工作区不做初始扫描,避免一次注定失败的查询。 - `maven.rs`:空工作区不发起 `maven.scan`,直接保持 Idle。 - 构造时仅在有工作区时才 `record_recent_project`,欢迎页不写空目录。 - 新增 `resolve_initial_workspace`(纯函数)与 5 个测试,锁定优先级与 「失效最近项目被跳过」「无有效来源返回空串」的行为。 验证:`scripts/build-linux.sh` 通过;`cargo test --lib` 57 项通过; verify-test-stability 通过;从仓库根与带 CLI 参数两种方式启动均无 panic。 --- linux/src/workbench/maven.rs | 8 +++ linux/src/workbench/sidebar.rs | 8 ++- linux/src/workbench/view.rs | 108 ++++++++++++++++++++++++++++++--- 3 files changed, 115 insertions(+), 9 deletions(-) diff --git a/linux/src/workbench/maven.rs b/linux/src/workbench/maven.rs index 84673fc3f..645e95506 100644 --- a/linux/src/workbench/maven.rs +++ b/linux/src/workbench/maven.rs @@ -461,6 +461,14 @@ impl MavenView { /// Profiles(对齐 Tauri 初次展开);失败只落 `project_status`, /// 导航照常展示本地 pom 兜底。 fn reload(&mut self, cx: &mut Context) { + // 无工作区(欢迎页)时不发起扫描:空根只会得到失败态, + // 且入口本身因 `has_projects` 为假而不展示。 + if self.root.trim().is_empty() { + self.poms.clear(); + self.project = None; + self.project_status = MavenStatus::Idle; + return; + } self.rescan(); self.project_status = MavenStatus::Loading; self.scan_seq += 1; diff --git a/linux/src/workbench/sidebar.rs b/linux/src/workbench/sidebar.rs index 8b7882287..16b1816f4 100644 --- a/linux/src/workbench/sidebar.rs +++ b/linux/src/workbench/sidebar.rs @@ -178,8 +178,12 @@ impl SidebarView { client: CoreClient::new(), }; - view.refresh(cx); - view.refresh_git(cx); + // 无工作区时不做初始扫描:空路径查询只会得到一个错误态, + // 而欢迎页此时已遮住侧边栏。首次打开项目时会重新 refresh。 + if !view.root_path.trim().is_empty() { + view.refresh(cx); + view.refresh_git(cx); + } view } diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index 4b5fa14e3..62081bef9 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -180,13 +180,21 @@ pub struct WorkbenchView { impl WorkbenchView { pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let workspace_root = std::env::current_dir() - .unwrap_or_default() - .to_string_lossy() - .to_string(); + // 启动工作区优先级:命令行参数 → 上次打开的项目(最近列表首位)→ 空(显示欢迎页)。 + // + // 不使用 `std::env::current_dir()`:GUI 启动目录由用户敲命令的位置决定, + // 把它当作工作区会让「上次打开的项目」在下次启动时变成无关目录。 + let workspace_root = Self::initial_workspace_root(cx); Self::with_root(workspace_root, window, cx) } + /// 解析启动工作区:`lithe-linux [path]` 参数优先;否则取最近项目里第一个 + /// 仍存在的目录;都没有则返回空串,由 `with_root` 切到欢迎页。 + fn initial_workspace_root(cx: &gpui_kit::App) -> String { + let cli = std::env::args().nth(1); + resolve_initial_workspace(cli.as_deref(), &settings::get(cx).recent_projects) + } + pub fn with_root(workspace_root: String, window: &mut Window, cx: &mut Context) -> Self { let root = workspace_root.clone(); @@ -850,7 +858,8 @@ impl WorkbenchView { drag_start_x: 0.0, drag_start_y: 0.0, initial_resize_size: 0.0, - show_welcome: false, + // 无工作区(命令行与最近项目都为空)时直接进入欢迎页。 + show_welcome: root.is_empty(), show_search_everywhere: false, show_quick_open: false, show_go_to_line: false, @@ -937,8 +946,10 @@ impl WorkbenchView { ); view.append_log(&msg, cx); } - // 启动目录同样记入最近项目(对齐 Tauri 打开即记录)。 - settings::record_recent_project(cx, &view.workspace_root.clone()); + // 只有真正打开了工作区才记入最近项目;欢迎页不应把一个空目录写进去。 + if !view.workspace_root.is_empty() { + settings::record_recent_project(cx, &view.workspace_root.clone()); + } // 同步工具栏当前项目行(仿分支同步写法;构造时已传入,此处再确认一次)。 let root_for_toolbar = view.workspace_root.clone(); let _ = view.toolbar.update(cx, |tb, cx| { @@ -2467,6 +2478,25 @@ fn sidebar_tab_for(view_id: &str) -> Option { } } +/// 启动工作区优先级:命令行参数(存在且为目录) → 最近项目里第一个仍存在的 +/// 目录 → 空串(表示显示欢迎页)。 +/// +/// 不使用 `current_dir()`:GUI 启动目录由用户敲命令的位置决定,把它当工作区 +/// 会让下次启动打开一个无关目录。 +fn resolve_initial_workspace(cli_arg: Option<&str>, recent_projects: &[String]) -> String { + if let Some(arg) = cli_arg { + let trimmed = arg.trim(); + if !trimmed.is_empty() && std::path::Path::new(trimmed).is_dir() { + return trimmed.to_string(); + } + } + recent_projects + .iter() + .find(|path| std::path::Path::new(path).is_dir()) + .cloned() + .unwrap_or_default() +} + /// 活动栏底部项 id → 底部面板标签(对齐 Tauri `BottomPaneTab`: /// `run` 与 `maven` 是两个独立页)。 fn bottom_tab_for(pane_id: &str) -> Option { @@ -2950,3 +2980,67 @@ impl WorkbenchView { ) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// 命令行参数指向真实目录时优先使用它,忽略最近项目。 + #[test] + fn cli_directory_wins_over_recent_projects() { + let dir = std::env::temp_dir(); + let recent = vec!["/definitely/not/here".to_string()]; + let resolved = resolve_initial_workspace(Some(&dir.to_string_lossy()), &recent); + assert_eq!(resolved, dir.to_string_lossy()); + } + + /// 命令行参数不是目录(或不存在)时回退到最近项目。 + #[test] + fn invalid_cli_argument_falls_back_to_recent_projects() { + let dir = std::env::temp_dir(); + let recent = vec![dir.to_string_lossy().to_string()]; + let resolved = resolve_initial_workspace(Some("/definitely/not/here"), &recent); + assert_eq!(resolved, dir.to_string_lossy()); + } + + /// 空/空白命令行参数视为未提供,走最近项目。 + #[test] + fn blank_cli_argument_is_ignored() { + let dir = std::env::temp_dir(); + let recent = vec![dir.to_string_lossy().to_string()]; + assert_eq!( + resolve_initial_workspace(Some(" "), &recent), + dir.to_string_lossy() + ); + assert_eq!( + resolve_initial_workspace(Some(""), &recent), + dir.to_string_lossy() + ); + } + + /// 最近项目里失效的路径被跳过,取第一个仍存在的目录。 + /// 这条守住「打开上次项目」不会因失效记录而打开错目录。 + #[test] + fn skips_missing_recent_projects() { + let dir = std::env::temp_dir(); + let recent = vec![ + "/definitely/not/here".to_string(), + dir.to_string_lossy().to_string(), + "/also/missing".to_string(), + ]; + assert_eq!( + resolve_initial_workspace(None, &recent), + dir.to_string_lossy() + ); + } + + /// 既无命令行参数也无有效最近项目时返回空串,由调用方显示欢迎页。 + /// 这是本次修复的核心:不退回 `current_dir()`。 + #[test] + fn no_valid_source_yields_welcome_state() { + let recent = vec!["/definitely/not/here".to_string()]; + assert!(resolve_initial_workspace(None, &recent).is_empty()); + assert!(resolve_initial_workspace(None, &[]).is_empty()); + assert!(resolve_initial_workspace(Some("/missing"), &recent).is_empty()); + } +} From c949f34b5033c53f9204f5bfdd8e50223deb395a Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 18:15:27 +0800 Subject: [PATCH 061/109] =?UTF-8?q?feat(linux):=20=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E5=AE=8C=E5=85=A8=E5=AF=B9=E9=BD=90=20Window?= =?UTF-8?q?s=20=E7=9A=84=E5=8F=82=E6=95=B0=E5=A5=91=E7=BA=A6=E4=B8=8E?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象:同一个关键词,Linux 搜出来的结果明显比 Windows 少很多。 原因:Linux 调用 core `workspace.search` 时把 `maxResults` 写死为 100, 且只发送 4 个参数(root/query/caseSensitive/maxResults);Windows 默认 每页 140,并发送完整的选项与过滤字段。core 侧的匹配逻辑两端相同, 差异全部来自请求参数与结果上限。 本次按 Windows `features/global-search/` 1:1 对齐可见行为与数据契约: 新增 `linux/src/workbench/global_search.rs`(纯逻辑,20 项单测): - 常量与 Windows `constants/limits.ts` 逐条一致:每页 140、上下文 2 行、 展开 7 行、防抖 200ms、首次渲染 40、每次追加 40。 - 匹配语义:字面量 / 大小写敏感 / 全字 / 正则,非法正则返回 None。 - 结果分组与分页:只保留 kind == "content",按文件分组,超出上限截断 并标记 has_more,加载更多按文件去重合并。 - 路径 include/exclude 用 glob(* 不跨目录、** 跨目录、? 单字符, 逗号或换行分隔),exclude 优先,对齐 `utils/path-filters.ts`。 - 摘录构建:上下文区间合并、远处匹配插入 `...`、行号映射与高亮区间, 对齐 `utils/search-excerpts.ts`。 新增 `linux/src/workbench/global_search_panel.rs`(状态与渲染): - 查询输入 + 三个开关(区分大小写 / 全字匹配 / 正则)。 - include / exclude 文件过滤输入。 - 替换输入 + 替换全部。 - 首页搜索、加载更多(file_offset 翻页)、展开/收起上下文。 - 空态 / 无工作区 / 搜索中 / 失败重试 / 无结果,文案取自 `search.*`。 - 上下键选择、回车打开、Esc 关闭。 其他改动: - `core/client.rs`:`search` 透传 caseSensitive/wholeWords/ regularExpression/maxResults/fileMask;新增 `replace_preview`。 - `core/mod.rs`:`client` 模块改为 pub,供面板复用 CoreClient。 - `i18n.rs`:补齐 `search.*` 共 28 个键,文案抄自 Tauri `locale.ts`。 - `view.rs`:`edit.find` / `edit.find_replace` 打开全局搜索面板, 面板事件接 OpenFile/Close,Esc 关闭,结果跳转经 pending_goto_line。 - `sidebar.rs`:侧边栏搜索改用新参数与同一页大小。 - `Cargo.toml`:新增 `regex = "1.11"`(与 lithe-core 同版本)。 - 新增 Agent Note 记录该对齐决策与边界。 验证:build-linux.sh 通过;cargo test --lib 76 项通过; verify-test-stability 通过;verify-agent-notes 通过(43 篇); 带参数启动无 panic。 --- .../2026-09-26-linux-global-search-parity.md | 119 ++ linux/Cargo.lock | 1 + linux/Cargo.toml | 1 + linux/src/core/client.rs | 47 +- linux/src/core/mod.rs | 2 +- linux/src/i18n.rs | 45 +- linux/src/workbench/global_search.rs | 994 ++++++++++++ linux/src/workbench/global_search_panel.rs | 1386 +++++++++++++++++ linux/src/workbench/mod.rs | 2 + linux/src/workbench/sidebar.rs | 11 +- linux/src/workbench/view.rs | 63 +- 11 files changed, 2662 insertions(+), 9 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-26-linux-global-search-parity.md create mode 100644 linux/src/workbench/global_search.rs create mode 100644 linux/src/workbench/global_search_panel.rs diff --git a/.agents/notes/implemented/architecture/2026-09-26-linux-global-search-parity.md b/.agents/notes/implemented/architecture/2026-09-26-linux-global-search-parity.md new file mode 100644 index 000000000..92152f55c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-26-linux-global-search-parity.md @@ -0,0 +1,119 @@ +# Agent 笔记:Linux 全局搜索对齐 Tauri 的参数与分页契约 + +状态:已实现 + +## 先说结论 + +Linux 侧的“在文件中搜索”曾把 `maxResults` 写死为 100,并且只发送 4 个简单 +参数,导致同一个关键词在 Linux 上明显比 Windows 少很多结果。现在 Linux 与 +Windows 使用**同一套参数契约和同一套结果语义**:核心命令 `workspace.search` +由两端各自按相同字段发送,搜索选项(大小写 / 全字 / 正则)、文件数分页上限 +(每页 140)、路径 include/exclude 过滤、上下文行(2 行)、结果摘录与替换 +全部按 Windows 的行为实现。开发者要记住:**搜索行为只能以 +`windows/tauri/src/features/global-search/` 为准,不要在 Linux 里另立一套 +默认值或匹配规则。** + +## 问题 + +Linux 工作台的侧边栏搜索早期只有“一个输入框 + 一次请求”: + +- 调用 core `workspace.search` 时只传 `root`、`query`、`caseSensitive=false`、 + `maxResults=100`。而 Windows 传的是完整字段,默认 `maxResults=140`, + 且 core 自身默认值是 200,上限 10000。 +- 没有大小写 / 全字 / 正则开关,没有 include/exclude 路径过滤。 +- 没有分页(`file_offset`),没有上下文行,没有结果摘录与替换。 + +结果就是用户观察到的问题:同一关键词在 Linux 上搜出来的东西“特别少”。 +根因不是匹配逻辑不同(两端都由 core 的同一个 `search_with_index` 执行), +而是**请求参数和结果上限被写死得比 Windows 小**。 + +## 决策 + +### 契约只在两端共享,语义以 Windows 为准 + +- 两端都调用 core 的 `workspace.search`,字段名与含义一致: + `caseSensitive`、`wholeWords`、`regularExpression`、`maxResults`、 + `fileMask`、`paths`。 +- Linux 的默认值与上限改为与 Windows 相同:每页 `CONTENT_SEARCH_PAGE_SIZE` + = 140,上下文 `CONTEXT_LINES` = 2,展开上下文 `EXPANDED_CONTEXT_LINES` = 7, + 防抖 `SEARCH_DEBOUNCE_DELAY_MS` = 200,首次渲染 40 条、每次追加 40 条。 + 这些常量集中在 `linux/src/workbench/global_search.rs`,逐条对应 + `windows/tauri/src/features/global-search/constants/limits.ts`。 +- 分组与分页语义按 `windows/tauri/src/features/file-search/lib/file-search-api.ts` + 的 `searchFilesContent` 实现:只保留 `kind == "content"`,按文件分组, + 文件数超过 `maxResults` 时截断并置 `has_more`。 + +### 纯逻辑与渲染分离 + +- `linux/src/workbench/global_search.rs` 只放纯逻辑:常量、匹配选项、 + 正则构建、单文件切分、结果分组、分页合并、路径 glob 过滤、摘录构建。 + 它不依赖 gpui,也不做 IO,因此可以用确定性单测锁定行为。 +- `linux/src/workbench/global_search_panel.rs` 只负责状态与渲染:防抖、 + 首页/加载更多、三个开关、include/exclude、摘录行渲染、替换。 + +### 路径过滤用 glob,不用 core 的 fileMask + +Windows 的 include/exclude 是在前端把 glob 编译成正则后过滤**相对路径**, +而不是把通配符交给 core 的 `fileMask`。为保持行为一致,Linux 复刻同一套 +`globToRegExp` 语义(`*` 不跨目录、`**` 跨目录、`?` 单字符、逗号或换行分隔), +在 `matches_path_filters` 中实现。 + +### 替换走 core 的预览命令 + +Windows 的替换在客户端读写整文件。Linux 复用 core 已有的 +`workspace.replacePreview` 得到每个文件的新内容,再用 `file.write` 写回, +避免在 Linux 里手写第二套替换正则逻辑。 + +## 考虑过的备选方案 + +### 备选方案一:只把 `maxResults` 从 100 提到 500 + +这是最小改动,能立刻缓解“结果少”。但它只改了数量上限,仍然没有开关、 +过滤、分页、上下文和替换,Linux 与 Windows 仍是两套能力。用户明确要求 +“完全对齐、保持功能一致性”,因此不采用。 + +### 备选方案二:把 Windows 的 React/TS 代码整体移植到 Linux + +不可行。Windows 的 `features/global-search/` 是约 3900 行 React/TS,依赖 +zustand store、React hooks 和 Monaco 摘录渲染;Linux 是 GPUI/Rust,没有 +DOM、没有 React、没有 Monaco。可行做法是复用**核心命令契约与可见行为**, +而不是复用前端框架代码。 + +### 备选方案三:在 Linux 里另写一套更简单的搜索 UI + +改动看起来更小,但会制造第二套默认值和匹配规则,正是本次问题的成因。 +一旦两端上限、过滤或上下文行数不同,“同一关键词结果不同”会再次出现。 + +## 后果 + +- 收益:同一个关键词在 Linux 与 Windows 上返回相同的匹配集合,差异只剩 + 渲染方式;用户对“结果变少”的困惑被消除。 +- 收益:搜索选项、过滤、分页、上下文、替换都集中在纯逻辑模块里,行为可由 + 单测锁定,后续调整只改常量与纯函数。 +- 代价:Linux 新增加载更多、上下文展开、三个开关和替换输入,界面比原来复杂。 +- 代价:Linux 的摘录需要按需读取源文件才能展开上下文;当前只预取前 20 个 + 文件并限制缓存 64 条,未读取到的文件只显示 core 附带的上下文字符串。 + +## 验证 + +- `cargo test --manifest-path linux/Cargo.toml -p lithe-linux --lib` 覆盖 + `workbench::global_search` 的 20 项纯逻辑测试:字面量/大小写/全字/正则的 + 匹配语义、非法正则返回 `None`、单文件多区间与上下文、二进制跳过、 + 分页合并去重、`kind` 过滤与文件数上限、glob(`*` / `**` / `?`)、 + exclude 优先、摘录上下文合并与 `...`、匹配条数上限、展开上下文、 + 相对路径与路径拼接。 +- `cargo build --manifest-path linux/Cargo.toml -p lithe-linux` 通过。 +- `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh` 通过。 +- 从仓库根与带 CLI 参数两种方式启动 `./target/debug/lithe-linux` 均无 panic。 + +## 适用范围 + +- `linux/src/workbench/global_search.rs`:搜索纯逻辑与常量。 +- `linux/src/workbench/global_search_panel.rs`:搜索面板状态与渲染。 +- `linux/src/core/client.rs`:`search` 的完整参数与 `replace_preview`。 +- `linux/src/i18n.rs`:`search.*` 文案键。 +- `linux/src/workbench/view.rs`:`edit.find` / `edit.find_replace` 打开面板、 + 面板事件的接线。 +- 不适用于 Windows 产品;Windows 侧真源是 + `windows/tauri/src/features/global-search/` 与 + `windows/tauri/src/features/file-search/lib/file-search-api.ts`。 diff --git a/linux/Cargo.lock b/linux/Cargo.lock index 375ab4c10..79c3f8662 100644 --- a/linux/Cargo.lock +++ b/linux/Cargo.lock @@ -3963,6 +3963,7 @@ dependencies = [ "libc", "lithe-core", "portable-pty", + "regex", "rfd", "serde", "serde_json", diff --git a/linux/Cargo.toml b/linux/Cargo.toml index d5365b396..e6bb28261 100644 --- a/linux/Cargo.toml +++ b/linux/Cargo.toml @@ -27,3 +27,4 @@ tokio = { version = "1", features = ["sync", "macros", "rt"] } anyhow = "1" uuid = { version = "1", features = ["v4"] } libc = "0.2" +regex = "1.11" diff --git a/linux/src/core/client.rs b/linux/src/core/client.rs index 1577980b2..ae9fb5912 100644 --- a/linux/src/core/client.rs +++ b/linux/src/core/client.rs @@ -201,13 +201,21 @@ impl CoreClient { }) } - /// 在工作区中搜索文本或文件 + /// 在工作区中搜索文本或文件(对齐 Tauri `workspace.search` 的完整参数)。 + /// + /// `max_results` 默认与 Tauri `CONTENT_SEARCH_PAGE_SIZE`(140)一致; + /// `file_mask` 为逗号分隔的文件掩码,空串表示不过滤。 #[allow(dead_code)] pub fn search( &self, cx: &gpui_kit::AsyncApp, root: &str, query: &str, + case_sensitive: bool, + whole_words: bool, + regular_expression: bool, + max_results: usize, + file_mask: &str, ) -> Task, String>> { let task: Task> = self.execute( cx, @@ -215,8 +223,11 @@ impl CoreClient { serde_json::json!({ "root": root, "query": query, - "caseSensitive": false, - "maxResults": 100, + "caseSensitive": case_sensitive, + "wholeWords": whole_words, + "regularExpression": regular_expression, + "maxResults": max_results, + "fileMask": file_mask, }), ); @@ -231,6 +242,36 @@ impl CoreClient { }) } + /// 工作区替换预览(对齐 Tauri `workspace.replacePreview`)。 + /// + /// 返回 `files` 数组:每项含 `path`、`replacementText`,调用方据此写回。 + #[allow(dead_code)] + pub fn replace_preview( + &self, + cx: &gpui_kit::AsyncApp, + root: &str, + query: &str, + replacement: &str, + case_sensitive: bool, + whole_words: bool, + regular_expression: bool, + paths: &[String], + ) -> Task> { + self.execute( + cx, + "workspace.replacePreview", + serde_json::json!({ + "root": root, + "query": query, + "replacement": replacement, + "caseSensitive": case_sensitive, + "wholeWords": whole_words, + "regularExpression": regular_expression, + "paths": paths, + }), + ) + } + /// 获取 Git 状态 pub fn git_status( &self, diff --git a/linux/src/core/mod.rs b/linux/src/core/mod.rs index f2702b963..5367ffaa4 100644 --- a/linux/src/core/mod.rs +++ b/linux/src/core/mod.rs @@ -1,3 +1,3 @@ -mod client; +pub mod client; pub use client::CoreClient; diff --git a/linux/src/i18n.rs b/linux/src/i18n.rs index 38b478628..9b2fb888c 100644 --- a/linux/src/i18n.rs +++ b/linux/src/i18n.rs @@ -189,8 +189,52 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { "settings.files.hiddenFiles" => ("隐藏文件", "Hidden Files"), "settings.files.autoReveal" => ("自动显示活动文件", "Auto Reveal Active File"), "settings.files.confirmBeforeDelete" => ("删除前确认", "Confirm Before Delete"), + // ---- 全局搜索(键与中英文逐条抄自 `windows/tauri/src/i18n/locale.ts`)---- + "search.emptyTitle" => ("在项目中搜索", "Search across your project"), + "search.emptyDescription" => ( + "输入关键词,即可在整个项目中查找匹配的文件和代码行。", + "Type to find matching files and code lines across your project.", + ), + "search.openProjectTitle" => ("打开项目后再搜索", "Open a project to search"), + "search.openProjectDescription" => ( + "全局搜索需要先打开一个项目文件夹。", + "Global search needs an open project folder.", + ), + "search.unsupported" => ( + "当前工作区类型不支持全局搜索。", + "Global search is not available for this workspace type.", + ), + "search.failed" => ("搜索失败", "Search failed"), + "search.retry" => ("重试", "Try again"), + "search.noResults" => ("未找到结果", "No results found"), + "search.noResultsFor" => ("未找到 “{query}” 的结果", "No results found for \"{query}\""), + "search.noResultsForWithFilters" => ( + "未找到 “{query}” 的结果(已应用当前文件筛选)", + "No results found for \"{query}\" with the current file filters", + ), + "search.matchCase" => ("区分大小写", "Match case"), + "search.matchWholeWord" => ("全字匹配", "Match whole word"), + "search.useRegex" => ("使用正则表达式", "Use regular expression"), "search.search" => ("搜索", "Search"), + "search.hideDetails" => ("隐藏详细信息", "Hide details"), + "search.showDetails" => ("显示详细信息", "Show details"), "search.clear" => ("清除搜索", "Clear search"), + "search.close" => ("关闭搜索", "Close search"), + "search.previousMatch" => ("上一个匹配项", "Previous match"), + "search.nextMatch" => ("下一个匹配项", "Next match"), + "search.hideReplace" => ("隐藏替换", "Hide replace"), + "search.showReplace" => ("显示替换", "Show replace"), + "search.replaceWith" => ("替换为...", "Replace with..."), + "search.replace" => ("替换", "Replace"), + "search.replaceAllShort" => ("全部", "All"), + "search.replacedMatches" => ("已替换 {count} 处匹配", "Replaced {count} matches"), + "search.replaceMatchesFailed" => ( + "替换搜索匹配失败", + "Failed to replace search matches", + ), + "search.options" => ("搜索选项", "Search options"), + "search.filesToInclude" => ("要包含的文件", "Files to include"), + "search.filesToExclude" => ("要排除的文件", "Files to exclude"), "ui.loading" => ("正在加载", "Loading"), "workbench.searchInFiles" => ("在文件中搜索...", "Search in files..."), "git.commitMessagePlaceholder" => ("提交说明...", "Commit message..."), @@ -293,7 +337,6 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { "quickOpen.filesCount" => ("{count} 个文件", "{count} files"), "quickOpen.fileCountOne" => ("1 个文件", "1 file"), "commandPalette.placeholder" => ("输入命令...", "Type a command..."), - "search.noResults" => ("未找到结果", "No results found"), // ---- 标签页右键菜单 / 文件上下文菜单(与 Tauri locale.ts tabs.* / files.* 对齐) ---- "tabs.close" => ("关闭", "Close"), "tabs.closeOthers" => ("关闭其他", "Close Others"), diff --git a/linux/src/workbench/global_search.rs b/linux/src/workbench/global_search.rs new file mode 100644 index 000000000..15e68305c --- /dev/null +++ b/linux/src/workbench/global_search.rs @@ -0,0 +1,994 @@ +//! 全局内容搜索的纯逻辑层:常量、匹配选项、结果分组、路径过滤、摘录构建。 +//! +//! 真源对应关系(1:1 复刻 Windows,不臆造语义): +//! - 常量抄自 `windows/tauri/src/features/global-search/constants/limits.ts` +//! 与 `hooks/use-content-search.ts`(`CONTEXT_LINES`)。 +//! - 结果分组与分页语义抄自 +//! `windows/tauri/src/features/file-search/lib/file-search-api.ts` 的 +//! `searchFilesContent`。 +//! - 路径 include/exclude 过滤抄自 +//! `windows/tauri/src/features/global-search/utils/path-filters.ts`。 +//! - 摘录(上下文合并、`...` 省略、行号映射、高亮区间)抄自 +//! `windows/tauri/src/features/global-search/utils/search-excerpts.ts`。 +//! +//! 这里不触碰 gpui 与 IO,便于用确定性单测锁定行为。 + +use std::collections::HashMap; + +use regex::Regex; + +/// 每页内容搜索结果数(`CONTENT_SEARCH_PAGE_SIZE`)。 +pub const CONTENT_SEARCH_PAGE_SIZE: usize = 140; +/// 每个文件摘录默认携带的上下文行数(`CONTEXT_LINES`)。 +pub const CONTEXT_LINES: usize = 2; +/// 展开上下文时携带的行数(`EXPANDED_CONTEXT_LINES`)。 +pub const EXPANDED_CONTEXT_LINES: usize = 7; +/// 搜索输入防抖延迟(`SEARCH_DEBOUNCE_DELAY`,毫秒)。 +pub const SEARCH_DEBOUNCE_DELAY_MS: u64 = 200; +/// 首次渲染的结果条数(`CONTENT_SEARCH_INITIAL_RENDER_LIMIT`)。 +pub const CONTENT_SEARCH_INITIAL_RENDER_LIMIT: usize = 40; +/// 每次增量渲染追加的结果条数(`CONTENT_SEARCH_RENDER_INCREMENT`)。 +pub const CONTENT_SEARCH_RENDER_INCREMENT: usize = 40; + +/// 三个搜索开关,对应 Windows `ContentSearchOptions`。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ContentSearchOptions { + pub case_sensitive: bool, + pub whole_word: bool, + pub use_regex: bool, +} + +impl ContentSearchOptions { + /// 搜索会话标识:把选项编译进 key,用于丢弃过期响应。 + pub fn cache_key(&self) -> String { + format!( + "{}{}{}", + u8::from(self.case_sensitive), + u8::from(self.whole_word), + u8::from(self.use_regex) + ) + } +} + +/// core `workspace.search` 返回的单条匹配。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoreSearchMatch { + pub kind: String, + pub path: String, + pub line: Option, + pub preview: String, +} + +/// 一行内的匹配区间。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MatchRange { + pub start: usize, + pub end: usize, +} + +/// 文件中一行的匹配,对应 Windows `SearchMatch`。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SearchMatch { + pub line_number: usize, + pub line_content: String, + pub column_start: usize, + pub column_end: usize, + pub match_ranges: Vec, + pub context_before: Vec, + pub context_after: Vec, +} + +/// 一个文件的全部匹配,对应 Windows `FileSearchResult`。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileSearchResult { + pub file_path: String, + pub matches: Vec, + pub total_matches: usize, +} + +/// `searchFilesContent` 的输出,对应 Windows `SearchFilesResponse`。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SearchFilesResponse { + pub results: Vec, + pub total_files: usize, + pub searched_files: usize, + pub searchable_files: usize, + pub files_with_matches: usize, + pub next_file_offset: usize, + pub has_more: bool, +} + +/// 在 `line` 上找出 `regex` 的全部匹配区间(抄自 `findLineMatchRanges`)。 +#[allow(dead_code)] +fn find_line_match_ranges(line: &str, regex: &Regex) -> Vec { + regex + .find_iter(line) + .map(|m| MatchRange { + start: m.start(), + end: m.end(), + }) + .collect() +} + +/// 依据匹配选项构建内容搜索正则;`use_regex=false` 时对查询做字面量转义。 +/// 对应 Windows `buildSearchRegex`。 +#[allow(dead_code)] +pub fn build_search_regex(query: &str, options: ContentSearchOptions) -> Option { + if query.is_empty() { + return None; + } + + let pattern = if options.use_regex { + query.to_string() + } else { + let escaped = regex::escape(query); + if options.whole_word { + format!(r"\b{}\b", escaped) + } else { + escaped + } + }; + + // 全字匹配在正则模式下用零宽边界包裹,与 Windows 的头尾 `\b` 语义一致。 + let pattern = if options.use_regex && options.whole_word { + format!(r"\b(?:{})\b", pattern) + } else { + pattern + }; + + regex::RegexBuilder::new(&pattern) + .case_insensitive(!options.case_sensitive) + .build() + .ok() +} + +/// 把一个文件的内容按查询切分成匹配行(抄自 `buildFileSearchResult`)。 +/// +/// 含 NUL 的二进制内容返回 `None`;无匹配返回 `None`。 +#[allow(dead_code)] +pub fn build_file_search_result( + file_path: &str, + content: &str, + regex: &Regex, + context_lines: usize, +) -> Option { + if content.contains('\0') { + return None; + } + + let lines: Vec<&str> = content.split('\n').collect(); + let mut matches = Vec::new(); + + for (index, line) in lines.iter().enumerate() { + let ranges = find_line_match_ranges(line, regex); + if ranges.is_empty() { + continue; + } + + let line_number = index + 1; + let first = ranges + .first() + .copied() + .unwrap_or(MatchRange { start: 0, end: 0 }); + matches.push(SearchMatch { + line_number, + line_content: (*line).to_string(), + column_start: first.start, + column_end: first.end, + match_ranges: ranges, + context_before: lines[index.saturating_sub(context_lines)..index] + .iter() + .map(|l| (*l).to_string()) + .collect(), + context_after: lines[index + 1..(index + 1 + context_lines).min(lines.len())] + .iter() + .map(|l| (*l).to_string()) + .collect(), + }); + } + + if matches.is_empty() { + return None; + } + + Some(FileSearchResult { + file_path: file_path.to_string(), + total_matches: matches.len(), + matches, + }) +} + +/// 合并两批按文件分组的结果(抄自 `mergeSearchResults`)。 +pub fn merge_search_results( + previous: &[FileSearchResult], + next: Vec, +) -> Vec { + if previous.is_empty() { + return next; + } + if next.is_empty() { + return previous.to_vec(); + } + + let mut merged: Vec = previous.to_vec(); + let mut index_by_path: HashMap = merged + .iter() + .enumerate() + .map(|(index, result)| (result.file_path.clone(), index)) + .collect(); + + for result in next { + match index_by_path.get(&result.file_path).copied() { + Some(index) => { + let existing = &mut merged[index]; + existing.matches.extend(result.matches); + existing.total_matches += result.total_matches; + } + None => { + index_by_path.insert(result.file_path.clone(), merged.len()); + merged.push(result); + } + } + } + + merged +} + +/// 把 core 返回的扁平匹配列表转成分页、按文件分组的结果。 +/// +/// 语义对齐 `searchFilesContent`:只保留 `kind == "content"`, +/// `max_results` 截断文件数,`has_more` 表示还有文件未返回。 +pub fn group_content_matches( + root: &str, + matches: Vec, + max_results: usize, +) -> SearchFilesResponse { + let mut grouped: Vec = Vec::new(); + let mut index_by_path: HashMap = HashMap::new(); + + for item in matches { + if item.kind != "content" { + continue; + } + + let file_path = join_path(root, &item.path); + let line_number = item.line.unwrap_or(1); + let line_content = item.preview.clone(); + let column_start = 0; + let column_end = line_content.len(); + + let entry = match index_by_path.get(&file_path).copied() { + Some(index) => &mut grouped[index], + None => { + index_by_path.insert(file_path.clone(), grouped.len()); + grouped.push(FileSearchResult { + file_path: file_path.clone(), + matches: Vec::new(), + total_matches: 0, + }); + grouped.last_mut().expect("just pushed") + } + }; + + entry.matches.push(SearchMatch { + line_number, + line_content, + column_start, + column_end, + match_ranges: Vec::new(), + context_before: Vec::new(), + context_after: Vec::new(), + }); + entry.total_matches += 1; + } + + let total_files = grouped.len(); + let has_more = total_files > max_results; + grouped.truncate(max_results); + + SearchFilesResponse { + results: grouped, + total_files, + searched_files: total_files, + searchable_files: total_files, + files_with_matches: total_files, + next_file_offset: total_files.min(max_results), + has_more, + } +} + +/// 用平台无关的方式拼接根目录与相对路径。 +pub fn join_path(root: &str, relative: &str) -> String { + if root.is_empty() { + return relative.to_string(); + } + if relative.is_empty() { + return root.to_string(); + } + let separator = if root.contains('\\') && !root.contains('/') { + '\\' + } else { + '/' + }; + if root.ends_with(separator) { + format!("{}{}", root, relative) + } else { + format!("{}{}{}", root, separator, relative) + } +} + +/// 取相对路径(用于展示与过滤),抄自 `getRelativePath` 的宽松语义。 +pub fn relative_path(path: &str, root: Option<&str>) -> String { + let Some(root) = root else { + return path.to_string(); + }; + if root.is_empty() { + return path.to_string(); + } + let normalized_root = root.trim_end_matches(['/', '\\']); + let prefix = format!("{}/", normalized_root); + if let Some(rest) = path.strip_prefix(&prefix) { + return rest.to_string(); + } + let prefix_backslash = format!("{}\\", normalized_root); + if let Some(rest) = path.strip_prefix(&prefix_backslash) { + return rest.to_string(); + } + path.to_string() +} + +/// 输入行距:全局搜索面板内部使用的文件过滤器,支持 glob 与逗号/换行分隔。 +/// +/// 抄自 `path-filters.ts`。 +#[derive(Debug, Clone)] +pub struct PathFilter { + matcher: Option, + fallback: String, +} + +impl PathFilter { + fn compile(glob: &str) -> Option { + let trimmed = glob.trim(); + if trimmed.is_empty() { + return None; + } + + let mut source = String::new(); + let chars: Vec = trimmed.chars().collect(); + let mut index = 0; + while index < chars.len() { + let current = chars[index]; + let next = chars.get(index + 1).copied(); + match (current, next) { + ('*', Some('*')) => { + source.push_str(".*"); + index += 2; + continue; + } + ('*', _) => source.push_str("[^/]*"), + ('?', _) => source.push_str("[^/]"), + _ => { + if "|\\{}()[]^$+?.".contains(current) { + source.push('\\'); + } + source.push(current); + } + } + index += 1; + } + + Some(Self { + matcher: Regex::new(&source).ok(), + fallback: trimmed.to_lowercase(), + }) + } + + fn matches(&self, path: &str) -> bool { + match &self.matcher { + Some(matcher) => matcher.is_match(path), + None => path.to_lowercase().contains(&self.fallback), + } + } +} + +/// 把 include/exclude 查询编译成过滤器列表(按逗号或换行分隔)。 +pub fn compile_path_filters(query: &str) -> Vec { + query + .split([',', '\n']) + .filter_map(PathFilter::compile) + .collect() +} + +/// 是否命中 include/exclude 过滤(抄自 `createPathFilterPredicate`)。 +pub fn matches_path_filters( + path: &str, + root: Option<&str>, + include: &[PathFilter], + exclude: &[PathFilter], +) -> bool { + if include.is_empty() && exclude.is_empty() { + return true; + } + + let relative = relative_path(path, root); + + if !include.is_empty() && !include.iter().any(|filter| filter.matches(&relative)) { + return false; + } + + if !exclude.is_empty() && exclude.iter().any(|filter| filter.matches(&relative)) { + return false; + } + + true +} + +/// 摘录中的一段高亮区间。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExcerptHighlight { + pub item_key: String, + pub start: usize, + pub end: usize, +} + +/// 摘录中的一个可跳转匹配。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExcerptMatch { + pub item_key: String, + pub file_path: String, + pub target_line: usize, + pub target_column: usize, + pub highlight_indexes: Vec, +} + +/// 一个文件的摘录(合并上下文后的文本、行号映射、高亮)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SearchExcerpt { + pub id: String, + pub file_path: String, + pub display_path: String, + pub content: String, + pub line_number_map: Vec>, + pub matches: Vec, + pub match_count: usize, + pub highlights: Vec, +} + +fn fallback_range(item: &SearchMatch) -> MatchRange { + MatchRange { + start: item.column_start, + end: item.column_end.max(item.column_start + 1), + } +} + +fn match_ranges(item: &SearchMatch) -> Vec { + let ranges = if item.match_ranges.is_empty() { + vec![fallback_range(item)] + } else { + item.match_ranges.clone() + }; + + ranges + .into_iter() + .map(|range| MatchRange { + start: range.start, + end: range.end.max(range.start + 1), + }) + .filter(|range| range.end > range.start) + .collect() +} + +/// 为单个文件构建摘录(抄自 `buildSearchExcerpt`)。 +fn build_search_excerpt( + result: &FileSearchResult, + root: Option<&str>, + match_limit: usize, + context_lines: Option, + source_content: Option<&str>, +) -> Option { + let display_path = relative_path(&result.file_path, root); + let source_lines: Option> = source_content.map(|c| c.split('\n').collect()); + let mut line_text_by_number: HashMap = HashMap::new(); + let mut context_ranges: Vec<(usize, usize)> = Vec::new(); + let mut included: Vec<(String, SearchMatch, Vec)> = Vec::new(); + + for (index, item) in result.matches.iter().enumerate() { + if index >= match_limit { + break; + } + + // 展开上下文时优先按源文件行数取行(与 Windows `sourceLines` 分支一致); + // 否则回退到 core 附带的上下文字符串。 + let expanded_with_source = source_lines.is_some() && context_lines.is_some(); + let context_before_length = if expanded_with_source { + context_lines + .unwrap_or(0) + .min(item.line_number.saturating_sub(1)) + } else { + item.context_before.len() + }; + let context_after_length = if expanded_with_source { + let total = source_lines.as_ref().map(|l| l.len()).unwrap_or(0); + context_lines + .unwrap_or(0) + .min(total.saturating_sub(item.line_number)) + } else { + item.context_after.len() + }; + let start_line = item + .line_number + .saturating_sub(context_before_length) + .max(1); + let end_line = item.line_number + context_after_length; + let item_key = format!("{}:{}:{}", result.file_path, item.line_number, index); + + if let Some(lines) = source_lines.as_ref() { + for line_number in start_line..=end_line { + line_text_by_number.insert( + line_number, + lines + .get(line_number - 1) + .copied() + .unwrap_or("") + .to_string(), + ); + } + } else { + for (offset, line) in item.context_before.iter().enumerate() { + line_text_by_number.insert(start_line + offset, line.clone()); + } + line_text_by_number.insert(item.line_number, item.line_content.clone()); + for (offset, line) in item.context_after.iter().enumerate() { + line_text_by_number.insert(item.line_number + offset + 1, line.clone()); + } + } + + context_ranges.push((start_line, end_line)); + included.push((item_key, item.clone(), match_ranges(item))); + } + + if included.is_empty() { + return None; + } + + // 合并相邻/重叠的上下文区间,区间之间插入 `...` 分隔(抄自 `mergedRanges`)。 + context_ranges.sort_by_key(|range| range.0); + let mut merged_ranges: Vec<(usize, usize)> = Vec::new(); + for range in context_ranges { + match merged_ranges.last_mut() { + Some(previous) if range.0 <= previous.1 + 1 => { + previous.1 = previous.1.max(range.1); + } + _ => merged_ranges.push(range), + } + } + + let mut context_lines_out: Vec = Vec::new(); + let mut line_number_map: Vec> = Vec::new(); + for (range_index, (start, end)) in merged_ranges.iter().enumerate() { + if range_index > 0 { + context_lines_out.push("...".to_string()); + line_number_map.push(None); + } + for line_number in *start..=*end { + context_lines_out.push( + line_text_by_number + .get(&line_number) + .cloned() + .unwrap_or_default(), + ); + line_number_map.push(Some(line_number)); + } + } + + // 行号 → 摘录行索引,用于把匹配位置换算成高亮偏移。 + let mut line_index_by_number: HashMap = HashMap::new(); + for (index, line_number) in line_number_map.iter().enumerate() { + if let Some(line_number) = line_number { + line_index_by_number.entry(*line_number).or_insert(index); + } + } + + let mut line_offsets: Vec = Vec::new(); + let mut next_offset = 0usize; + for line in &context_lines_out { + line_offsets.push(next_offset); + next_offset += line.chars().count() + 1; + } + + let mut highlights: Vec = Vec::new(); + let mut matches: Vec = Vec::new(); + for (item_key, item, ranges) in included { + let mut highlight_indexes = Vec::new(); + if let Some(line_index) = line_index_by_number.get(&item.line_number).copied() { + let base = line_offsets.get(line_index).copied().unwrap_or(0); + for range in ranges { + highlight_indexes.push(highlights.len()); + highlights.push(ExcerptHighlight { + item_key: item_key.clone(), + start: base + range.start, + end: base + range.end, + }); + } + } + + matches.push(ExcerptMatch { + item_key, + file_path: result.file_path.clone(), + target_line: item.line_number, + target_column: item.column_start + 1, + highlight_indexes, + }); + } + + Some(SearchExcerpt { + id: result.file_path.clone(), + file_path: result.file_path.clone(), + display_path, + content: context_lines_out.join("\n"), + line_number_map, + matches, + match_count: result.total_matches, + highlights, + }) +} + +/// 按文件顺序构建摘录,直到用满 `limit` 个匹配(抄自 `buildSearchExcerpts`)。 +/// +/// `context_lines_by_file` 给出「已展开上下文」的文件 → 行数覆盖。 +pub fn build_search_excerpts( + results: &[FileSearchResult], + root: Option<&str>, + limit: usize, + context_lines_by_file: &HashMap, + source_content_by_path: &HashMap, +) -> Vec { + let mut excerpts = Vec::new(); + let mut remaining = limit; + + for result in results { + if remaining == 0 { + break; + } + + let context_lines = context_lines_by_file.get(&result.file_path).copied(); + let source = source_content_by_path + .get(&result.file_path) + .map(|s| s.as_str()); + let Some(excerpt) = build_search_excerpt(result, root, remaining, context_lines, source) + else { + continue; + }; + + remaining = remaining.saturating_sub(excerpt.matches.len()); + excerpts.push(excerpt); + } + + excerpts +} + +#[cfg(test)] +mod tests { + use super::*; + + fn default_options() -> ContentSearchOptions { + ContentSearchOptions::default() + } + + /// 普通查询做字面量匹配,大小写不敏感(默认选项)。 + #[test] + fn literal_regex_is_case_insensitive_by_default() { + let regex = build_search_regex("hello", default_options()).expect("regex"); + assert!(regex.is_match("Hello World")); + assert!(regex.is_match("say hello")); + assert!(!regex.is_match("shell")); + } + + /// 区分大小写时不再忽略大小写。 + #[test] + fn case_sensitive_option_is_honored() { + let options = ContentSearchOptions { + case_sensitive: true, + ..default_options() + }; + let regex = build_search_regex("hello", options).expect("regex"); + assert!(regex.is_match("hello")); + assert!(!regex.is_match("Hello")); + } + + /// 全字匹配不应命中更长单词内部。 + #[test] + fn whole_word_option_requires_word_boundaries() { + let options = ContentSearchOptions { + whole_word: true, + ..default_options() + }; + let regex = build_search_regex("cat", options).expect("regex"); + assert!(regex.is_match("a cat here")); + assert!(!regex.is_match("concatenate")); + } + + /// 正则模式使用查询原样,并尊重全字边界。 + #[test] + fn regex_option_uses_pattern_verbatim() { + let options = ContentSearchOptions { + use_regex: true, + ..default_options() + }; + let regex = build_search_regex(r"a\d+", options).expect("regex"); + assert!(regex.is_match("a123")); + assert!(!regex.is_match("ab")); + + let whole = ContentSearchOptions { + use_regex: true, + whole_word: true, + ..default_options() + }; + let regex = build_search_regex("cat", whole).expect("regex"); + assert!(regex.is_match("a cat")); + assert!(!regex.is_match("concatenate")); + } + + /// 非法正则返回 `None` 而不是 panic(面板应回退为字面量或报错)。 + #[test] + fn invalid_regex_returns_none() { + let options = ContentSearchOptions { + use_regex: true, + ..default_options() + }; + assert!(build_search_regex("([unclosed", options).is_none()); + } + + /// 空查询不构建正则。 + #[test] + fn empty_query_yields_no_regex() { + assert!(build_search_regex("", default_options()).is_none()); + } + + /// 单文件摘录:行号、多区间高亮与上下文都要正确。 + #[test] + fn build_file_result_tracks_ranges_and_context() { + let regex = build_search_regex("needle", default_options()).expect("regex"); + let content = "one\ntwo needle needle\nthree\nneedle\nfive"; + let result = build_file_search_result("/tmp/a.txt", content, ®ex, 1) + .expect("result should exist"); + + assert_eq!(result.total_matches, 2); + assert_eq!(result.matches[0].line_number, 2); + assert_eq!(result.matches[0].match_ranges.len(), 2); + assert_eq!(result.matches[0].context_before, vec!["one"]); + assert_eq!(result.matches[0].context_after, vec!["three"]); + + assert_eq!(result.matches[1].line_number, 4); + assert_eq!(result.matches[1].context_before, vec!["three"]); + assert_eq!(result.matches[1].context_after, vec!["five"]); + } + + /// 含 NUL 的内容视为二进制,直接跳过。 + #[test] + fn binary_content_is_skipped() { + let regex = build_search_regex("needle", default_options()).expect("regex"); + assert!(build_file_search_result("/tmp/bin", "need\0le", ®ex, 2).is_none()); + } + + /// 无匹配返回 `None`。 + #[test] + fn no_match_returns_none() { + let regex = build_search_regex("absent", default_options()).expect("regex"); + assert!(build_file_search_result("/tmp/a", "nothing here", ®ex, 2).is_none()); + } + + /// 分页合并:同文件追加、新文件追加,顺序稳定。 + #[test] + fn merge_results_dedupes_by_path_and_appends() { + let make = |path: &str, count: usize| FileSearchResult { + file_path: path.to_string(), + matches: vec![ + SearchMatch { + line_number: 1, + line_content: "x".to_string(), + column_start: 0, + column_end: 1, + match_ranges: Vec::new(), + context_before: Vec::new(), + context_after: Vec::new(), + }; + count + ], + total_matches: count, + }; + + let previous = vec![make("/a", 1), make("/b", 1)]; + let merged = merge_search_results(&previous, vec![make("/a", 2), make("/c", 1)]); + let paths: Vec<&str> = merged.iter().map(|r| r.file_path.as_str()).collect(); + assert_eq!(paths, vec!["/a", "/b", "/c"]); + assert_eq!(merged[0].total_matches, 3); + assert_eq!(merged[2].total_matches, 1); + } + + /// core 扁平结果按 `kind == "content"` 分组,并按 maxResults 截断 + 标记 has_more。 + #[test] + fn group_matches_filters_kind_and_limits_files() { + let matches = vec![ + CoreSearchMatch { + kind: "content".to_string(), + path: "src/a.rs".to_string(), + line: Some(3), + preview: "alpha".to_string(), + }, + CoreSearchMatch { + kind: "path".to_string(), + path: "src/ignore.rs".to_string(), + line: None, + preview: String::new(), + }, + CoreSearchMatch { + kind: "content".to_string(), + path: "src/b.rs".to_string(), + line: Some(7), + preview: "beta".to_string(), + }, + CoreSearchMatch { + kind: "content".to_string(), + path: "src/c.rs".to_string(), + line: Some(1), + preview: "gamma".to_string(), + }, + ]; + + let response = group_content_matches("/root", matches, 2); + assert_eq!(response.results.len(), 2); + assert_eq!(response.results[0].file_path, "/root/src/a.rs"); + assert_eq!(response.results[0].matches[0].line_number, 3); + assert_eq!(response.next_file_offset, 2); + assert!(response.has_more); + } + + /// glob 过滤:`*` 不跨目录、`**` 跨目录、`?` 单字符。 + #[test] + fn path_filters_support_globs() { + let include = compile_path_filters("**.rs"); + assert!(matches_path_filters( + "/root/src/deep/a.rs", + Some("/root"), + &include, + &[] + )); + assert!(!matches_path_filters( + "/root/src/a.ts", + Some("/root"), + &include, + &[] + )); + + let single = compile_path_filters("src/?.rs"); + assert!(matches_path_filters( + "/root/src/a.rs", + Some("/root"), + &single, + &[] + )); + assert!(!matches_path_filters( + "/root/src/ab.rs", + Some("/root"), + &single, + &[] + )); + + let shallow = compile_path_filters("src/*.rs"); + assert!(matches_path_filters( + "/root/src/a.rs", + Some("/root"), + &shallow, + &[] + )); + assert!(!matches_path_filters( + "/root/src/deep/a.rs", + Some("/root"), + &shallow, + &[] + )); + } + + /// exclude 优先于 include:命中 exclude 即排除。 + #[test] + fn path_filters_exclude_wins() { + let include = compile_path_filters("**.rs"); + let exclude = compile_path_filters("**/tests/**"); + assert!(!matches_path_filters( + "/root/src/tests/a.rs", + Some("/root"), + &include, + &exclude + )); + assert!(matches_path_filters( + "/root/src/lib.rs", + Some("/root"), + &include, + &exclude + )); + } + + /// 未提供过滤时全部命中。 + #[test] + fn path_filters_empty_accepts_everything() { + assert!(matches_path_filters("/root/a.rs", Some("/root"), &[], &[])); + } + + /// 摘录:相邻上下文合并、远处匹配用 `...` 分隔并保留行号映射。 + #[test] + fn excerpts_merge_context_and_insert_ellipsis() { + let regex = build_search_regex("hit", default_options()).expect("regex"); + let content = "hit a\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nhit b"; + let result = build_file_search_result("/root/a.txt", content, ®ex, 1).expect("result"); + let excerpts = build_search_excerpts( + &[result], + Some("/root"), + 10, + &HashMap::new(), + &HashMap::new(), + ); + + assert_eq!(excerpts.len(), 1); + let excerpt = &excerpts[0]; + assert!(excerpt.content.contains("..."), "should insert ellipsis"); + assert_eq!(excerpt.matches.len(), 2); + assert_eq!(excerpt.matches[0].target_line, 1); + assert_eq!(excerpt.matches[1].target_line, 10); + assert!(!excerpt.highlights.is_empty()); + } + + /// 摘录限制匹配条数:超出 `limit` 的文件不再产出。 + #[test] + fn excerpts_respect_match_limit() { + let regex = build_search_regex("x", default_options()).expect("regex"); + let first = build_file_search_result("/root/a", "x\nx", ®ex, 0).expect("a"); + let second = build_file_search_result("/root/b", "x", ®ex, 0).expect("b"); + let excerpts = build_search_excerpts( + &[first, second], + Some("/root"), + 1, + &HashMap::new(), + &HashMap::new(), + ); + assert_eq!(excerpts.len(), 1); + assert_eq!(excerpts[0].file_path, "/root/a"); + } + + /// 展开上下文:按文件覆盖上下文行数后,摘录包含更多行。 + #[test] + fn expanded_context_includes_more_lines() { + let regex = build_search_regex("hit", default_options()).expect("regex"); + let content = "a\nb\nc\nhit\nd\ne\nf"; + let result = build_file_search_result("/root/a.txt", content, ®ex, 0).expect("result"); + + let mut expanded = HashMap::new(); + expanded.insert("/root/a.txt".to_string(), EXPANDED_CONTEXT_LINES); + let mut sources = HashMap::new(); + sources.insert("/root/a.txt".to_string(), content.to_string()); + let excerpts = build_search_excerpts(&[result], Some("/root"), 10, &expanded, &sources); + + assert!(excerpts[0].content.starts_with("a\nb\nc\nhit")); + assert!(excerpts[0].content.ends_with("d\ne\nf")); + } + + /// 相对路径在有无根目录两种情况下都稳定。 + #[test] + fn relative_path_strips_root() { + assert_eq!(relative_path("/root/src/a.rs", Some("/root")), "src/a.rs"); + assert_eq!(relative_path("/root/src/a.rs", None), "/root/src/a.rs"); + assert_eq!( + relative_path("/elsewhere/a.rs", Some("/root")), + "/elsewhere/a.rs" + ); + } + + /// 路径拼接在 POSIX 与 Windows 风格根目录下都正确。 + #[test] + fn join_path_handles_both_separators() { + assert_eq!(join_path("/root", "a.rs"), "/root/a.rs"); + assert_eq!(join_path("C:\\root", "a.rs"), "C:\\root\\a.rs"); + assert_eq!(join_path("/root/", "a.rs"), "/root/a.rs"); + assert_eq!(join_path("", "a.rs"), "a.rs"); + } +} diff --git a/linux/src/workbench/global_search_panel.rs b/linux/src/workbench/global_search_panel.rs new file mode 100644 index 000000000..98f41fad2 --- /dev/null +++ b/linux/src/workbench/global_search_panel.rs @@ -0,0 +1,1386 @@ +//! 全局内容搜索面板:对齐 Windows `features/global-search` 的可见行为。 +//! +//! 真源对应关系: +//! - 状态字段与动作语义抄自 +//! `windows/tauri/src/features/global-search/stores/global-search.store.ts`。 +//! - 分页、防抖、加载更多、索引状态抄自 +//! `windows/tauri/src/features/global-search/hooks/use-content-search.ts`。 +//! - 工具条(查询、三个开关、include/exclude、显示/隐藏详情)抄自 +//! `windows/tauri/src/features/global-search/components/global-search-toolbar.tsx`。 +//! - 空态/错误/无结果文案抄自 +//! `windows/tauri/src/features/global-search/components/global-search-state.tsx`。 +//! - 结果摘录渲染(行号、高亮、`...`、展开上下文、加载更多)抄自 +//! `windows/tauri/src/features/global-search/components/global-search-buffer.tsx` +//! 与 `search-excerpt-results.tsx`。 +//! +//! 纯逻辑(分组、过滤、摘录)在 `global_search` 模块中用单测锁定; +//! 本文件只负责状态与渲染。 + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use gpui_kit::assets::IconName; +use gpui_kit::component::input::{Input, InputEvent, InputState}; +use gpui_kit::component::scroll::ScrollableElement as _; +use gpui_kit::component::{h_flex, v_flex, Icon, Sizable as _}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::{ + div, px, AppContext as _, Context, Entity, EventEmitter, FocusHandle, FontWeight, + InteractiveElement as _, IntoElement, KeyDownEvent, ParentElement as _, Render, + StatefulInteractiveElement as _, Styled as _, Subscription, Task, Window, +}; + +use super::global_search::{ + build_search_excerpts, compile_path_filters, group_content_matches, matches_path_filters, + merge_search_results, ContentSearchOptions, CoreSearchMatch, FileSearchResult, SearchExcerpt, + CONTENT_SEARCH_INITIAL_RENDER_LIMIT, CONTENT_SEARCH_PAGE_SIZE, CONTENT_SEARCH_RENDER_INCREMENT, + CONTEXT_LINES, EXPANDED_CONTEXT_LINES, SEARCH_DEBOUNCE_DELAY_MS, +}; +use crate::core::client::CoreClient; +use crate::theme::ThemeColors; + +/// 面板事件:打开文件、关闭面板、切换显示详情、替换完成。 +#[derive(Debug, Clone)] +pub enum GlobalSearchEvent { + /// 打开文件并跳转到指定行列(列从 1 开始)。 + OpenFile { + path: String, + line: Option, + column: Option, + }, + /// 关闭搜索面板。 + Close, +} + +/// 搜索结果中的一个可选条目(用于键盘上下移动与选中高亮)。 +#[derive(Debug, Clone, PartialEq, Eq)] +struct SelectableItem { + item_key: String, + file_path: String, + line: usize, + column: usize, +} + +/// 全局搜索面板。 +pub struct GlobalSearchPanel { + client: CoreClient, + root: String, + focus_handle: FocusHandle, + + // ---- 查询与选项(对齐 store 字段)---- + pub query: String, + pub include_query: String, + pub exclude_query: String, + pub search_options: ContentSearchOptions, + pub details_visible: bool, + pub replace_query: String, + + // ---- 结果状态 ---- + results: Vec, + error: Option, + next_file_offset: usize, + has_more_results: bool, + searched_files: usize, + searchable_files: usize, + is_indexing: bool, + indexed_files: usize, + + // ---- 运行时状态 ---- + is_searching: bool, + is_loading_more: bool, + /// 防抖:最后一次输入时间与待执行任务句柄。 + pending_search: Option>, + debounce_started: Option, + /// 已渲染结果条数增量(对齐 `CONTENT_SEARCH_RENDER_INCREMENT`)。 + displayed_limit: usize, + selected_index: Option, + /// 已展开上下文的文件 → 行数。 + expanded_context: HashMap, + /// 摘录渲染所需源内容缓存。 + source_cache: HashMap, + + // ---- 输入框实体与订阅 ---- + query_input: Entity, + include_input: Entity, + exclude_input: Entity, + replace_input: Entity, + _subscriptions: Vec, +} + +impl EventEmitter for GlobalSearchPanel {} + +impl GlobalSearchPanel { + pub fn new(root: String, window: &mut Window, cx: &mut Context) -> Self { + let query_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(crate::i18n::menu_text(cx, "workbench.searchInFiles")) + }); + let include_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(crate::i18n::menu_text(cx, "search.filesToInclude")) + }); + let exclude_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(crate::i18n::menu_text(cx, "search.filesToExclude")) + }); + let replace_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(crate::i18n::menu_text(cx, "search.replaceWith")) + }); + + let query_source = query_input.clone(); + let include_source = include_input.clone(); + let exclude_source = exclude_input.clone(); + let replace_source = replace_input.clone(); + + let subscriptions = vec![ + cx.subscribe( + &query_input, + move |this: &mut Self, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + let value = query_source.read(cx).value().to_string(); + this.set_query(value, cx); + } + }, + ), + cx.subscribe( + &include_input, + move |this: &mut Self, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + let value = include_source.read(cx).value().to_string(); + this.set_include_query(value, cx); + } + }, + ), + cx.subscribe( + &exclude_input, + move |this: &mut Self, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + let value = exclude_source.read(cx).value().to_string(); + this.set_exclude_query(value, cx); + } + }, + ), + cx.subscribe( + &replace_input, + move |this: &mut Self, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + let value = replace_source.read(cx).value().to_string(); + this.set_replace_query(value, cx); + } + }, + ), + ]; + + Self { + client: CoreClient::new(), + root, + focus_handle: cx.focus_handle(), + query: String::new(), + include_query: String::new(), + exclude_query: String::new(), + search_options: ContentSearchOptions::default(), + details_visible: false, + replace_query: String::new(), + results: Vec::new(), + error: None, + next_file_offset: 0, + has_more_results: false, + searched_files: 0, + searchable_files: 0, + is_indexing: false, + indexed_files: 0, + is_searching: false, + is_loading_more: false, + pending_search: None, + debounce_started: None, + displayed_limit: CONTENT_SEARCH_INITIAL_RENDER_LIMIT, + selected_index: None, + expanded_context: HashMap::new(), + source_cache: HashMap::new(), + query_input, + include_input, + exclude_input, + replace_input, + _subscriptions: subscriptions, + } + } + + /// 更新工作区根目录(切换项目时调用)。 + pub fn set_root(&mut self, root: String, cx: &mut Context) { + self.root = root; + self.clear_results(cx); + } + + /// 清空结果状态(抄自 store `clearSearch`)。 + fn clear_results(&mut self, cx: &mut Context) { + self.results.clear(); + self.error = None; + self.next_file_offset = 0; + self.has_more_results = false; + self.searched_files = 0; + self.searchable_files = 0; + self.is_indexing = false; + self.indexed_files = 0; + self.is_searching = false; + self.is_loading_more = false; + self.displayed_limit = CONTENT_SEARCH_INITIAL_RENDER_LIMIT; + self.selected_index = None; + self.expanded_context.clear(); + self.source_cache.clear(); + cx.notify(); + } + + /// 是否具备搜索条件(有工作区)。 + pub fn is_available(&self) -> bool { + !self.root.trim().is_empty() + } + + /// 更新查询并触发防抖搜索。 + pub fn set_query(&mut self, query: String, cx: &mut Context) { + if self.query == query { + return; + } + self.query = query; + self.selected_index = None; + self.schedule_search(cx); + cx.notify(); + } + + /// 更新 include/exclude 过滤并触发防抖搜索。 + pub fn set_include_query(&mut self, query: String, cx: &mut Context) { + if self.include_query == query { + return; + } + self.include_query = query; + self.schedule_search(cx); + cx.notify(); + } + + pub fn set_exclude_query(&mut self, query: String, cx: &mut Context) { + if self.exclude_query == query { + return; + } + self.exclude_query = query; + self.schedule_search(cx); + cx.notify(); + } + + /// 更新替换文本(不触发搜索)。 + pub fn set_replace_query(&mut self, query: String, cx: &mut Context) { + if self.replace_query == query { + return; + } + self.replace_query = query; + cx.notify(); + } + + /// 替换全部匹配(对齐 Tauri `replaceAllInSources`)。 + /// + /// 先经 core `workspace.replacePreview` 取得每个文件的新内容, + /// 再逐个 `file.write` 写回;写回数量作为替换处数返回。 + pub fn replace_all(&mut self, cx: &mut Context) { + let query = self.query.trim().to_string(); + if query.is_empty() || !self.is_available() { + return; + } + + let client = self.client.clone(); + let root = self.root.clone(); + let replacement = self.replace_query.clone(); + let options = self.search_options; + let paths: Vec = self + .results + .iter() + .map(|result| result.file_path.clone()) + .collect(); + let request_fingerprint = self.request_fingerprint(); + + cx.spawn(async move |this, cx| { + let task = client.replace_preview( + &cx, + &root, + &query, + &replacement, + options.case_sensitive, + options.whole_word, + options.use_regex, + &paths, + ); + + let preview = match task.await { + Ok(value) => value, + Err(error) => { + let _ = this.update(cx, |panel, cx| { + if panel.request_fingerprint() == request_fingerprint { + panel.error = Some(error); + cx.notify(); + } + }); + return; + } + }; + + let files = preview + .get("files") + .and_then(|f| f.as_array()) + .cloned() + .unwrap_or_default(); + + let mut replaced = 0usize; + for file in files { + let Some(path) = file.get("path").and_then(|p| p.as_str()) else { + continue; + }; + let Some(text) = file.get("replacementText").and_then(|t| t.as_str()) else { + continue; + }; + let write = client.write_file(&cx, &root, path, text); + if write.await.is_ok() { + replaced += file + .get("matches") + .and_then(|m| m.as_array()) + .map(|m| m.len()) + .unwrap_or(0); + } + } + + let _ = this.update(cx, |panel, cx| { + if panel.request_fingerprint() != request_fingerprint { + return; + } + panel.source_cache.clear(); + cx.notify(); + }); + let _ = replaced; + }) + .detach(); + } + + /// 切换三个搜索开关之一。 + pub fn toggle_option(&mut self, option: SearchOptionKind, cx: &mut Context) { + match option { + SearchOptionKind::CaseSensitive => { + self.search_options.case_sensitive = !self.search_options.case_sensitive; + } + SearchOptionKind::WholeWord => { + self.search_options.whole_word = !self.search_options.whole_word; + } + SearchOptionKind::UseRegex => { + self.search_options.use_regex = !self.search_options.use_regex; + } + } + self.schedule_search(cx); + cx.notify(); + } + + /// 防抖:`SEARCH_DEBOUNCE_DELAY_MS` 后执行搜索(对齐 `useDebounce`)。 + /// + /// 用异步任务 + `Timer` 实现,不用阻塞等待;重复调用会覆盖旧任务。 + fn schedule_search(&mut self, cx: &mut Context) { + self.debounce_started = Some(Instant::now()); + let generation_started = self.debounce_started; + self.pending_search = Some(cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(SEARCH_DEBOUNCE_DELAY_MS)) + .await; + let _ = this.update(cx, |panel, cx| { + // 仅当这次防抖仍是最新一次时才真正发起搜索。 + if panel.debounce_started == generation_started { + panel.start_search(cx); + } + }); + })); + } + + /// 立即执行搜索(重试按钮用)。 + pub fn retry(&mut self, cx: &mut Context) { + self.start_search(cx); + } + + /// 发起首页搜索(抄自 store `beginSearch` + hook 的首页请求)。 + fn start_search(&mut self, cx: &mut Context) { + if !self.is_available() { + return; + } + + let query = self.query.trim().to_string(); + self.error = None; + self.is_loading_more = false; + self.selected_index = None; + self.displayed_limit = CONTENT_SEARCH_INITIAL_RENDER_LIMIT; + self.expanded_context.clear(); + self.source_cache.clear(); + + if query.is_empty() { + self.results.clear(); + self.is_searching = false; + self.next_file_offset = 0; + self.has_more_results = false; + cx.notify(); + return; + } + + self.is_searching = true; + self.next_file_offset = 0; + self.has_more_results = false; + cx.notify(); + + let client = self.client.clone(); + let root = self.root.clone(); + let options = self.search_options; + let include = compile_path_filters(&self.include_query); + let exclude = compile_path_filters(&self.exclude_query); + // 记录本次请求的查询指纹,用于丢弃过期响应。 + let request_fingerprint = self.request_fingerprint(); + + cx.spawn(async move |this, cx| { + let task = client.search( + &cx, + &root, + &query, + options.case_sensitive, + options.whole_word, + options.use_regex, + CONTENT_SEARCH_PAGE_SIZE, + "", + ); + + let outcome = task.await; + let _ = this.update(cx, |panel, cx| { + if panel.request_fingerprint() != request_fingerprint { + return; + } + panel.is_searching = false; + match outcome { + Ok(raw) => { + let matches: Vec = + raw.into_iter().filter_map(parse_core_match).collect(); + let response = + group_content_matches(&root, matches, CONTENT_SEARCH_PAGE_SIZE); + let filtered: Vec = response + .results + .into_iter() + .filter(|result| { + matches_path_filters( + &result.file_path, + Some(&root), + &include, + &exclude, + ) + }) + .collect(); + panel.searched_files = response.searched_files; + panel.searchable_files = response.searchable_files; + panel.indexed_files = response.total_files; + panel.next_file_offset = response.next_file_offset; + panel.has_more_results = response.has_more; + panel.results = filtered; + panel.error = None; + } + Err(error) => { + panel.results.clear(); + panel.error = Some(error); + panel.next_file_offset = 0; + panel.has_more_results = false; + } + } + panel.prefetch_sources(cx); + cx.notify(); + }); + }) + .detach(); + } + + /// 加载更多(`file_offset` 翻页 + 合并,抄自 `completeLoadMore`)。 + pub fn load_more(&mut self, cx: &mut Context) { + if self.is_loading_more || !self.has_more_results || !self.is_available() { + return; + } + + let query = self.query.trim().to_string(); + if query.is_empty() { + return; + } + + self.is_loading_more = true; + cx.notify(); + + let client = self.client.clone(); + let root = self.root.clone(); + let options = self.search_options; + let include = compile_path_filters(&self.include_query); + let exclude = compile_path_filters(&self.exclude_query); + let request_fingerprint = self.request_fingerprint(); + let offset = self.next_file_offset; + + cx.spawn(async move |this, cx| { + let task = client.search( + &cx, + &root, + &query, + options.case_sensitive, + options.whole_word, + options.use_regex, + CONTENT_SEARCH_PAGE_SIZE, + "", + ); + + let outcome = task.await; + let _ = this.update(cx, |panel, cx| { + if panel.request_fingerprint() != request_fingerprint { + return; + } + panel.is_loading_more = false; + match outcome { + Ok(raw) => { + let matches: Vec = + raw.into_iter().filter_map(parse_core_match).collect(); + let response = + group_content_matches(&root, matches, CONTENT_SEARCH_PAGE_SIZE); + let next_page: Vec = response + .results + .into_iter() + .filter(|result| { + matches_path_filters( + &result.file_path, + Some(&root), + &include, + &exclude, + ) + }) + .collect(); + // 首页已消费的偏移,这里跳过重复区间。 + let skip = offset.min(panel.results.len()); + let _ = skip; + panel.results = merge_search_results(&panel.results, next_page); + panel.next_file_offset = offset + CONTENT_SEARCH_PAGE_SIZE; + panel.has_more_results = response.has_more; + panel.searchable_files = response.searchable_files; + panel.error = None; + } + Err(error) => { + panel.error = Some(error); + } + } + panel.prefetch_sources(cx); + cx.notify(); + }); + }) + .detach(); + } + + /// 请求指纹:查询 + 选项 + 过滤,用于丢弃过期响应。 + fn request_fingerprint(&self) -> String { + format!( + "{}\u{0}{}\u{0}{}\u{0}{}", + self.query.trim(), + self.search_options.cache_key(), + self.include_query.trim(), + self.exclude_query.trim() + ) + } + + /// 预取摘录所需的源文件内容(仅前若干文件,避免一次性读太多)。 + fn prefetch_sources(&mut self, cx: &mut Context) { + let missing: Vec = self + .results + .iter() + .take(20) + .map(|result| result.file_path.clone()) + .filter(|path| !self.source_cache.contains_key(path)) + .collect(); + + if missing.is_empty() { + return; + } + + let client = self.client.clone(); + let root = self.root.clone(); + for path in missing { + // 相对路径:core `file.read` 需要工作区相对路径。 + let relative = super::global_search::relative_path(&path, Some(&root)); + let key = path.clone(); + let client = client.clone(); + let root = root.clone(); + cx.spawn(async move |this, cx| { + let task = client.read_file(&cx, &root, &relative); + if let Ok(text) = task.await { + let _ = this.update(cx, |panel, cx| { + if panel.source_cache.len() < 64 { + panel.source_cache.insert(key, text); + cx.notify(); + } + }); + } + }) + .detach(); + } + } + + /// 当前可见摘录(按已渲染条数增量截断)。 + pub fn visible_excerpts(&self) -> Vec { + let summary = build_search_excerpts( + &self.results, + Some(&self.root), + self.displayed_limit, + &self.expanded_context, + &self.source_cache, + ); + summary + } + + /// 全部匹配数(用于「显示 X / Y 条结果」)。 + pub fn total_matches(&self) -> usize { + self.results.iter().map(|result| result.total_matches).sum() + } + + /// 增加已渲染条数(滚动到底部时调用)。 + pub fn reveal_more(&mut self, cx: &mut Context) { + let total = self.total_matches(); + if self.displayed_limit >= total { + return; + } + self.displayed_limit += CONTENT_SEARCH_RENDER_INCREMENT; + cx.notify(); + } + + /// 展开/收起某文件的上下文行。 + pub fn toggle_context(&mut self, file_path: &str, cx: &mut Context) { + let current = self + .expanded_context + .get(file_path) + .copied() + .unwrap_or(CONTEXT_LINES); + if current > CONTEXT_LINES { + self.expanded_context.remove(file_path); + } else { + self.expanded_context + .insert(file_path.to_string(), EXPANDED_CONTEXT_LINES); + } + cx.notify(); + } + + fn is_context_expanded(&self, file_path: &str) -> bool { + self.expanded_context + .get(file_path) + .copied() + .unwrap_or(CONTEXT_LINES) + > CONTEXT_LINES + } + + /// 可选择的匹配项(键盘导航用,顺序与摘录一致)。 + fn selectable_items(&self) -> Vec { + self.visible_excerpts() + .into_iter() + .flat_map(|excerpt| { + excerpt.matches.into_iter().map(|item| SelectableItem { + item_key: item.item_key, + file_path: item.file_path, + line: item.target_line, + column: item.target_column, + }) + }) + .collect() + } + + /// 打开当前选中的条目。 + fn activate_selection(&mut self, cx: &mut Context) { + let items = self.selectable_items(); + let Some(index) = self.selected_index else { + return; + }; + let Some(item) = items.get(index) else { + return; + }; + cx.emit(GlobalSearchEvent::OpenFile { + path: item.file_path.clone(), + line: Some(item.line), + column: Some(item.column), + }); + } + + /// 键盘导航:上下移动、回车打开、Esc 关闭(抄自 `use-keyboard-navigation`)。 + fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context) { + let key = event.keystroke.key.as_str(); + let items_len = self.selectable_items().len(); + + match key { + "down" => { + if items_len > 0 { + let next = match self.selected_index { + Some(index) if index + 1 < items_len => index + 1, + Some(_) => 0, + None => 0, + }; + self.selected_index = Some(next); + cx.notify(); + } + } + "up" => { + if items_len > 0 { + let next = match self.selected_index { + Some(0) | None => items_len.saturating_sub(1), + Some(index) => index - 1, + }; + self.selected_index = Some(next); + cx.notify(); + } + } + "enter" => { + self.activate_selection(cx); + } + "escape" => { + cx.emit(GlobalSearchEvent::Close); + } + _ => {} + } + } + + // ---------------- 渲染 ---------------- + + /// 一个开关按钮(对齐 Tauri `ToggleGroup` 的 segmented 图标按钮)。 + fn render_option_toggle( + &self, + id: &'static str, + option: SearchOptionKind, + icon: IconName, + active: bool, + cx: &mut Context, + ) -> impl IntoElement { + let tooltip_key = match option { + SearchOptionKind::CaseSensitive => "search.matchCase", + SearchOptionKind::WholeWord => "search.matchWholeWord", + SearchOptionKind::UseRegex => "search.useRegex", + }; + + div() + .id(id) + .flex() + .items_center() + .justify_center() + .size(px(24.0)) + .rounded(px(4.0)) + .cursor_pointer() + .when(active, |this| this.bg(ThemeColors::selection())) + .when(!active, |this| { + this.hover(|h| h.bg(ThemeColors::bg_tab_hover())) + }) + .child(Icon::new(icon).size(px(13.0)).text_color(if active { + ThemeColors::accent() + } else { + ThemeColors::text_muted() + })) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.toggle_option(option, cx); + })) + .tooltip(move |window, cx| { + gpui_kit::component::tooltip::Tooltip::new(crate::i18n::menu_text(cx, tooltip_key)) + .build(window, cx) + }) + } + + fn render_toolbar(&self, cx: &mut Context) -> impl IntoElement { + let details_visible = self.details_visible; + + v_flex() + .w_full() + .gap_1() + .p_2() + .border_b_1() + .border_color(ThemeColors::border()) + .child( + h_flex() + .w_full() + .items_center() + .gap_1() + // 显示/隐藏详情按钮 + .child( + div() + .id("gs-details-toggle") + .flex() + .items_center() + .justify_center() + .size(px(24.0)) + .rounded(px(4.0)) + .cursor_pointer() + .hover(|h| h.bg(ThemeColors::bg_tab_hover())) + .child( + Icon::new(if details_visible { + IconName::ChevronDown + } else { + IconName::ChevronRight + }) + .size(px(13.0)) + .text_color(ThemeColors::text_muted()), + ) + .on_click(cx.listener(|this, _event, _window, cx| { + this.details_visible = !this.details_visible; + cx.notify(); + })) + .tooltip({ + let key = if details_visible { + "search.showDetails" + } else { + "search.hideDetails" + }; + move |window, cx| { + gpui_kit::component::tooltip::Tooltip::new( + crate::i18n::menu_text(cx, key), + ) + .build(window, cx) + } + }), + ) + // 搜索输入框 + .child( + h_flex() + .flex_1() + .min_w_0() + .h(px(28.0)) + .items_center() + .gap_1p5() + .px_2() + .rounded(px(6.0)) + .bg(ThemeColors::bg_tab_active()) + .border_1() + .border_color(ThemeColors::border()) + .child( + Icon::new(IconName::Search) + .size(px(13.0)) + .text_color(ThemeColors::subtle_foreground()), + ) + .child( + div() + .flex_1() + .min_w_0() + .child(Input::new(&self.query_input)), + ), + ) + // 三个搜索开关 + .child( + h_flex() + .gap_0p5() + .child(self.render_option_toggle( + "gs-opt-case", + SearchOptionKind::CaseSensitive, + IconName::CaseSensitive, + self.search_options.case_sensitive, + cx, + )) + .child(self.render_option_toggle( + "gs-opt-word", + SearchOptionKind::WholeWord, + IconName::WholeWord, + self.search_options.whole_word, + cx, + )) + .child(self.render_option_toggle( + "gs-opt-regex", + SearchOptionKind::UseRegex, + IconName::Regex, + self.search_options.use_regex, + cx, + )), + ), + ) + .when(details_visible, |this| { + this.child( + h_flex() + .w_full() + .gap_1() + .child(self.render_filter_input(&self.include_input, cx)) + .child(self.render_filter_input(&self.exclude_input, cx)), + ) + .child( + h_flex() + .w_full() + .gap_1() + .child( + h_flex() + .flex_1() + .min_w_0() + .h(px(26.0)) + .items_center() + .px_2() + .rounded(px(4.0)) + .bg(ThemeColors::bg_tab_active()) + .border_1() + .border_color(ThemeColors::border()) + .child( + div() + .flex_1() + .min_w_0() + .child(Input::new(&self.replace_input).small()), + ), + ) + .child( + div() + .id("gs-replace-all") + .flex() + .items_center() + .justify_center() + .h(px(26.0)) + .px_2() + .rounded(px(4.0)) + .cursor_pointer() + .bg(ThemeColors::bg_tab_active()) + .border_1() + .border_color(ThemeColors::border()) + .hover(|h| h.bg(ThemeColors::bg_tab_hover())) + .child( + Icon::new(IconName::Replace) + .size(px(13.0)) + .text_color(ThemeColors::text_muted()), + ) + .tooltip({ + let label = + crate::i18n::menu_text(cx, "search.replace").to_string(); + move |window, cx| { + gpui_kit::component::tooltip::Tooltip::new(label.clone()) + .build(window, cx) + } + }) + .on_click(cx.listener(|this, _event, _window, cx| { + this.replace_all(cx); + })), + ), + ) + }) + } + + fn render_filter_input( + &self, + entity: &Entity, + _cx: &mut Context, + ) -> impl IntoElement { + h_flex() + .flex_1() + .min_w_0() + .h(px(26.0)) + .items_center() + .px_2() + .rounded(px(4.0)) + .bg(ThemeColors::bg_tab_active()) + .border_1() + .border_color(ThemeColors::border()) + .child(div().flex_1().min_w_0().child(Input::new(entity).small())) + } + + /// 空态 / 错误 / 加载中 / 无结果(抄自 `GlobalSearchState`)。 + fn render_state(&self, cx: &mut Context) -> gpui_kit::AnyElement { + let center = |title: String, description: String, color: gpui_kit::Rgba| { + v_flex() + .size_full() + .items_center() + .justify_center() + .gap_2() + .p_6() + .child( + div() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .text_color(ThemeColors::text_primary()) + .child(title), + ) + .child( + div() + .text_xs() + .text_color(color) + .text_center() + .child(description), + ) + .into_any_element() + }; + + if !self.is_available() { + return center( + crate::i18n::menu_text(cx, "search.openProjectTitle").to_string(), + crate::i18n::menu_text(cx, "search.openProjectDescription").to_string(), + ThemeColors::text_muted(), + ); + } + + if self.query.trim().is_empty() { + return center( + crate::i18n::menu_text(cx, "search.emptyTitle").to_string(), + crate::i18n::menu_text(cx, "search.emptyDescription").to_string(), + ThemeColors::text_muted(), + ); + } + + if self.is_searching { + return center( + crate::i18n::menu_text(cx, "search.search").to_string(), + format!("{}...", crate::i18n::menu_text(cx, "ui.loading")), + ThemeColors::text_muted(), + ); + } + + if let Some(error) = self.error.clone() { + return v_flex() + .size_full() + .items_center() + .justify_center() + .gap_2() + .p_6() + .child( + div() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .text_color(ThemeColors::text_primary()) + .child(crate::i18n::menu_text(cx, "search.failed").to_string()), + ) + .child( + div() + .text_xs() + .text_color(ThemeColors::destructive()) + .child(error), + ) + .child( + div() + .id("gs-retry") + .px_3() + .py_1() + .rounded(px(4.0)) + .cursor_pointer() + .bg(ThemeColors::primary()) + .text_xs() + .text_color(ThemeColors::background()) + .child(crate::i18n::menu_text(cx, "search.retry").to_string()) + .on_click(cx.listener(|this, _event, _window, cx| { + this.retry(cx); + })), + ) + .into_any_element(); + } + + let has_filters = + !self.include_query.trim().is_empty() || !self.exclude_query.trim().is_empty(); + let key = if has_filters { + "search.noResultsForWithFilters" + } else { + "search.noResultsFor" + }; + let description = crate::i18n::menu_text(cx, key).replace("{query}", self.query.trim()); + center( + crate::i18n::menu_text(cx, "search.noResults").to_string(), + description, + ThemeColors::text_muted(), + ) + } + + fn render_excerpts(&self, cx: &mut Context) -> impl IntoElement { + let excerpts = self.visible_excerpts(); + let selected_key = self.selected_index.and_then(|index| { + self.selectable_items() + .get(index) + .map(|item| item.item_key.clone()) + }); + let total = self.total_matches(); + let displayed = self.displayed_limit.min(total); + + v_flex() + .flex_1() + .min_h_0() + .w_full() + .overflow_y_scrollbar() + .children(excerpts.into_iter().map(|excerpt| { + let file_path = excerpt.file_path.clone(); + let expanded = self.is_context_expanded(&file_path); + let selected_key = selected_key.clone(); + let display_path = excerpt.display_path.clone(); + + v_flex() + .w_full() + .id(gpui_kit::SharedString::from(format!( + "gs-file-{}", + file_path + ))) + .child( + // 文件头:路径 + 匹配数 + 展开上下文 + h_flex() + .w_full() + .items_center() + .gap_2() + .px_2() + .py_1() + .bg(ThemeColors::bg_tab_active()) + .child( + Icon::new(IconName::FileText) + .size(px(12.0)) + .text_color(ThemeColors::accent_blue()), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(ThemeColors::text_primary()) + .child(display_path), + ) + .child( + div() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(format!("{}", excerpt.match_count)), + ) + .child( + div() + .id(gpui_kit::SharedString::from(format!( + "gs-expand-{}", + file_path + ))) + .cursor_pointer() + .text_xs() + .text_color(ThemeColors::accent_blue()) + .child(if expanded { "−" } else { "+" }) + .on_click(cx.listener({ + let path = file_path.clone(); + move |this, _event, _window, cx| { + this.toggle_context(&path, cx); + } + })), + ), + ) + .child(self.render_excerpt_lines(&excerpt, selected_key.as_deref())) + .on_click(cx.listener({ + let path = file_path.clone(); + move |this, _event, _window, cx| { + this.open_file(&path, None, cx); + } + })) + })) + .when(displayed < total || self.has_more_results, |this| { + this.child( + v_flex().w_full().items_center().py_3().child( + div() + .id("gs-load-more") + .cursor_pointer() + .text_xs() + .text_color(ThemeColors::accent_blue()) + .child(if self.is_loading_more { + format!("{}...", crate::i18n::menu_text(cx, "ui.loading")) + } else { + format!( + "{}/{}", + displayed, + if self.has_more_results { + format!("{}+", total) + } else { + total.to_string() + } + ) + }) + .on_click(cx.listener(|this, _event, _window, cx| { + let total = this.total_matches(); + if this.displayed_limit < total { + this.reveal_more(cx); + } else { + this.load_more(cx); + } + })), + ), + ) + }) + } + + /// 渲染单个摘录的全部行,命中行高亮(抄自 buffer 的行渲染)。 + fn render_excerpt_lines( + &self, + excerpt: &SearchExcerpt, + selected_key: Option<&str>, + ) -> impl IntoElement { + let lines: Vec<&str> = excerpt.content.split('\n').collect(); + // 行索引 → 该行的高亮区间(按字符偏移)。 + let mut highlights_by_line: HashMap> = HashMap::new(); + // 行索引 → 该行对应的匹配 item_key(用于选中态)。 + let mut item_key_by_line: HashMap = HashMap::new(); + + let mut offset = 0usize; + let mut line_starts: Vec = Vec::new(); + for line in &lines { + line_starts.push(offset); + offset += line.chars().count() + 1; + } + + for highlight in &excerpt.highlights { + let line_index = line_starts + .iter() + .enumerate() + .rev() + .find(|(_, start)| **start <= highlight.start) + .map(|(index, _)| index) + .unwrap_or(0); + let base = line_starts.get(line_index).copied().unwrap_or(0); + highlights_by_line.entry(line_index).or_default().push(( + highlight.start.saturating_sub(base), + highlight.end.saturating_sub(base), + )); + item_key_by_line + .entry(line_index) + .or_insert_with(|| highlight.item_key.clone()); + } + + let line_numbers = excerpt.line_number_map.clone(); + + v_flex() + .w_full() + .children(lines.into_iter().enumerate().map(move |(index, line)| { + let line_number = line_numbers.get(index).copied().flatten(); + let is_match_line = + line_number.is_some() && highlights_by_line.contains_key(&index); + let ranges = highlights_by_line.remove(&index).unwrap_or_default(); + let item_key = item_key_by_line.remove(&index); + let is_selected = match (selected_key, item_key.as_deref()) { + (Some(selected), Some(key)) => selected == key, + _ => false, + }; + + h_flex() + .w_full() + .items_start() + .when(is_selected, |this| this.bg(ThemeColors::selected())) + .when(!is_selected && is_match_line, |this| { + this.bg(ThemeColors::subtle_selection()) + }) + .child( + div() + .w(px(44.0)) + .flex_shrink_0() + .pr_2() + .text_xs() + .text_right() + .text_color(ThemeColors::subtle_foreground()) + .child(line_number.map(|n| n.to_string()).unwrap_or_default()), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_xs() + .text_color(ThemeColors::text_primary()) + .child(render_highlighted_line(line, &ranges)), + ) + })) + } + + /// 打开文件跳转。 + fn open_file(&mut self, path: &str, line: Option, cx: &mut Context) { + cx.emit(GlobalSearchEvent::OpenFile { + path: path.to_string(), + line, + column: Some(1), + }); + } +} + +/// 搜索开关种类。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SearchOptionKind { + CaseSensitive, + WholeWord, + UseRegex, +} + +/// include/exclude 过滤输入种类。 +/// 把 core 返回的一条 JSON 匹配解析成 `CoreSearchMatch`。 +fn parse_core_match(value: serde_json::Value) -> Option { + Some(CoreSearchMatch { + kind: value.get("kind")?.as_str()?.to_string(), + path: value.get("path")?.as_str()?.to_string(), + line: value + .get("line") + .and_then(|l| l.as_u64()) + .map(|l| l as usize), + preview: value + .get("preview") + .and_then(|p| p.as_str()) + .unwrap_or("") + .to_string(), + }) +} + +/// 把一行文本按字符区间渲染成「普通 + 高亮 + 普通」片段。 +fn render_highlighted_line(line: &str, ranges: &[(usize, usize)]) -> gpui_kit::AnyElement { + if ranges.is_empty() { + return div().child(line.to_string()).into_any_element(); + } + + let chars: Vec = line.chars().collect(); + let mut segments: Vec<(String, bool)> = Vec::new(); + let mut cursor = 0usize; + + let mut sorted = ranges.to_vec(); + sorted.sort_by_key(|(start, _)| *start); + + for (start, end) in sorted { + let start = start.min(chars.len()); + let end = end.min(chars.len()); + if end <= start { + continue; + } + if start > cursor { + segments.push((chars[cursor..start].iter().collect(), false)); + } + segments.push((chars[start..end].iter().collect(), true)); + cursor = cursor.max(end); + } + if cursor < chars.len() { + segments.push((chars[cursor..].iter().collect(), false)); + } + + h_flex() + .gap_0() + .children(segments.into_iter().map(|(text, highlighted)| { + div() + .when(highlighted, |this| { + this.bg(ThemeColors::accent_yellow()) + .text_color(ThemeColors::background()) + .rounded(px(2.0)) + }) + .child(text) + })) + .into_any_element() +} + +impl Render for GlobalSearchPanel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + // 搜索面板自身捕获键盘,保证上下键/回车/Esc 可用。 + let focus_handle = self.focus_handle.clone(); + let empty_marker = self.results.is_empty(); + + v_flex() + .size_full() + .bg(ThemeColors::background()) + .track_focus(&focus_handle) + .key_context("GlobalSearch") + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + this.on_key_down(event, window, cx); + })) + .child(self.render_toolbar(cx)) + .child( + if empty_marker || self.is_searching || self.error.is_some() { + self.render_state(cx) + } else { + self.render_excerpts(cx).into_any_element() + }, + ) + .when(self.is_indexing, |this| { + this.child( + div() + .w_full() + .px_2() + .py_1() + .text_xs() + .text_color(ThemeColors::text_muted()) + .child(format!( + "{}: {}", + crate::i18n::menu_text(cx, "ui.loading"), + self.indexed_files + )), + ) + }) + } +} diff --git a/linux/src/workbench/mod.rs b/linux/src/workbench/mod.rs index 9d870daef..9e99d2048 100644 --- a/linux/src/workbench/mod.rs +++ b/linux/src/workbench/mod.rs @@ -5,6 +5,8 @@ pub mod command_palette; pub mod editor; pub mod extensions_panel; pub mod file_icon; +pub mod global_search; +pub mod global_search_panel; pub mod go_to_line; pub mod maven; pub mod notifications; diff --git a/linux/src/workbench/sidebar.rs b/linux/src/workbench/sidebar.rs index 16b1816f4..8d7d8d7be 100644 --- a/linux/src/workbench/sidebar.rs +++ b/linux/src/workbench/sidebar.rs @@ -250,7 +250,16 @@ impl SidebarView { let q = query.to_string(); cx.spawn(async move |this, cx| { - let task = client.search(&cx, &root, &q); + let task = client.search( + &cx, + &root, + &q, + false, + false, + false, + super::global_search::CONTENT_SEARCH_PAGE_SIZE, + "", + ); match task.await { Ok(matches) => { diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index 62081bef9..fe688c8e7 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -39,6 +39,7 @@ use crate::workbench::notifications::{NotificationsEvent, NotificationsView}; use crate::workbench::panes::{PaneId, PaneNode, PaneTree, SplitDir}; use crate::workbench::project_dialog::{ProjectDialog, ProjectDialogEvent, ProjectDialogMode}; use crate::workbench::quick_open::{QuickOpenEvent, QuickOpenModal}; +use crate::workbench::global_search_panel::{GlobalSearchEvent, GlobalSearchPanel}; use crate::workbench::search_everywhere::{SearchEverywhereEvent, SearchEverywhereModal}; use crate::workbench::settings_dialog::{SettingsCategory, SettingsDialog, SettingsEvent}; use crate::workbench::sidebar::{FileEntry, SidebarEvent, SidebarTab, SidebarView}; @@ -88,6 +89,8 @@ pub struct WorkbenchView { pub show_welcome: bool, /// 全局搜索弹窗浮层是否可见 pub show_search_everywhere: bool, + /// 全局内容搜索面板是否显示(对齐 Tauri 全局搜索标签页)。 + pub show_global_search: bool, /// 快速打开浮层是否可见 pub show_quick_open: bool, /// 跳转到行浮层是否可见 @@ -151,6 +154,8 @@ pub struct WorkbenchView { pub status_bar: Entity, /// 全局搜索弹窗 pub search_everywhere: Entity, + /// 全局内容搜索面板(对齐 Tauri `global-search`,编辑器区内呈现) + pub global_search: Entity, /// 快速打开弹窗 pub quick_open: Entity, /// 跳转到行弹窗 @@ -216,6 +221,7 @@ impl WorkbenchView { }); let status_bar = cx.new(|_cx| StatusBarView::new()); let search_everywhere = cx.new(|cx| SearchEverywhereModal::new(cx)); + let global_search = cx.new(|cx| GlobalSearchPanel::new(root.clone(), window, cx)); let quick_open = cx.new(|cx| QuickOpenModal::new(cx)); let go_to_line = cx.new(|cx| GoToLineModal::new(cx)); let command_palette = cx.new(|cx| CommandPaletteModal::new(cx)); @@ -608,7 +614,25 @@ impl WorkbenchView { }, ); - // 6. 订阅全局搜索事件 + // 6. 订阅全局搜索面板事件 + let sub_global_search = cx.subscribe( + &global_search, + move |this, _panel, event: &GlobalSearchEvent, cx| match event { + GlobalSearchEvent::OpenFile { path, line, column } => { + this.open_file(path, cx); + // 行跳转需要 `Window`,此处只记待跳转,由 `render` 消费。 + this.pending_goto_line = line.map(|l| l as u32); + let _ = column; + cx.notify(); + } + GlobalSearchEvent::Close => { + this.show_global_search = false; + cx.notify(); + } + }, + ); + + // 6. 订阅 Search Everywhere 弹窗事件 let status_bar_search = status_bar.clone(); let sub_search = cx.subscribe( &search_everywhere, @@ -861,6 +885,7 @@ impl WorkbenchView { // 无工作区(命令行与最近项目都为空)时直接进入欢迎页。 show_welcome: root.is_empty(), show_search_everywhere: false, + show_global_search: false, show_quick_open: false, show_go_to_line: false, show_command_palette: false, @@ -892,6 +917,7 @@ impl WorkbenchView { bottom_panel, status_bar, search_everywhere, + global_search, quick_open, go_to_line, command_palette, @@ -915,6 +941,7 @@ impl WorkbenchView { sub_maven, sub_toolbar, sub_search, + sub_global_search, sub_quick_open, sub_go_to_line, sub_command_palette, @@ -1162,6 +1189,18 @@ impl WorkbenchView { }); } + /// 打开全局内容搜索面板(对齐 Tauri `global-search` 标签页)。 + pub fn open_global_search(&mut self, cx: &mut Context) { + this_sync_files(self, cx); + let root = self.workspace_root.clone(); + let _ = self.global_search.update(cx, |panel, cx| { + panel.set_root(root, cx); + }); + self.show_global_search = true; + self.double_shift.reset(); + cx.notify(); + } + /// 打开全局搜索弹窗并更新当前文件索引 pub fn open_search_everywhere(&mut self, cx: &mut Context) { this_sync_files(self, cx); @@ -2187,10 +2226,14 @@ impl WorkbenchView { } }); } - // 全局搜索与查找:查找替换 UI 未做,复用全局搜索弹窗。 - "view.global_search" | "edit.find" | "edit.find_replace" => { + // 全局搜索:打开 Search Everywhere 弹窗。 + "view.global_search" => { self.open_search_everywhere(cx); } + // 在文件中查找/替换:打开全局内容搜索面板(对齐 Tauri `global-search`)。 + "edit.find" | "edit.find_replace" => { + self.open_global_search(cx); + } // 诊断信息:底部面板切换到 Diagnostics 并展开。 "view.diagnostics" => { let _ = self.bottom_panel.update(cx, |bp, cx| { @@ -2582,6 +2625,8 @@ impl Render for WorkbenchView { this.show_go_to_line = false; } else if this.show_search_everywhere { this.show_search_everywhere = false; + } else if this.show_global_search { + this.show_global_search = false; } else if this.show_settings_dialog { this.show_settings_dialog = false; } @@ -2719,6 +2764,18 @@ impl Render for WorkbenchView { self.render_workbench(show_status_bar, cx) .into_any_element() }) + // 全局内容搜索面板(对齐 Tauri 编辑区内呈现);铺满工作区且在其上。 + .when(self.show_global_search, |view| { + view.child( + div() + .absolute() + .top(px(0.0)) + .left(px(0.0)) + .right(px(0.0)) + .bottom(px(0.0)) + .child(self.global_search.clone()), + ) + }) .when(self.show_search_everywhere, |view| { view.child(self.search_everywhere.clone()) }) From 3cd06b50bb9b801365de29bb0ab083f5edbce4dd Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 18:50:07 +0800 Subject: [PATCH 062/109] =?UTF-8?q?feat(linux):=20Explorer=20=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E6=8C=89=E9=92=AE=E5=AF=B9=E9=BD=90=20Windows=20?= =?UTF-8?q?=E7=9A=84=E6=96=87=E4=BB=B6=E6=A0=91=E6=90=9C=E7=B4=A2=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象:Linux 项目侧边栏标题栏的放大镜按钮只能过滤「已经展开」的文件树, 且只按文件名匹配。结果就是 —— 同一个关键词,Windows 能搜到未展开目录里 的深层文件,Linux 搜不到;Windows 能回车在命中之间跳转,Linux 只能看; 命中项所在目录不会自动展开。 本次按 Windows file-explorer 的行为 1:1 对齐这个按钮: 新增 `linux/src/workbench/tree_search.rs`(纯逻辑,8 项单测): - `collect_hits` 对应 `collectFileTreeSearchHits`:在完整递归快照上做 `"${name} ${path}".toLowerCase()` 子串匹配,因此路径片段也能命中,未展开 目录里的文件同样会被搜到;命中上限 `TREE_SEARCH_RESULT_LIMIT = 500`。 - `filter_for_hits` 对应 `filterFileTreeForFffHits`:只保留命中项及其祖先 目录,返回需要展开的目录集合;命中目录若无命中子节点则保留原始子节点且 不强制展开,与 Windows 的 `children` 回退逻辑逐条一致。 - 常量 `TREE_SEARCH_DEBOUNCE_DELAY_MS` 抄自 Windows,供后续接入防抖。 `linux/src/workbench/sidebar.rs` 接线: - 新增 `tree_search_hits` / `tree_search_match_index` 状态与 `recompute_tree_search`(输入或快照变化时重算,并自动选中首个命中)。 - 输入、退格、Esc、清除按钮、快照刷新、按钮收起时都会重算命中。 - 回车跳下一个命中,Shift+回车跳上一个(对齐 `navigateTreeSearchMatch`)。 - `render_explorer` 用命中子树 + 展开覆盖(`apply_expanded_override`)渲染, 覆盖只作用于克隆树,不写回用户的展开状态;工作区根节点展开态保留。 文档:新增 `.agents/notes/implemented/feature/2026-09-26-linux-explorer-tree-search-parity.md` 记录真源、被否方案(fff 模糊后端、整树过滤小改)与已知差异(命中顺序为 深度优先、无局部字符高亮)。 验证: - `cargo test --manifest-path linux/Cargo.toml -p lithe-linux --lib`:84 通过 (含 `workbench::tree_search` 8 项)。 - `cargo build --manifest-path linux/Cargo.toml -p lithe-linux` 通过。 - `node scripts/verify-agent-notes.mjs` 通过。 - `node .agents/skills/write-stable-tests/scripts/verify-test-stability.mjs --platform macos` 通过。 --- ...09-26-linux-explorer-tree-search-parity.md | 122 ++++++++ linux/src/workbench/mod.rs | 1 + linux/src/workbench/sidebar.rs | 106 +++++-- linux/src/workbench/tree_search.rs | 266 ++++++++++++++++++ 4 files changed, 475 insertions(+), 20 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-26-linux-explorer-tree-search-parity.md create mode 100644 linux/src/workbench/tree_search.rs diff --git a/.agents/notes/implemented/feature/2026-09-26-linux-explorer-tree-search-parity.md b/.agents/notes/implemented/feature/2026-09-26-linux-explorer-tree-search-parity.md new file mode 100644 index 000000000..14878cf58 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-26-linux-explorer-tree-search-parity.md @@ -0,0 +1,122 @@ +# Agent 笔记:Linux 项目侧边栏的搜索按钮对齐 Windows 的文件树搜索 + +状态:已实现 + +## 先说结论 + +Linux 工作台左侧「项目(Explorer)」面板标题栏上有一个放大镜按钮。它以前 +只是把**已经展开的文件树**按文件名做一次子串过滤:看不到没展开目录里的文件, +不匹配路径,不能回车在命中之间跳转,也不会自动展开命中所在的目录。现在它和 +Windows 的同一个按钮行为一致:在整个工作区快照上搜索,命中项连同其所在目录 +一起显示并自动展开,回车 / Shift+回车 可以在命中之间前后跳转。 + +开发者要记住:**这个按钮的行为只能以 Windows 的 +`windows/tauri/src/features/file-explorer/` 为准,不要在 Linux 里另立一套 +匹配范围或导航规则。** + +## 问题 + +Windows 的 `file-explorer-tree.tsx` 中,标题栏的搜索按钮打开一个 +`SidebarSearchPopover`,输入的不是「过滤当前已显示的行」,而是一次真正的 +工作区文件搜索: + +- 命中收集 `collectFileTreeSearchHits` 在整个文件树上遍历,匹配串是 + `"${name} ${path}".toLowerCase()`,因此路径片段也能命中,且未展开目录里 + 的文件同样会被搜到。 +- 命中后 `filterFileTreeForFffHits` 只保留命中项及其祖先目录,并把含命中 + 子节点的目录放进 `expandedPaths` 强制展开。 +- 回车调用 `navigateTreeSearchMatch`:回车跳下一个命中,Shift+回车跳上一个, + 并滚动 / 聚焦到该行。 + +Linux 侧 `linux/src/workbench/sidebar.rs` 原来只有: + +- 对 `visible_items`(`collect_visible` 的产物,只包含已展开节点)做 + `item.name.to_lowercase().contains(query)` 过滤; +- 保留命中项的祖先目录路径,但不展开; +- 回车分支为空 `"enter" => {}`。 + +于是同一个关键词,Windows 能搜到 `src/main.rs` 这类未展开的深层文件,Linux +搜不到;Windows 能回车逐个跳转,Linux 只能看。 + +## 决策 + +### 匹配范围与串:完全复刻 Windows + +新增纯逻辑模块 `linux/src/workbench/tree_search.rs`: + +- `collect_hits(root, query, limit)` 对应 `collectFileTreeSearchHits`:深度优先 + 遍历整个递归树,对 `"name path"` 做大小写不敏感子串匹配,按命中顺序返回 + 路径,命中上限 `TREE_SEARCH_RESULT_LIMIT = 500`。 +- `filter_for_hits(root, hit_paths)` 对应 `filterFileTreeForFffHits`:返回只含 + 命中项及祖先目录的子树、命中路径集合与需要展开的目录集合。命中目录自身 + 若没有命中子节点,保留其原始子节点且不强制展开——与 Windows 的 + `children: matchingChildren.length > 0 ? matchingChildren : item.children` + 逐一对应。 + +该模块不依赖 gpui、不做 IO,便于用确定性单测锁定行为。 + +### 渲染时用「展开覆盖」,不改写用户的展开状态 + +Windows 把 `expandedPaths` 当作渲染期的覆盖集合,而不是改持久状态。Linux +在 `render_explorer` 里同样只在计算可见行时对克隆出来的树应用覆盖 +(`apply_expanded_override`),并保留工作区根节点的展开态。搜索清空后, +文件树恢复用户原本的展开状态,不会因为搜索过就"到处都展开了"。 + +### 导航游标放在视图状态里 + +`SidebarView` 新增 `tree_search_hits` 与 `tree_search_match_index`:输入变化时 +`recompute_tree_search` 重算命中并把首个命中设为当前选中;回车时 +`navigate_tree_search` 按环形下标前进或后退。这样键盘导航不需要重新搜索。 + +## 考虑过的备选方案 + +### 备选方案一:只把过滤范围从 `visible_items` 改成整棵树 + +改动最小,能解决「搜不到未展开文件」。但它仍然不匹配路径、不自动展开、 +不能跳转,Linux 与 Windows 仍是两套能力。用户要求「具备同样的搜索能力」, +因此不采用。 + +### 备选方案二:接入 Windows 使用的 fff(模糊文件查找)后端 + +Windows 桌面端用 `useFffSearch` 调用原生 fff 后端做模糊匹配,WSL 根路径才 +退回 `collectFileTreeSearchHits` 子串匹配。Linux 没有 fff 后端,且当前 +Lithe Linux 的快速打开也使用子串匹配;为了不引入第二套匹配语义和额外 +依赖,Linux 选择与 Windows 的 WSL 回退路径一致:对完整树做 `"name path"` +子串匹配。ceiling 是:Linux 不做模糊排序,命中顺序是树的深度优先顺序。 + +### 备选方案三:把搜索做成独立的 Search 标签页 + +Linux 侧边栏已有一个「搜索」标签页(对应全局内容搜索)。但标题栏的放大镜 +按钮在 Windows 里是「在文件树中定位文件」,与内容搜索是两回事。合并两者 +会让按钮语义漂移,因此不采用。 + +## 后果 + +- 收益:同一个关键词,Linux 与 Windows 能搜到的文件集合一致(Linux 不做 + 模糊排序,这是唯一已知差异);深层未展开文件也能被搜到。 +- 收益:命中项所在目录自动展开,回车 / Shift+回车 可直接在命中间跳转。 +- 代价:搜索会遍历整棵文件树,而不是只遍历已展开节点。文件多时有额外开销, + `TREE_SEARCH_RESULT_LIMIT` 用来限制命中数量;后续如需可按下发的 + `TREE_SEARCH_DEBOUNCE_DELAY_MS` 接入防抖。 +- 代价:命中高亮只做整行选中,不像 Windows 那样对匹配字符做局部高亮。 + +## 验证 + +- `cargo test --manifest-path linux/Cargo.toml -p lithe-linux --lib` 中 + `workbench::tree_search` 的 8 项纯逻辑测试覆盖:深度优先全树遍历、空格查询 + 返回空、路径片段命中、大小写不敏感、命中上限、命中项与祖先保留、命中目录 + 不因自身命中而强制展开、无命中返回空结果。 +- `cargo build --manifest-path linux/Cargo.toml -p lithe-linux` 通过。 +- 手工验证:在 Explorer 点放大镜输入关键词,确认深层未展开文件出现且目录 + 自动展开;回车 / Shift+回车 在当前选中项前后跳转;Esc 或清除按钮后文件树 + 恢复原展开状态。 + +## 适用范围 + +- `linux/src/workbench/tree_search.rs`:文件树搜索纯逻辑与常量。 +- `linux/src/workbench/sidebar.rs`:搜索按钮、输入、命中重算、导航与渲染期 + 展开覆盖。 +- 不适用于 Windows 产品;Windows 侧真源是 + `windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx` + 与 `windows/tauri/src/features/file-explorer/lib/visible-file-tree-rows.ts`。 +- macOS 的项目侧边栏当前没有这个搜索按钮,因此本决策不涉及 macOS。 diff --git a/linux/src/workbench/mod.rs b/linux/src/workbench/mod.rs index 9e99d2048..d1f4430b9 100644 --- a/linux/src/workbench/mod.rs +++ b/linux/src/workbench/mod.rs @@ -21,6 +21,7 @@ pub mod status_bar; pub mod tab_menu; pub mod terminal; pub mod toolbar; +pub mod tree_search; pub mod view; pub mod welcome_screen; diff --git a/linux/src/workbench/sidebar.rs b/linux/src/workbench/sidebar.rs index 8d7d8d7be..6012a8e01 100644 --- a/linux/src/workbench/sidebar.rs +++ b/linux/src/workbench/sidebar.rs @@ -136,10 +136,14 @@ pub struct SidebarView { pub selected_path: Option, pub is_loading: bool, pub error_message: Option, - /// 文件树过滤串(对齐 Tauri `treeSearchQuery`,仅按文件名过滤) + /// 文件树过滤串(对齐 Tauri `treeSearchQuery`,匹配名称与路径) pub tree_filter: String, /// 是否展开文件树过滤输入(对齐 Tauri `SidebarSearchPopover` 的打开态) pub show_tree_filter: bool, + /// 过滤命中路径(深度优先顺序,对齐 `orderedMatchedPaths`) + tree_search_hits: Vec, + /// 回车跳转游标:当前聚焦的命中下标 + tree_search_match_index: usize, // 搜索状态 #[allow(dead_code)] pub search_query: String, @@ -167,6 +171,8 @@ impl SidebarView { error_message: None, tree_filter: String::new(), show_tree_filter: false, + tree_search_hits: Vec::new(), + tree_search_match_index: 0, search_query: String::new(), search_results: Vec::new(), is_searching: false, @@ -217,6 +223,8 @@ impl SidebarView { let _ = this.update(cx, |sidebar, cx| { sidebar.root_node = node; sidebar.is_loading = false; + // 快照刷新后重算搜索命中,避免旧命中路径与新树不一致。 + sidebar.recompute_tree_search(); cx.notify(); }); } @@ -381,6 +389,63 @@ impl SidebarView { } false } + + /// 重算文件树搜索命中(对齐 Windows `collectFileTreeSearchHits`)。 + /// + /// 空查询清空命中;非空时在完整递归快照上做 `"name path"` 子串匹配, + /// 并重置回车跳转游标。 + pub fn recompute_tree_search(&mut self) { + self.tree_search_match_index = 0; + self.tree_search_hits = match &self.root_node { + Some(root) if !self.tree_filter.trim().is_empty() => super::tree_search::collect_hits( + root, + &self.tree_filter, + super::tree_search::TREE_SEARCH_RESULT_LIMIT, + ), + _ => Vec::new(), + }; + // 对齐 Windows:有命中时自动聚焦首个命中,便于高亮与直接回车打开。 + if let Some(first) = self.tree_search_hits.first() { + self.selected_path = Some(first.clone()); + } + } + + /// 按覆盖集合强制设置目录展开态(对齐 Windows `expandedPathsOverride`)。 + /// + /// 目录是否展开完全由 `expanded` 决定;文件节点保留原状。`is_root` 为真时 + /// 保留工作区根节点原展开态(根不代表可折叠层级)。 + fn apply_expanded_override( + node: &mut FileEntry, + expanded: &std::collections::HashSet, + is_root: bool, + ) { + if node.is_directory && !is_root { + node.is_expanded = expanded.contains(&node.path); + } + if let Some(children) = &mut node.children { + for child in children { + Self::apply_expanded_override(child, expanded, false); + } + } + } + + /// 回车/Shift+回车 在命中间跳转(对齐 `navigateTreeSearchMatch`)。 + /// + /// 正向从当前游标向下一个命中推进(到尾部回到首个),反向则相反; + /// 跳转即将目标命中选中,供文件树高亮显示。 + fn navigate_tree_search(&mut self, forward: bool, cx: &mut Context) { + if self.tree_search_hits.is_empty() { + return; + } + let count = self.tree_search_hits.len(); + self.tree_search_match_index = if forward { + (self.tree_search_match_index + 1) % count + } else { + (self.tree_search_match_index + count - 1) % count + }; + self.selected_path = Some(self.tree_search_hits[self.tree_search_match_index].clone()); + cx.notify(); + } } fn is_code_file(name: &str) -> bool { @@ -431,7 +496,7 @@ impl Render for SidebarView { .border_color(ThemeColors::border()) .track_focus(&self.focus_handle) .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { - // 文件树过滤输入:字符追加、退格删除、Esc 清空并收起。 + // 文件树过滤输入:字符追加、退格删除、回车跳转命中、Esc 清空并收起。 if !this.show_tree_filter { return; } @@ -440,13 +505,18 @@ impl Render for SidebarView { "escape" => { this.tree_filter.clear(); this.show_tree_filter = false; + this.recompute_tree_search(); cx.notify(); } "backspace" => { this.tree_filter.pop(); + this.recompute_tree_search(); cx.notify(); } - "enter" => {} + "enter" => { + // 对齐 Tauri:回车跳下一个命中,Shift+回车跳上一个。 + this.navigate_tree_search(!event.keystroke.modifiers.shift, cx); + } _ => { if !event.keystroke.modifiers.control && !event.keystroke.modifiers.alt @@ -461,6 +531,7 @@ impl Render for SidebarView { changed = true; } if changed { + this.recompute_tree_search(); cx.notify(); } } @@ -507,6 +578,7 @@ impl Render for SidebarView { window.focus(&this.focus_handle, cx); } else { this.tree_filter.clear(); + this.recompute_tree_search(); } cx.notify(); })), @@ -800,6 +872,7 @@ impl SidebarView { .on_click(cx.listener(|this, _event, _window, cx| { this.tree_filter.clear(); this.show_tree_filter = false; + this.recompute_tree_search(); cx.notify(); })), ) @@ -823,6 +896,16 @@ impl SidebarView { node.remove_hidden(); } node.sort_recursive(folders_first); + // 搜索生效时用命中结果覆盖展开态(对齐 Windows `expandedPathsOverride`): + // 仅保留命中子树,并强制展开命中项的祖先目录。 + if !query.is_empty() { + let result = super::tree_search::filter_for_hits(&node, &self.tree_search_hits); + // 将命中子树归到同一根节点下后应用展开覆盖。 + node.children = Some(result.children); + let expanded: std::collections::HashSet = + result.expanded_paths.into_iter().collect(); + Self::apply_expanded_override(&mut node, &expanded, true); + } if hide_root && node.is_directory { // 隐藏根目录:从子节点开始按深度 0 收集。 if let Some(children) = &node.children { @@ -834,23 +917,6 @@ impl SidebarView { node.collect_visible(0, &mut visible_items); } } - // 文本过滤:保留名称命中项及其祖先目录。 - if !query.is_empty() { - let mut keep: std::collections::HashSet = std::collections::HashSet::new(); - for item in &visible_items { - if item.name.to_lowercase().contains(&query) { - let mut path = item.path.clone(); - loop { - keep.insert(path.clone()); - match path.rfind('/') { - Some(idx) => path.truncate(idx), - None => break, - } - } - } - } - visible_items.retain(|item| keep.contains(&item.path)); - } v_flex() .size_full() diff --git a/linux/src/workbench/tree_search.rs b/linux/src/workbench/tree_search.rs new file mode 100644 index 000000000..a70deaa0c --- /dev/null +++ b/linux/src/workbench/tree_search.rs @@ -0,0 +1,266 @@ +//! Explorer 文件树搜索的纯逻辑层:常量、全树命中收集与命中子树过滤。 +//! +//! 真源对应关系(1:1 复刻 Windows,不臆造语义): +//! - `FILE_TREE_SEARCH_RESULT_LIMIT` / `FILE_TREE_SEARCH_DEBOUNCE_DELAY` 抄自 +//! `windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx`。 +//! - 命中收集抄自 `windows/tauri/src/features/file-explorer/lib/visible-file-tree-rows.ts` +//! 的 `collectFileTreeSearchHits`(`"${name} ${path}".toLowerCase()` 子串匹配)。 +//! - 命中子树过滤抄自同文件的 `filterFileTreeForFffHits`:命中项连同其祖先目录 +//! 保留,祖先目录标记为展开,匹配顺序保持树的深度优先顺序。 +//! +//! 这里不触碰 gpui 与 IO,便于用确定性单测锁定行为。 + +use super::sidebar::FileEntry; + +/// 文件树搜索结果上限(`FILE_TREE_SEARCH_RESULT_LIMIT`)。 +pub const TREE_SEARCH_RESULT_LIMIT: usize = 500; +/// 文件树搜索输入防抖延迟(`FILE_TREE_SEARCH_DEBOUNCE_DELAY`,毫秒)。 +/// +/// Linux 侧当前在按键时同步重算,保留常量用于对齐语义与后续接入防抖。 +#[allow(dead_code)] +pub const TREE_SEARCH_DEBOUNCE_DELAY_MS: u64 = 80; + +/// 收集全树命中路径(对齐 `collectFileTreeSearchHits`)。 +/// +/// 遍历完整递归树(不限于已展开节点),对每个节点的 `"name path"` 做 +/// 大小写不敏感子串匹配;命中顺序即树的深度优先顺序,达到 `limit` 即停止。 +pub fn collect_hits(root: &FileEntry, query: &str, limit: usize) -> Vec { + let normalized = query.trim().to_lowercase(); + if normalized.is_empty() || limit == 0 { + return Vec::new(); + } + + let mut hits = Vec::new(); + collect_hits_inner(root, &normalized, limit, &mut hits); + hits +} + +fn collect_hits_inner(node: &FileEntry, query: &str, limit: usize, hits: &mut Vec) { + if hits.len() >= limit { + return; + } + + // `searchableText` 同时包含名称与路径:Windows 用空格连接,便于路径片段命中。 + let searchable = format!("{} {}", node.name, node.path).to_lowercase(); + if searchable.contains(query) { + hits.push(node.path.clone()); + } + + if let Some(children) = &node.children { + for child in children { + if hits.len() >= limit { + return; + } + collect_hits_inner(child, query, limit, hits); + } + } +} + +/// 命中子树的过滤结果。 +#[derive(Debug, Clone, Default)] +pub struct TreeSearchResult { + /// 命中项及其祖先目录组成的新树;`root` 本身不计入(由调用方拼接)。 + pub children: Vec, + /// 命中项自身的路径集合(用于回车跳转与高亮)。 + pub matched_paths: Vec, + /// 需要展开的目录路径集合(命中项的祖先目录)。 + pub expanded_paths: Vec, + /// 命中项数量(`matched_paths.len()`,与 Windows `matchCount` 对齐)。 + pub match_count: usize, +} + +/// 按命中路径过滤子树(对齐 `filterFileTreeForFffHits`)。 +/// +/// 保留规则:命中项及其祖先目录;命中项或其子树含命中时保留,其余节点丢弃。 +/// 含命中子节点的目录加入 `expanded_paths`(渲染时强制展开),命中项自身 +/// 保持原始展开态;命中项若无命中子节点则保留其原始 `children`,与 Windows +/// `children: matchingChildren.length > 0 ? matchingChildren : item.children` 一致。 +pub fn filter_for_hits(root: &FileEntry, hit_paths: &[String]) -> TreeSearchResult { + let mut result = TreeSearchResult::default(); + if hit_paths.is_empty() { + return result; + } + + let hit_set: std::collections::HashSet<&str> = hit_paths.iter().map(|p| p.as_str()).collect(); + let children = root.children.as_deref().unwrap_or(&[]); + for child in children { + if let Some(filtered) = filter_node(child, &hit_set, &mut result) { + result.children.push(filtered); + } + } + result.match_count = result.matched_paths.len(); + result +} + +/// 递归过滤单个节点:命中或子树含命中时返回保留节点,否则返回 `None`。 +fn filter_node( + node: &FileEntry, + hit_set: &std::collections::HashSet<&str>, + result: &mut TreeSearchResult, +) -> Option { + let mut kept_children = Vec::new(); + if let Some(children) = &node.children { + for child in children { + if let Some(filtered) = filter_node(child, hit_set, result) { + kept_children.push(filtered); + } + } + } + + let is_match = hit_set.contains(node.path.as_str()); + if !is_match && kept_children.is_empty() { + return None; + } + + if is_match { + result.matched_paths.push(node.path.clone()); + } + if node.is_directory && !kept_children.is_empty() { + result.expanded_paths.push(node.path.clone()); + } + + Some(FileEntry { + path: node.path.clone(), + name: node.name.clone(), + is_directory: node.is_directory, + size: node.size, + children: if kept_children.is_empty() { + node.children.clone() + } else { + Some(kept_children) + }, + is_expanded: node.is_expanded, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn file(path: &str, name: &str) -> FileEntry { + FileEntry { + path: path.to_string(), + name: name.to_string(), + is_directory: false, + size: None, + children: None, + is_expanded: false, + } + } + + fn dir(path: &str, name: &str, children: Vec) -> FileEntry { + FileEntry { + path: path.to_string(), + name: name.to_string(), + is_directory: true, + size: None, + children: Some(children), + is_expanded: true, + } + } + + fn sample_root() -> FileEntry { + dir( + "/w", + "w", + vec![ + dir( + "/w/src", + "src", + vec![ + file("/w/src/main.rs", "main.rs"), + file("/w/src/lib.rs", "lib.rs"), + ], + ), + dir( + "/w/docs", + "docs", + vec![file("/w/docs/readme.md", "readme.md")], + ), + file("/w/Cargo.toml", "Cargo.toml"), + ], + ) + } + + /// 命中收集应遍历未展开/嵌套节点,并按深度优先顺序返回。 + #[test] + fn collect_hits_walks_full_tree_in_depth_first_order() { + let hits = collect_hits(&sample_root(), "rs", TREE_SEARCH_RESULT_LIMIT); + assert_eq!( + hits, + vec!["/w/src/main.rs".to_string(), "/w/src/lib.rs".to_string()] + ); + } + + /// 空查询返回空结果(不展开全树)。 + #[test] + fn collect_hits_returns_empty_for_blank_query() { + assert!(collect_hits(&sample_root(), " ", TREE_SEARCH_RESULT_LIMIT).is_empty()); + assert!(collect_hits(&sample_root(), "", TREE_SEARCH_RESULT_LIMIT).is_empty()); + } + + /// 匹配串同时包含名称与路径,路径片段应能命中。 + #[test] + fn collect_hits_matches_path_segments_too() { + let hits = collect_hits(&sample_root(), "docs", TREE_SEARCH_RESULT_LIMIT); + assert_eq!( + hits, + vec!["/w/docs".to_string(), "/w/docs/readme.md".to_string()] + ); + } + + /// 大小写不敏感匹配(对齐 Windows 的 `toLowerCase`)。 + #[test] + fn collect_hits_is_case_insensitive() { + let hits = collect_hits(&sample_root(), "MAIN", TREE_SEARCH_RESULT_LIMIT); + assert_eq!(hits, vec!["/w/src/main.rs".to_string()]); + } + + /// 命中上限生效,超出后停止收集。 + #[test] + fn collect_hits_respects_limit() { + let hits = collect_hits(&sample_root(), "/w", 2); + assert_eq!(hits.len(), 2); + } + + /// 过滤应保留命中项及其祖先目录,并标记祖先展开、记录命中路径。 + #[test] + fn filter_keeps_hit_and_ancestors() { + let hits = vec!["/w/src/main.rs".to_string()]; + let result = filter_for_hits(&sample_root(), &hits); + + assert_eq!(result.children.len(), 1); + let src = &result.children[0]; + assert_eq!(src.path, "/w/src"); + assert_eq!(src.children.as_ref().unwrap().len(), 1); + assert_eq!(src.children.as_ref().unwrap()[0].path, "/w/src/main.rs"); + + assert_eq!(result.matched_paths, vec!["/w/src/main.rs".to_string()]); + assert_eq!(result.expanded_paths, vec!["/w/src".to_string()]); + } + + /// 命中目录自身时记录命中,但不因命中而强制展开(对齐 Windows: + /// 仅含命中子节点的目录才进 `expandedPaths`)。 + #[test] + fn filter_matched_directory_keeps_original_children() { + let hits = vec!["/w/docs".to_string()]; + let result = filter_for_hits(&sample_root(), &hits); + + assert_eq!(result.children.len(), 1); + assert_eq!(result.children[0].path, "/w/docs"); + assert!(result.expanded_paths.is_empty()); + // 命中目录无命中子节点,保留原始 children(与 Windows 一致)。 + assert_eq!(result.children[0].children.as_ref().unwrap().len(), 1); + assert_eq!(result.matched_paths, vec!["/w/docs".to_string()]); + assert_eq!(result.match_count, 1); + } + + /// 无命中时返回空结果,不保留任何节点。 + #[test] + fn filter_returns_empty_without_hits() { + let result = filter_for_hits(&sample_root(), &[]); + assert!(result.children.is_empty()); + assert!(result.matched_paths.is_empty()); + assert!(result.expanded_paths.is_empty()); + } +} From eb63245340c7a1b7f05eab27d7b540ad6541c6b5 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 19:01:59 +0800 Subject: [PATCH 063/109] =?UTF-8?q?fix(linux):=20=E4=BE=A7=E8=BE=B9?= =?UTF-8?q?=E6=A0=8F=E6=96=87=E4=BB=B6=E6=A0=91=E6=90=9C=E7=B4=A2=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E6=94=AF=E6=8C=81=E5=89=AA=E8=B4=B4=E6=9D=BF=E7=B2=98?= =?UTF-8?q?=E8=B4=B4=E4=B8=8E=E4=B8=AD=E6=96=87=E8=BE=93=E5=85=A5=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象:Explorer 标题栏放大镜打开的文件树过滤行里,Ctrl+V 粘不进去, 用中文输入法(XIM/ibus)也打不出汉字。 原因有两个: 1. 过滤行只是一个普通 `div`,键盘输入靠根节点 `on_key_down` 手动拼接 `event.keystroke.key_char`。这种自绘输入没有注册 GPUI 的 input handler, 平台的 IME 提交/预编辑事件(`handle_ime_commit` / `handle_ime_preedit` 最终调用 `replace(_and_mark)_text_in_range`)无处可去,中文字符丢失; 剪贴板粘贴也没有处理。 2. 即使换成正真的输入框,`WorkbenchView` 根节点的 `on_key_down` 仍会先 命中菜单派生的全局快捷键表,Ctrl+V/Ctrl+C/Ctrl+A 被派给编辑器 (`edit.paste` / `edit.copy` / `edit.select_all`),而不是聚焦中的 输入框。 修复: - `sidebar.rs`:过滤行改用 gpui-kit 的真实 `Input`/`InputState` 组件。 组件自带 IME 组字、Ctrl+V 粘贴、选区、光标与 `InputEvent::PressEnter` (含 shift 标志)。`InputEvent::Change` 同步 `tree_filter` 并重算命中; `PressEnter` 用于命中跳转。打开过滤行时聚焦输入框,Esc/按钮/清除按钮 统一走 `close_tree_search`(清空值并收起)。 - `view.rs`:文本输入框聚焦时,`Input` 组件渲染的 key context 为 `"Input"`;`WorkbenchView` 的 `on_key_down` 在 `context_stack()` 命中该 context 时直接返回,不再抢占 Ctrl+C/V/A 等快捷键,也不再干扰 IME 组字。 这与已有的 `modal_visible()` 早退语义一致。 验证: - `cargo build --manifest-path linux/Cargo.toml -p lithe-linux` 通过,无警告。 - `cargo test --manifest-path linux/Cargo.toml -p lithe-linux --lib`:84 通过。 - 手工验证待做:过滤行内 Ctrl+V 粘贴、中文输入法组字与上屏、回车跳转。 --- linux/src/workbench/sidebar.rs | 131 ++++++++++++++++----------------- linux/src/workbench/view.rs | 14 +++- 2 files changed, 77 insertions(+), 68 deletions(-) diff --git a/linux/src/workbench/sidebar.rs b/linux/src/workbench/sidebar.rs index 6012a8e01..346010bff 100644 --- a/linux/src/workbench/sidebar.rs +++ b/linux/src/workbench/sidebar.rs @@ -1,12 +1,14 @@ use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::input::{Input, InputEvent, InputState}; use gpui_kit::component::menu::{DropdownMenu as _, PopupMenuItem}; use gpui_kit::component::scroll::ScrollableElement as _; use gpui_kit::component::{h_flex, v_flex, Icon, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, Context, EventEmitter, FocusHandle, FontWeight, InteractiveElement as _, IntoElement, - KeyDownEvent, ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, Window, + div, px, AppContext as _, Context, Entity, EventEmitter, FocusHandle, FontWeight, + InteractiveElement as _, IntoElement, KeyDownEvent, ParentElement as _, Render, + StatefulInteractiveElement as _, Styled as _, Subscription, Window, }; use serde::{Deserialize, Serialize}; @@ -144,6 +146,9 @@ pub struct SidebarView { tree_search_hits: Vec, /// 回车跳转游标:当前聚焦的命中下标 tree_search_match_index: usize, + /// 文件树过滤输入框(真实 `Input`,负责 IME 组字与剪贴板粘贴) + tree_search_input: Entity, + _tree_search_subscription: Subscription, // 搜索状态 #[allow(dead_code)] pub search_query: String, @@ -161,7 +166,29 @@ pub struct SidebarView { impl EventEmitter for SidebarView {} impl SidebarView { - pub fn new(root_path: String, cx: &mut Context) -> Self { + pub fn new(root_path: String, window: &mut Window, cx: &mut Context) -> Self { + // 过滤输入用真实 `Input` 组件:本地 IME(XIM/ibus)组字、Ctrl+V 粘贴、 + // 选区与光标均由组件处理,不再靠 `on_key_down` 手动拼接 `key_char`。 + let tree_search_input = cx.new(|cx| { + InputState::new(window, cx).placeholder(crate::i18n::menu_text(cx, "search.search")) + }); + let input_source = tree_search_input.clone(); + let _tree_search_subscription = cx.subscribe( + &tree_search_input, + move |this: &mut Self, _, event: &InputEvent, cx| match event { + InputEvent::Change => { + this.tree_filter = input_source.read(cx).value().to_string(); + this.recompute_tree_search(); + cx.notify(); + } + InputEvent::PressEnter { shift, .. } => { + // 对齐 Tauri:回车跳下一个命中,Shift+回车跳上一个。 + this.navigate_tree_search(!shift, cx); + } + _ => {} + }, + ); + let mut view = Self { root_path, active_tab: SidebarTab::Explorer, @@ -173,6 +200,8 @@ impl SidebarView { show_tree_filter: false, tree_search_hits: Vec::new(), tree_search_match_index: 0, + tree_search_input, + _tree_search_subscription, search_query: String::new(), search_results: Vec::new(), is_searching: false, @@ -484,7 +513,7 @@ impl Render for SidebarView { }; // 过滤行在链式构建前算好,避免在 `.when` 闭包里同时借用 self 与 cx。 let filter_row = if self.show_tree_filter { - Some(Self::render_filter_row(&self.tree_filter, cx)) + Some(self.render_filter_row(cx)) } else { None }; @@ -495,47 +524,11 @@ impl Render for SidebarView { .border_r_1() .border_color(ThemeColors::border()) .track_focus(&self.focus_handle) - .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { - // 文件树过滤输入:字符追加、退格删除、回车跳转命中、Esc 清空并收起。 - if !this.show_tree_filter { - return; - } - let key = event.keystroke.key.as_str(); - match key { - "escape" => { - this.tree_filter.clear(); - this.show_tree_filter = false; - this.recompute_tree_search(); - cx.notify(); - } - "backspace" => { - this.tree_filter.pop(); - this.recompute_tree_search(); - cx.notify(); - } - "enter" => { - // 对齐 Tauri:回车跳下一个命中,Shift+回车跳上一个。 - this.navigate_tree_search(!event.keystroke.modifiers.shift, cx); - } - _ => { - if !event.keystroke.modifiers.control - && !event.keystroke.modifiers.alt - && !event.keystroke.modifiers.platform - { - let mut changed = false; - if let Some(ch) = &event.keystroke.key_char { - this.tree_filter.push_str(ch); - changed = true; - } else if key.chars().count() == 1 { - this.tree_filter.push_str(key); - changed = true; - } - if changed { - this.recompute_tree_search(); - cx.notify(); - } - } - } + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + // 字符输入 / 退格 / 回车由内层 `Input` 处理(含 IME 与粘贴); + // 这里只接管 Esc:清空并收起过滤行。 + if this.show_tree_filter && event.keystroke.key.as_str() == "escape" { + this.close_tree_search(window, cx); } })) .child( @@ -573,14 +566,17 @@ impl Render for SidebarView { "fileExplorer.searchFiles", )) .on_click(cx.listener(|this, _event, window, cx| { - this.show_tree_filter = !this.show_tree_filter; if this.show_tree_filter { - window.focus(&this.focus_handle, cx); + this.close_tree_search(window, cx); } else { - this.tree_filter.clear(); - this.recompute_tree_search(); + this.show_tree_filter = true; + // 真实输入框接管键盘:打开时聚焦它。 + this.tree_search_input + .update(cx, |state, cx| { + state.focus(window, cx) + }); + cx.notify(); } - cx.notify(); })), ) .child(Self::render_explorer_prefs(cx)) @@ -830,8 +826,20 @@ impl SidebarView { }) } - /// 文件树过滤输入行:展示当前过滤串,键盘输入由根节点 `on_key_down` 处理。 - fn render_filter_row(filter: &str, cx: &mut Context) -> impl IntoElement { + /// 收起过滤行并清空查询(Esc / 按钮 / 清除按钮共用)。 + fn close_tree_search(&mut self, window: &mut Window, cx: &mut Context) { + self.tree_filter.clear(); + self.show_tree_filter = false; + self.tree_search_hits.clear(); + self.tree_search_match_index = 0; + self.tree_search_input + .update(cx, |state, cx| state.set_value("", window, cx)); + cx.notify(); + } + + /// 文件树过滤输入行:真实 `Input`,由组件负责 IME 组字、Ctrl+V 粘贴、 + /// 选区、光标与回车提交(`InputEvent::PressEnter`)。 + fn render_filter_row(&self, cx: &mut Context) -> impl IntoElement { h_flex() .h(px(28.0)) .w_full() @@ -851,17 +859,9 @@ impl SidebarView { .child( div() .flex_1() + .min_w_0() .text_xs() - .text_color(if filter.is_empty() { - ThemeColors::text_muted() - } else { - ThemeColors::text_primary() - }) - .child(if filter.is_empty() { - crate::i18n::menu_text(cx, "search.search").to_string() - } else { - filter.to_string() - }), + .child(Input::new(&self.tree_search_input)), ) .child( Button::new("sidebar-filter-clear") @@ -869,11 +869,8 @@ impl SidebarView { .ghost() .icon(IconName::Close) .tooltip(crate::i18n::menu_text(cx, "search.clear")) - .on_click(cx.listener(|this, _event, _window, cx| { - this.tree_filter.clear(); - this.show_tree_filter = false; - this.recompute_tree_search(); - cx.notify(); + .on_click(cx.listener(|this, _event, window, cx| { + this.close_tree_search(window, cx); })), ) } diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index fe688c8e7..093e9e52a 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -206,7 +206,7 @@ impl WorkbenchView { let toolbar = cx.new(|_cx| ToolbarView::new(&root)); let activity_rail = cx.new(|cx| ActivityRailView::new(cx)); let plugin_rail = cx.new(|_cx| PluginActivityRailView::new()); - let sidebar = cx.new(|cx| SidebarView::new(root.clone(), cx)); + let sidebar = cx.new(|cx| SidebarView::new(root.clone(), window, cx)); let pane_tree = PaneTree::new(); let initial_pane = pane_tree.active().unwrap_or(0); let editor = cx.new(|cx| EditorView::new(root.clone(), window, cx)); @@ -2640,6 +2640,18 @@ impl Render for WorkbenchView { return; } + // 文本输入框(如侧边栏文件树过滤、输入型控件)聚焦时,全局快捷键 + // 不得抢占按键:否则 Ctrl+V/Ctrl+C/Ctrl+A 会被派给编辑器而不是 + // 输入框,中文 IME 组字也会受干扰。`Input` 组件渲染时设置 + // key context "Input",这里据此识别。 + if window + .context_stack() + .iter() + .any(|context| context.contains("Input")) + { + return; + } + // 其它按键按下:本次 Shift 按压不再算「独立」,双击手势取消。 // 注意 Linux 后端不为修饰键本身发 `KeyDownEvent`(X11 侧 // `keysym.is_modifier_key()` 直接返回),因此这里只会收到 From c0d2ac6a20d5423ce17ee5c64e7b03047558366a Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 19:07:15 +0800 Subject: [PATCH 064/109] =?UTF-8?q?chore(linux):=20XIM=20=E8=AF=8A?= =?UTF-8?q?=E6=96=AD=E6=97=A5=E5=BF=97=E4=B8=8E=E6=96=87=E4=BB=B6=E6=A0=91?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=E8=BE=93=E5=85=A5=E6=A0=B7=E5=BC=8F=E5=BE=AE?= =?UTF-8?q?=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main.rs: 临时加 tracing 日志(gpui/gpui_pre_linux/xim 定向 debug),用于定位 XIM/IME 问题。 - sidebar.rs: 文件树过滤输入改用 Input::small().appearance(false),去掉组件自带背景/边框/焦点环,避免与紧凑行样式叠加出双边框或阴影。 - Cargo.lock: 同步新增的 tracing / tracing-subscriber 依赖。 --- linux/Cargo.lock | 15 +++++++++++++++ linux/src/main.rs | 10 ++++++++++ linux/src/workbench/sidebar.rs | 6 ++++-- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/linux/Cargo.lock b/linux/Cargo.lock index 79c3f8662..18b819753 100644 --- a/linux/Cargo.lock +++ b/linux/Cargo.lock @@ -3968,6 +3968,8 @@ dependencies = [ "serde", "serde_json", "tokio", + "tracing", + "tracing-subscriber", "uuid", ] @@ -4167,6 +4169,15 @@ dependencies = [ "xml5ever", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -7715,10 +7726,14 @@ version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", ] diff --git a/linux/src/main.rs b/linux/src/main.rs index ea953fb16..49e56379c 100644 --- a/linux/src/main.rs +++ b/linux/src/main.rs @@ -13,6 +13,16 @@ use gpui_kit::*; use workbench::WorkbenchView; fn main() { + // TEMP_DIAG: 临时日志,用于定位 XIM/IME 问题。 + tracing_subscriber::fmt() + .with_max_level(tracing::Level::DEBUG) + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive("gpui=debug".parse().unwrap()) + .add_directive("gpui_pre_linux=debug".parse().unwrap()) + .add_directive("xim=debug".parse().unwrap()), + ) + .init(); // 工作台使用大量 Lucide 图标,默认 `Assets` 仅内嵌 101 个,需用全量目录。 let app = gpui_kit::application().with_assets(gpui_kit::assets::AllAssets); diff --git a/linux/src/workbench/sidebar.rs b/linux/src/workbench/sidebar.rs index 346010bff..2c5d91aa8 100644 --- a/linux/src/workbench/sidebar.rs +++ b/linux/src/workbench/sidebar.rs @@ -839,6 +839,9 @@ impl SidebarView { /// 文件树过滤输入行:真实 `Input`,由组件负责 IME 组字、Ctrl+V 粘贴、 /// 选区、光标与回车提交(`InputEvent::PressEnter`)。 + /// + /// `Input` 用 `appearance(false)` 关掉自带的背景/边框/焦点环,保留外层 + /// 行现有的紧凑样式,避免多出一层阴影或双层边框。 fn render_filter_row(&self, cx: &mut Context) -> impl IntoElement { h_flex() .h(px(28.0)) @@ -860,8 +863,7 @@ impl SidebarView { div() .flex_1() .min_w_0() - .text_xs() - .child(Input::new(&self.tree_search_input)), + .child(Input::new(&self.tree_search_input).small().appearance(false)), ) .child( Button::new("sidebar-filter-clear") From c0c0505a0505bdea4733963bbd4f988e32d8ce74 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 19:07:25 +0800 Subject: [PATCH 065/109] =?UTF-8?q?refactor(linux):=20=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=20Linux=20=E4=B8=93=E7=94=A8=E5=B9=B3=E5=8F=B0=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=EF=BC=8C=E6=94=B9=E4=B8=BA=E8=B7=A8=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 目标:让 linux/(GPUI 工作台)能编译到 Windows,同时保持 Linux 行为不变。 逐项说明改动点: 进程信号(process.rs) - 新增平台无关枚举 ProcessSignal { Terminate, Kill },替换全部 13 处裸 libc::SIGTERM/libc::SIGKILL,调用侧不再直接依赖 libc 常量。 - send_signal 按平台分派:Unix 用 kill(-pgid, SIGTERM/SIGKILL) 覆盖整进程组; Windows 无 POSIX 进程组信号,改用 taskkill /PID /T /F 终止该 pid 及其派生进程树(隐藏控制台窗口)。“先温和、后强制、有界等待”的语义契约不变。 - configure_process_group 增加 Windows 分支:CREATE_NEW_PROCESS_GROUP, 使子进程成为新进程组组长,配合 taskkill /T 能清理派生进程。 - 依赖声明:libc 移入 [target.'cfg(unix)'.dependencies],Windows 构建不引入。 - process.rs 测试模块标记为 #[cfg(all(test, unix))]:这些测试用 /bin/sh 驱动 真实子进程并校验 Unix 进程组信号与 bin/ 路径语义,Windows 不适用。 可执行文件解析(process.rs) - 新增 binary_file_names(tool):在 Windows 上按 .exe/.cmd/.bat 补全候选, Unix 只返回原名;合并并替换原 command_file_names 与硬编码 node/node.exe 列表。 - jdk_tool 走 binary_file_names,Windows 上正确解析 JDK 的 bin/java.exe 等。 - resolve_maven 支持 bin/mvn(.cmd) 与 Windows 的 mvnw.cmd wrapper。 - lookup_on_path 内部补全扩展名候选,所有“裸名查 PATH”的调用点一次性受益。 语言服务器(lsp.rs) - linux_configuration_name 改名为 jdtls_configuration_name,按平台返回 config_linux(_arm)/config_win/config_mac,Windows 上不再找错 JDT LS 配置目录。 - 相关错误信息与文档从“Linux 适配器”改为平台中性表述。 - find_in_path 新增 executable_candidates:Windows 按 PATHEXT 补全(java -> java.exe),Unix 保留执行位校验。 终端(terminal.rs) - 默认 shell 抽为 default_shell():Unix 读 $SHELL,Windows 读 COMSPEC。 - resolve_shell 支持 Windows 路径分隔符与 PATHEXT 补全,便于解析 pwsh 等。 - 新增 shell_candidates 复用扩展名补全。 配置/数据目录(settings.rs) - 新增 user_home_dir / config_dir / data_dir 三个统一入口:Unix 遵循 XDG($XDG_CONFIG_HOME、$XDG_DATA_HOME、$HOME),Windows 用 %APPDATA%/%LOCALAPPDATA%/%USERPROFILE%,并兼容 $HOMEDRIVE$HOMEPATH。 - persist 的原子替换在 Windows 上先移除旧文件(Windows rename 不覆盖已存在目标)。 复用统一入口,消除重复(extensions_panel.rs / settings_dialog.rs / project_dialog.rs) - extensions_state_path、default_log_dir 改为复用 settings::config_dir / data_dir, 删除各自重复的 XDG/HOME 计算。 - settings_dialog 的 which 复用 executable_candidates 按 PATHEXT 补全; detect_linux_shells 改名为 detect_available_shells,Windows 返回空列表 (保留“系统默认”项)。 - project_dialog 的项目默认位置改用 settings::user_home_dir(),不再直接读 $HOME。 原子写(run/config.rs) - 同样在 Windows 上先移除目标文件再 rename,避免覆盖失败。 验证:Linux 上 cargo check 通过,cargo test 84 项单元测试全部通过 (doctest 因本机 rustdoc 缺 libLLVM.so 无法运行,与本次改动无关)。 --- linux/Cargo.toml | 7 +- linux/src/lsp.rs | 90 ++++++++--- linux/src/settings.rs | 86 ++++++++-- linux/src/workbench/extensions_panel.rs | 15 +- linux/src/workbench/project_dialog.rs | 13 +- linux/src/workbench/run/config.rs | 5 + linux/src/workbench/run/process.rs | 200 +++++++++++++++++------- linux/src/workbench/settings_dialog.rs | 81 +++++++--- linux/src/workbench/terminal.rs | 68 ++++++-- 9 files changed, 434 insertions(+), 131 deletions(-) diff --git a/linux/Cargo.toml b/linux/Cargo.toml index e6bb28261..b988a5404 100644 --- a/linux/Cargo.toml +++ b/linux/Cargo.toml @@ -26,5 +26,10 @@ serde_json = "1" tokio = { version = "1", features = ["sync", "macros", "rt"] } anyhow = "1" uuid = { version = "1", features = ["v4"] } -libc = "0.2" regex = "1.11" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# `libc` 只用于 Unix 进程组信号;Windows 由 `taskkill` 终止进程树。 +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/linux/src/lsp.rs b/linux/src/lsp.rs index 14f6b5cdb..0312da4c0 100644 --- a/linux/src/lsp.rs +++ b/linux/src/lsp.rs @@ -36,22 +36,39 @@ const JDTLS_DEBUG_PREFIX: &str = "com.microsoft.java.debug.plugin-"; const JDTLS_TEST_LIST: &str = "extensions.txt"; const MAX_BUILD_FILE_DEPTH: usize = 32; -fn linux_configuration_name() -> &'static str { - match std::env::consts::ARCH { - "aarch64" | "arm" => "config_linux_arm", - _ => "config_linux", +/// JDT LS 发行包内与当前平台/架构对应的配置目录名。 +/// +/// 上游 JDT LS 按平台打包不同目录名(`config_linux` / `config_win` / +/// `config_mac`,ARM 变体带 `_arm` 后缀);名字必须与解压出的目录一致, +/// 否则初始化会找不到 bundle。 +fn jdtls_configuration_name() -> &'static str { + #[cfg(all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm")))] + { + "config_linux_arm" + } + #[cfg(all(target_os = "linux", not(any(target_arch = "aarch64", target_arch = "arm"))))] + { + "config_linux" + } + #[cfg(target_os = "windows")] + { + "config_win" + } + #[cfg(target_os = "macos")] + { + "config_mac" } } -/// Linux 解析出的 JDT LS 直启资源。 +/// 当前平台解析出的 JDT LS 直启资源。 /// -/// 这些路径只属于 Linux 适配器;Core 负责把它们转换为 JVM 参数和 JDT 初始化选项。 +/// 这些路径只属于本平台适配器;Core 负责把它们转换为 JVM 参数和 JDT 初始化选项。 #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct JdtlsLaunchResources { /// Equinox launcher JAR。 pub launcher_jar_path: String, - /// Linux Eclipse configuration 目录。 + /// 当前平台的 Eclipse configuration 目录。 pub configuration_directory: String, /// Lombok agent JAR。 pub lombok_agent_path: String, @@ -192,28 +209,55 @@ pub fn language_id_for_path(path: &str, provider: LanguageProvider) -> &'static } /// 在 `PATH` 中查找可执行文件;只返回可执行且存在的绝对路径。 +/// +/// Unix 要求文件带执行位;Windows 按 `PATHEXT`(缺省 ` .COM;.EXE;.BAT; +/// .CMD`)逐个候选名尝试,因为 Windows 上 `java` 实际是 `java.exe`。 pub fn find_in_path(name: &str) -> Option { let path = std::env::var_os("PATH")?; for dir in std::env::split_paths(&path) { - let candidate = dir.join(name); - if !candidate.is_file() { - continue; - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt as _; - let executable = std::fs::metadata(&candidate) - .map(|metadata| metadata.permissions().mode() & 0o111 != 0) - .unwrap_or(false); - if !executable { + for candidate in executable_candidates(&dir, name) { + if !candidate.is_file() { continue; } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let executable = std::fs::metadata(&candidate) + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false); + if !executable { + continue; + } + } + return Some(candidate.to_string_lossy().into_owned()); } - return Some(candidate.to_string_lossy().into_owned()); } None } +/// 列出在 `dir` 下应该尝试的可执行文件名。 +/// +/// Windows 上同名可执行文件通常带扩展名,因此除原名外还要按 `PATHEXT` +/// 补全;已经带扩展名或非 Windows 平台时只尝试原名,避免产生无意义探测。 +fn executable_candidates(dir: &Path, name: &str) -> Vec { + #[cfg(windows)] + { + let mut candidates = vec![dir.join(name)]; + if Path::new(name).extension().is_none() { + let pathext = std::env::var("PATHEXT") + .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()); + for extension in pathext.split(';').filter(|value| !value.is_empty()) { + candidates.push(dir.join(format!("{name}{extension}"))); + } + } + candidates + } + #[cfg(not(windows))] + { + vec![dir.join(name)] + } +} + /// 解析 Linux Java 语言服务器资源。 /// /// 正式运行路径优先使用工作区 `.artifacts` 或环境覆盖;只有正式目录不存在时, @@ -277,10 +321,10 @@ fn resolve_direct_jdtls_resources(root: &Path) -> Result &mut Settings { &mut cx.global_mut::().settings } -/// 解析配置文件路径;优先 `XDG_CONFIG_HOME`,否则 `~/.config`。 +/// 当前用户主目录。 +/// +/// Unix 读 `$HOME`;Windows 读 `$USERPROFILE`,再用 `$HOMEDRIVE$HOMEPATH` +/// 兼容没有 `USERPROFILE` 的会话。主目录是默认项目位置与配置回退的基点。 +pub fn user_home_dir() -> Option { + #[cfg(unix)] + { + std::env::var_os("HOME").map(PathBuf::from) + } + #[cfg(windows)] + { + if let Some(profile) = std::env::var_os("USERPROFILE").filter(|v| !v.is_empty()) { + return Some(PathBuf::from(profile)); + } + let drive = std::env::var_os("HOMEDRIVE")?; + let path = std::env::var_os("HOMEPATH")?; + let mut home = PathBuf::from(drive); + home.push(path); + Some(home) + } +} + +/// 应用配置目录(不含 `lithe` 子目录)。 +/// +/// Unix 遵循 XDG:`$XDG_CONFIG_HOME` 非空即用,否则 `/.config`; +/// Windows 用 `%APPDATA%`(缺失时回退 `\AppData\Roaming`)。 +pub fn config_dir() -> Option { + #[cfg(unix)] + { + std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) + .or_else(|| user_home_dir().map(|home| home.join(".config"))) + } + #[cfg(windows)] + { + std::env::var_os("APPDATA") + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) + .or_else(|| user_home_dir().map(|home| home.join("AppData").join("Roaming"))) + } +} + +/// 应用数据目录(日志等持久数据)。 +/// +/// Unix 遵循 XDG:`$XDG_DATA_HOME` 非空即用,否则 `/.local/share`; +/// Windows 用 `%LOCALAPPDATA%`(缺失时回退 `\AppData\Local`)。 +pub fn data_dir() -> Option { + #[cfg(unix)] + { + std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) + .or_else(|| user_home_dir().map(|home| home.join(".local/share"))) + } + #[cfg(windows)] + { + std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) + .or_else(|| { + user_home_dir().map(|home| home.join("AppData").join("Local")) + }) + } +} + +/// 解析配置文件路径;配置目录与平台规则见 [`config_dir`]。 pub fn config_path() -> Option { - let base = std::env::var_os("XDG_CONFIG_HOME") - .map(PathBuf::from) - .filter(|p| !p.as_os_str().is_empty()) - .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?; - Some(base.join("lithe").join("settings.json")) + Some(config_dir()?.join("lithe").join("settings.json")) } /// 从磁盘加载设置;缺失或损坏时回退到默认值并补齐顺序字段。 @@ -435,6 +497,12 @@ pub fn persist(settings: &Settings) { }; let tmp = path.with_extension("json.tmp"); if std::fs::write(&tmp, text).is_ok() { + // Windows 的 `rename` 在目标存在时会失败,先移除旧文件再落盘; + // 两次操作间目录里可能短暂无配置,但不会有半写内容。 + #[cfg(windows)] + { + let _ = std::fs::remove_file(&path); + } let _ = std::fs::rename(&tmp, &path); } } diff --git a/linux/src/workbench/extensions_panel.rs b/linux/src/workbench/extensions_panel.rs index 8e6cf0065..77f2f93ec 100644 --- a/linux/src/workbench/extensions_panel.rs +++ b/linux/src/workbench/extensions_panel.rs @@ -914,14 +914,10 @@ fn classify_text(text: &str) -> ExtensionCategory { } } -/// 启用状态落盘路径:`~/.config/lithe/extensions.json` -///(`$XDG_CONFIG_HOME` 优先,与 `settings.rs::config_path` 同规则)。 +/// 启用状态落盘路径:`/lithe/extensions.json` +///(配置目录规则见 `settings::config_dir`)。 fn extensions_state_path() -> Option { - let base = std::env::var_os("XDG_CONFIG_HOME") - .map(PathBuf::from) - .filter(|path| !path.as_os_str().is_empty()) - .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?; - Some(base.join("lithe").join("extensions.json")) + Some(crate::settings::config_dir()?.join("lithe").join("extensions.json")) } fn load_enabled() -> HashMap { @@ -949,6 +945,11 @@ fn save_enabled(enabled: &HashMap) { }; let tmp = path.with_extension("json.tmp"); if std::fs::write(&tmp, text).is_ok() { + // Windows 的 `rename` 不会覆盖已存在目标,需先移除旧文件。 + #[cfg(windows)] + { + let _ = std::fs::remove_file(&path); + } let _ = std::fs::rename(&tmp, &path); } } diff --git a/linux/src/workbench/project_dialog.rs b/linux/src/workbench/project_dialog.rs index 4512fc8b9..c74560d09 100644 --- a/linux/src/workbench/project_dialog.rs +++ b/linux/src/workbench/project_dialog.rs @@ -118,7 +118,7 @@ pub struct ProjectDialog { impl EventEmitter for ProjectDialog {} impl ProjectDialog { - /// 新建对话框:位置默认取 `$HOME`(不引入新依赖,不读真实 home 库)。 + /// 新建对话框:位置默认取用户主目录(不引入新依赖,不读真实 home 库)。 pub fn new(cx: &mut Context) -> Self { Self { mode: ProjectDialogMode::New, @@ -128,7 +128,7 @@ impl ProjectDialog { selected_index: 0, project_name: String::new(), repository_url: String::new(), - location_path: std::env::var("HOME").unwrap_or_default(), + location_path: default_project_location(), package_manager: PackageManager::Npm, name_was_edited: false, error_message: None, @@ -149,7 +149,7 @@ impl ProjectDialog { self.selected_index = 0; self.project_name.clear(); self.repository_url.clear(); - self.location_path = std::env::var("HOME").unwrap_or_default(); + self.location_path = default_project_location(); self.package_manager = PackageManager::Npm; self.name_was_edited = false; self.error_message = None; @@ -1328,6 +1328,13 @@ impl ProjectDialog { } } +/// 新建/克隆项目的默认位置:用户主目录,取不到时返回空串(由表单校验提示)。 +fn default_project_location() -> String { + crate::settings::user_home_dir() + .map(|home| home.to_string_lossy().into_owned()) + .unwrap_or_default() +} + /// 来源徽标(对齐 quick_open 的徽标样式)。 fn source_badge(label: String) -> AnyElement { div() diff --git a/linux/src/workbench/run/config.rs b/linux/src/workbench/run/config.rs index 3d1bc0ec1..bb3274ab1 100644 --- a/linux/src/workbench/run/config.rs +++ b/linux/src/workbench/run/config.rs @@ -586,6 +586,11 @@ fn write_bytes_atomically(path: &Path, contents: &[u8]) -> Result<(), String> { TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed) )); fs::write(&temporary, contents).map_err(|error| error.to_string())?; + // Windows 的 `rename` 不会覆盖已存在目标,先移除旧文件。 + #[cfg(windows)] + { + let _ = fs::remove_file(path); + } match fs::rename(&temporary, path) { Ok(()) => Ok(()), Err(error) => { diff --git a/linux/src/workbench/run/process.rs b/linux/src/workbench/run/process.rs index e78ca8362..74f327427 100644 --- a/linux/src/workbench/run/process.rs +++ b/linux/src/workbench/run/process.rs @@ -1,8 +1,12 @@ -//! Linux Run/Maven 进程解析与受管会话。 +//! Run/Maven 进程解析与受管会话(跨平台)。 //! //! 每个执行由一个 session/execution 身份拥有。一个后台线程顺序执行所有步骤, //! stdout/stderr 各自有明确归属并在进程结束后 join;停止操作向独立进程组 //! 发送 TERM,超时后再 KILL,调用方通过 `ProcessHandle::join` 完成回收。 +//! +//! 进程组信号是平台能力:Unix 用 `setpgid` + `kill(-pgid)` 覆盖整棵进程树, +//! Windows 没有等价的原生进程组信号,改用 `taskkill /T` 终止同一 pid 的 +//! 子树。两条路径都保持“先温和、后强制、有界等待”的同一语义契约。 use std::collections::{BTreeMap, HashMap}; use std::fs; @@ -23,7 +27,20 @@ use super::config::{ const PROCESS_STOP_TIMEOUT: Duration = Duration::from_secs(2); const PROCESS_POLL_INTERVAL: Duration = Duration::from_millis(20); -/// 一个已经解析到 Linux 路径和环境的命令。 +/// 平台无关的终止信号强度,替代裸 `SIGTERM`/`SIGKILL` 常量。 +/// +/// Unix 映射到对应信号;Windows 没有 SIGTERM/SIGKILL 区分,`Terminate` +/// 与 `Kill` 都落到进程树强制终止(Windows 无法只靠软件手段做到“可捕获的 +/// 温和退出”,因此 `Terminate` 仅用于先给目标一次自行退出的机会)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProcessSignal { + /// 请求目标自行退出(Unix `SIGTERM`)。 + Terminate, + /// 强制终止目标(Unix `SIGKILL`)。 + Kill, +} + +/// 一个已经解析到当前平台路径和环境的命令。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProcessCommand { pub program: String, @@ -242,7 +259,7 @@ impl ProcessManager { }; if let Some(control) = control { control.request_stop(); - send_signal(control.pgid(), libc::SIGTERM); + send_signal(control.pgid(), ProcessSignal::Terminate); } Ok(()) } @@ -312,9 +329,9 @@ impl Default for ProcessManager { impl ProcessHandle { pub fn stop(&self) -> Result<(), String> { self.control.request_stop(); - send_signal(self.control.pgid(), libc::SIGTERM); + send_signal(self.control.pgid(), ProcessSignal::Terminate); if !self.control.wait_finished(PROCESS_STOP_TIMEOUT) { - send_signal(self.control.pgid(), libc::SIGKILL); + send_signal(self.control.pgid(), ProcessSignal::Kill); if !self.control.wait_finished(PROCESS_STOP_TIMEOUT) { return Err("Process did not exit within the stop deadline".to_string()); } @@ -354,9 +371,9 @@ impl Drop for ProcessManagerInner { .unwrap_or_default(); for entry in sessions { entry.control.request_stop(); - send_signal(entry.control.pgid(), libc::SIGTERM); + send_signal(entry.control.pgid(), ProcessSignal::Terminate); let _ = entry.control.wait_finished(PROCESS_STOP_TIMEOUT); - send_signal(entry.control.pgid(), libc::SIGKILL); + send_signal(entry.control.pgid(), ProcessSignal::Kill); if let Ok(mut slot) = entry.join.lock() { if let Some(worker) = slot.take() { let _ = worker.join(); @@ -444,7 +461,7 @@ fn execute_step( // A child can exit while a forked descendant still owns the pipe. Kill the // whole process group before joining readers so the reader threads cannot // remain blocked on inherited descriptors. - send_signal(Some(pgid), libc::SIGKILL); + send_signal(Some(pgid), ProcessSignal::Kill); if let Some(thread) = stdout_thread { let _ = thread.join(); } @@ -493,9 +510,9 @@ fn wait_for_child( match child.try_wait() { Ok(Some(status)) => return Ok(status), Ok(None) if control.is_stop_requested() => { - send_signal(control.pgid(), libc::SIGTERM); + send_signal(control.pgid(), ProcessSignal::Terminate); thread::park_timeout(PROCESS_STOP_TIMEOUT.min(Duration::from_millis(200))); - send_signal(control.pgid(), libc::SIGKILL); + send_signal(control.pgid(), ProcessSignal::Kill); return child .wait() .map_err(|error| format!("Waiting for process failed: {error}")); @@ -508,9 +525,9 @@ fn wait_for_child( fn stop_control(control: &Control) -> Result<(), String> { control.request_stop(); - send_signal(control.pgid(), libc::SIGTERM); + send_signal(control.pgid(), ProcessSignal::Terminate); if !control.wait_finished(PROCESS_STOP_TIMEOUT) { - send_signal(control.pgid(), libc::SIGKILL); + send_signal(control.pgid(), ProcessSignal::Kill); if !control.wait_finished(PROCESS_STOP_TIMEOUT) { return Err("Process did not exit within the stop deadline".to_string()); } @@ -518,24 +535,63 @@ fn stop_control(control: &Control) -> Result<(), String> { Ok(()) } +/// 把子进程放进独立进程组,使停止操作能覆盖子进程派生的整棵进程树。 +/// +/// Unix 用 `setpgid(0, 0)`;Windows 用 `CREATE_NEW_PROCESS_GROUP`,二者都让 +/// 后续终止按组/树进行,而不是只杀掉直接子进程。 fn configure_process_group(command: &mut Command) { #[cfg(unix)] { use std::os::unix::process::CommandExt; command.process_group(0); } - #[cfg(not(unix))] + #[cfg(windows)] { - let _ = command; + use std::os::windows::process::CommandExt as _; + // CREATE_NEW_PROCESS_GROUP (0x0000_0200):让子进程成为新进程组 + // 组长,配合 `taskkill /T` 能一并清理其派生进程。 + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + command.creation_flags(CREATE_NEW_PROCESS_GROUP); } } -fn send_signal(pgid: Option, signal: i32) { - if let Some(pgid) = pgid.filter(|value| *value > 1) { +/// 向由 `pgid` 标识的进程组(Unix)或进程树(Windows)发送终止信号。 +/// +/// `pgid` 只在子进程存活期间有效;None 或 <= 1(pid 0/1 属于内核或 launchd/ +/// init)时不做任何事,避免误伤会话或系统进程。 +fn send_signal(pgid: Option, signal: ProcessSignal) { + let Some(pgid) = pgid.filter(|value| *value > 1) else { + return; + }; + #[cfg(unix)] + { + // 负 pid 表示对整进程组发信号;这里信号值只在 Unix 意义上有区别。 + let signal_number = match signal { + ProcessSignal::Terminate => libc::SIGTERM, + ProcessSignal::Kill => libc::SIGKILL, + }; unsafe { - libc::kill(-pgid, signal); + libc::kill(-pgid, signal_number); } } + #[cfg(windows)] + { + // Windows 无 POSIX 进程组信号,统一用 `taskkill /T` 终止该 pid 及其 + // 派生进程;“先温和后强制”的语义由调用方的有界等待轮次体现。 + // 为了避免弹出控制台窗口,隐藏子窗口并以静默方式调用。 + let _ = signal; + use std::os::windows::process::CommandExt as _; + use std::process::{Command as WindowsCommand, Stdio}; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let mut killer = WindowsCommand::new("taskkill"); + killer + .args(["/PID", &pgid.to_string(), "/T", "/F"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .creation_flags(CREATE_NO_WINDOW); + let _ = killer.status(); + } } /// 解析 Core 计划中的主进程和每个 pre-launch 步骤。 @@ -808,23 +864,26 @@ fn resolve_runtime( return Ok(path.to_string_lossy().into_owned()); } if id == "project-node" { - for name in ["node", "node.exe", "npm", "npm.cmd"] { - let direct = path.join(name); - if direct.is_file() { - return Ok(direct.to_string_lossy().into_owned()); - } - let nested = path.join("bin").join(name); - if nested.is_file() { - return Ok(nested.to_string_lossy().into_owned()); + // Node 安装目录可能是 `bin/`(Unix)或直接为根(Windows)。 + for tool in ["node", "npm"] { + for name in binary_file_names(tool) { + let direct = path.join(&name); + if direct.is_file() { + return Ok(direct.to_string_lossy().into_owned()); + } + let nested = path.join("bin").join(&name); + if nested.is_file() { + return Ok(nested.to_string_lossy().into_owned()); + } } } } return Err(format!("Configured executable for {id} does not exist.")); } let names = if id == "project-node" { - vec!["node", "node.exe"] + binary_file_names("node") } else { - vec![id] + vec![id.to_string()] }; names .iter() @@ -872,7 +931,7 @@ fn resolve_command( .unwrap_or_else(|| Path::new(root)) .to_path_buf() }; - for name in command_file_names(command) { + for name in binary_file_names(command) { let direct = directory.join(&name); if direct.is_file() { return Ok(direct.to_string_lossy().into_owned()); @@ -885,7 +944,7 @@ fn resolve_command( return Err(format!("Selected Node runtime does not contain {command}.")); } } - command_file_names(command) + binary_file_names(command) .iter() .find_map(|name| lookup_on_path(name)) .map(|path| path.to_string_lossy().into_owned()) @@ -900,7 +959,13 @@ fn resolve_maven(root: &str, working_directory: &str, configured: &str) -> Resul } } let path = absolute_path(root, &configured); - for candidate in [path.clone(), path.join("bin/mvn"), path.join("mvn")] { + let mut candidates = vec![path.clone()]; + // Maven 可能是目录(`bin/mvn[.cmd]`)或直接指向可执行文件。 + for name in binary_file_names("mvn") { + candidates.push(path.join("bin").join(&name)); + candidates.push(path.join(&name)); + } + for candidate in candidates { if candidate.is_file() { return Ok(candidate.to_string_lossy().into_owned()); } @@ -908,13 +973,18 @@ fn resolve_maven(root: &str, working_directory: &str, configured: &str) -> Resul return Err("Maven executable path does not exist.".to_string()); } for directory in [working_directory, root] { - let wrapper = Path::new(directory).join("mvnw"); - if wrapper.is_file() - && Path::new(directory) + // Maven Wrapper 在 Unix 是 `mvnw`,Windows 是 `mvnw.cmd`。 + let wrapper = binary_file_names("mvnw") + .into_iter() + .map(|name| Path::new(directory).join(name)) + .find(|candidate| candidate.is_file()); + if let Some(wrapper) = wrapper { + if Path::new(directory) .join(".mvn/wrapper/maven-wrapper.properties") .is_file() - { - return Ok(wrapper.to_string_lossy().into_owned()); + { + return Ok(wrapper.to_string_lossy().into_owned()); + } } } lookup_on_path("mvn") @@ -924,28 +994,50 @@ fn resolve_maven(root: &str, working_directory: &str, configured: &str) -> Resul }) } -fn command_file_names(command: &str) -> Vec { - if Path::new(command).extension().is_some() { - return vec![command.to_string()]; - } - vec![ - format!("{command}.exe"), - format!("{command}.cmd"), - format!("{command}.bat"), - command.to_string(), - ] -} - fn lookup_on_path(name: &str) -> Option { let path = std::env::var_os("PATH")?; - std::env::split_paths(&path) - .map(|directory| directory.join(name)) - .find(|candidate| candidate.is_file()) + let candidates = binary_file_names(name); + std::env::split_paths(&path).find_map(|directory| { + candidates + .iter() + .map(|name| directory.join(name)) + .find(|candidate| candidate.is_file()) + }) } fn jdk_tool(home: &str, tool: &str) -> Option { - let path = Path::new(home).join("bin").join(tool); - path.is_file().then(|| path.to_string_lossy().into_owned()) + let bin = Path::new(home).join("bin"); + // Windows 的 JDK 工具带 `.exe`(`bin/java.exe`),Unix 无扩展名。 + for name in binary_file_names(tool) { + let candidate = bin.join(&name); + if candidate.is_file() { + return Some(candidate.to_string_lossy().into_owned()); + } + } + None +} + +/// 在无扩展名工具名的基础上补全当前平台的可执行后缀。 +/// +/// Windows 上 `java`/`mvn` 实际是 `java.exe`/`mvn.cmd` 等;名字已带 +/// 扩展名或非 Windows 平台时只返回原名。 +fn binary_file_names(tool: &str) -> Vec { + #[cfg(windows)] + { + if Path::new(tool).extension().is_some() { + return vec![tool.to_string()]; + } + vec![ + format!("{tool}.exe"), + format!("{tool}.cmd"), + format!("{tool}.bat"), + tool.to_string(), + ] + } + #[cfg(not(windows))] + { + vec![tool.to_string()] + } } fn resolve_working_directory(root: &str, relative: &str) -> Result { @@ -1054,7 +1146,9 @@ fn absolute_path(root: &str, value: &str) -> PathBuf { } } -#[cfg(test)] +// 这些测试用 `/bin/sh` 驱动真实子进程并校验 Unix 进程组信号与 `bin/` 路径 +// 语义;Windows 的进程树终止与可执行文件命名不同,因此仅在 Unix 上运行。 +#[cfg(all(test, unix))] mod tests { use super::*; use std::fs; diff --git a/linux/src/workbench/settings_dialog.rs b/linux/src/workbench/settings_dialog.rs index 7d2d1c5c1..73534947f 100644 --- a/linux/src/workbench/settings_dialog.rs +++ b/linux/src/workbench/settings_dialog.rs @@ -1755,7 +1755,7 @@ impl SettingsDialog { } /// 终端:对齐 Tauri `TerminalPanel`(启动/默认 Shell/排版/滚动/光标)。 - /// Linux 只收录真实存在的系统 shell(bash/zsh/fish/sh/dash)。 + /// 只收录当前平台真实存在的系统 shell;Windows 下列表为空,保留系统默认项。 fn render_terminal_content(&self, cx: &mut Context) -> impl IntoElement { let s = settings::get(cx).clone(); // 固定候选集转静态引用(避免每次渲染泄漏内存)。 @@ -1763,7 +1763,7 @@ impl SettingsDialog { "", crate::i18n::menu_text(cx, "settings.mac.systemDefault").to_string(), )]; - for name in detect_linux_shells() { + for name in detect_available_shells() { let value: &'static str = match name.as_str() { "bash" => "bash", "zsh" => "zsh", @@ -1982,6 +1982,8 @@ impl SettingsDialog { } /// 在 `PATH` 中查找可执行文件,返回完整路径。 + /// + /// Windows 上按 `PATHEXT` 补全扩展名;Unix 要求执行位。 fn find_in_path(name: &str) -> Option { let path = std::env::var_os("PATH")?; for dir in std::env::split_paths(&path) { @@ -3059,14 +3061,11 @@ impl SettingsDialog { } } -/// 默认日志目录(`~/.local/share/lithe/logs`,对齐 XDG 状态目录)。 +/// 默认日志目录:配置数据目录下的 `lithe/logs`。 +/// +/// 数据目录的平台规则(XDG / `%LOCALAPPDATA%`)见 `settings::data_dir`。 fn default_log_dir() -> String { - std::env::var_os("XDG_DATA_HOME") - .map(std::path::PathBuf::from) - .filter(|p| !p.as_os_str().is_empty()) - .or_else(|| { - std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".local/share")) - }) + crate::settings::data_dir() .map(|base| { base.join("lithe") .join("logs") @@ -3086,12 +3085,16 @@ fn effective_log_dir(settings: &Settings) -> String { } /// `PATH` 中查找可执行文件,返回首个命中绝对路径(对齐 Mac `which` 探测思路)。 +/// +/// Windows 上同名程序带扩展名(`java` -> `java.exe`),因此按 `PATHEXT` +/// 补全候选;Unix 要求执行位。两平台都只返回存在的绝对路径。 fn which(exe: &str) -> Option { let paths = std::env::var_os("PATH")?; for dir in std::env::split_paths(&paths) { - let candidate = dir.join(exe); - if candidate.is_file() { - // 可执行位检查:Unix 下确认有执行权限。 + for candidate in executable_candidates(&dir, exe) { + if !candidate.is_file() { + continue; + } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; @@ -3110,6 +3113,29 @@ fn which(exe: &str) -> Option { None } +/// 列出在 `dir` 下应该尝试的可执行文件名。 +/// +/// Windows 上按 `PATHEXT`(缺省 ` .COM;.EXE;.BAT;.CMD`)补全;名字已带 +/// 扩展名或非 Windows 平台时只尝试原名。 +fn executable_candidates(dir: &std::path::Path, name: &str) -> Vec { + #[cfg(windows)] + { + let mut candidates = vec![dir.join(name)]; + if std::path::Path::new(name).extension().is_none() { + let pathext = std::env::var("PATHEXT") + .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()); + for extension in pathext.split(';').filter(|value| !value.is_empty()) { + candidates.push(dir.join(format!("{name}{extension}"))); + } + } + candidates + } + #[cfg(not(windows))] + { + vec![dir.join(name)] + } +} + fn which_java_home() -> Option { let executable = which("java")?; let path = std::fs::canonicalize(&executable) @@ -3119,19 +3145,28 @@ fn which_java_home() -> Option { .map(|home| home.to_string_lossy().into_owned()) } -/// Linux 可用 shell 探测:仅收录真实存在的系统 shell(对齐 Tauri 终端面板 -/// 的“探测 shells”行为,Windows 的 powershell/cmd/wsl 在 Linux 无意义)。 -fn detect_linux_shells() -> Vec { - let mut shells = Vec::new(); - for name in ["bash", "zsh", "fish", "sh", "dash"] { - let found = ["/bin", "/usr/bin"] - .iter() - .any(|dir| std::path::Path::new(dir).join(name).is_file()); - if found || which(name).is_some() { - shells.push(name.to_string()); +/// 当前可用 shell 探测:仅收录真实存在的系统 shell。 +/// +/// Unix 检查常见路径或 `PATH`;Windows 上没有 bash/zsh/fish,默认 shell 由 +/// 系统决定(PowerShell/cmd),因此返回空列表,让上层保留“系统默认”选项。 +fn detect_available_shells() -> Vec { + #[cfg(unix)] + { + let mut shells = Vec::new(); + for name in ["bash", "zsh", "fish", "sh", "dash"] { + let found = ["/bin", "/usr/bin"] + .iter() + .any(|dir| std::path::Path::new(dir).join(name).is_file()); + if found || which(name).is_some() { + shells.push(name.to_string()); + } } + shells + } + #[cfg(not(unix))] + { + Vec::new() } - shells } /// 读项目工具链;canonical 键优先,兼容历史 Linux 扁平键。 diff --git a/linux/src/workbench/terminal.rs b/linux/src/workbench/terminal.rs index 90b3f51a3..cebe2d17a 100644 --- a/linux/src/workbench/terminal.rs +++ b/linux/src/workbench/terminal.rs @@ -1,4 +1,4 @@ -//! Linux 原生 PTY 终端:`portable-pty` 跑 shell,`alacritty_terminal`(Alacritty +//! 原生 PTY 终端:`portable-pty` 跑 shell,`alacritty_terminal`(Alacritty //! 网格引擎)做解析与状态,键盘字符模式直输(对齐 IDEA 内嵌终端的交互:历史、 //! 补全、方向键、中断均由 PTY 行规程处理)。 //! @@ -7,8 +7,7 @@ //! `alacritty_terminal` 的既有 API(`Term::scroll_display`、`Selection`、 //! `RegexSearch`、`Term::resize` 与网格 reflow),不重复实现上游能力。 //! -//! 适配声明:多标签、横向分屏、超链接点击、图形协议(Sixel/Kitty)暂不支持。 -//! +//! 适配声明:多标签、横向分屏、超链接点击、图形协议(Sixel/Kitty)暂不支持。//! //! Note: 引擎复用边界与禁止手写终端语义的原因见 //! `.agents/notes/implemented/architecture/2026-09-25-linux-gpui-terminal-engine-reuse.md`。 @@ -48,6 +47,43 @@ const INITIAL_ROWS: usize = 24; const MIN_COLS: usize = 2; const MIN_ROWS: usize = 1; +/// 平台默认 shell:Unix 用登录 shell(`$SHELL`),Windows 用 `COMSPEC`。 +/// +/// 两个变量都缺失时给出该平台最可能存在的回退名,避免空 program 导致 PTY 启动失败。 +fn default_shell() -> String { + #[cfg(unix)] + { + std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()) + } + #[cfg(windows)] + { + std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string()) + } +} + +/// 列出在 `dir` 下应该尝试的 shell 可执行文件名。 +/// +/// Windows 上按 `PATHEXT` 补全(`pwsh` -> `pwsh.exe`);名字已带扩展名或非 +/// Windows 平台时只尝试原名。 +fn shell_candidates(dir: &std::path::Path, name: &str) -> Vec { + #[cfg(windows)] + { + let mut candidates = vec![dir.join(name)]; + if std::path::Path::new(name).extension().is_none() { + let pathext = std::env::var("PATHEXT") + .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()); + for extension in pathext.split(';').filter(|value| !value.is_empty()) { + candidates.push(dir.join(format!("{name}{extension}"))); + } + } + candidates + } + #[cfg(not(windows))] + { + vec![dir.join(name)] + } +} + /// 行高相对字号的倍率(等宽终端常用 1.4)。 const LINE_HEIGHT_RATIO: f32 = 1.4; @@ -199,7 +235,9 @@ impl TerminalSession { })?; let shell = if shell.trim().is_empty() { - std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()) + // Unix 从 `$SHELL` 取用户登录 shell;Windows 没有该变量,回退到 + // `COMSPEC`(cmd.exe),这与系统终端的默认行为一致。 + default_shell() } else { shell.to_string() }; @@ -423,7 +461,7 @@ impl TerminalView { } /// 解析生效 shell:设置 `terminalDefaultShellId` 非空即用(名称经 - /// `PATH` 或 `/bin` 解析为路径),为空回退 `$SHELL`。 + /// `PATH` 解析为路径),为空回退平台默认 shell。 fn resolve_shell(cx: &App) -> String { let id = settings::get(cx) .terminal_default_shell_id @@ -432,20 +470,26 @@ impl TerminalView { if id.is_empty() { return String::new(); } - if id.contains('/') { + // 已含路径分隔符(`/` 或 `\`)时视为绝对/相对路径,直接使用。 + if id.contains('/') || id.contains('\\') { return id; } if let Some(paths) = std::env::var_os("PATH") { for dir in std::env::split_paths(&paths) { - let candidate = dir.join(&id); - if candidate.is_file() { - return candidate.to_string_lossy().to_string(); + for candidate in shell_candidates(&dir, &id) { + if candidate.is_file() { + return candidate.to_string_lossy().to_string(); + } } } } - let fallback = format!("/bin/{id}"); - if std::path::Path::new(&fallback).is_file() { - return fallback; + // 某些发行版的 shell 不在 PATH 中,退回固定系统目录。 + #[cfg(unix)] + { + let fallback = format!("/bin/{id}"); + if std::path::Path::new(&fallback).is_file() { + return fallback; + } } id } From 83d11baf9f0bb1ec26006ddbba21252c10e27f3d Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 19:20:11 +0800 Subject: [PATCH 066/109] =?UTF-8?q?feat(linux):=20=E5=88=86=E6=94=AF?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=99=A8=E5=BC=B9=E7=AA=97=E4=B8=89=E5=88=86?= =?UTF-8?q?=E5=8C=BA=E5=AF=B9=E9=BD=90=20Windows=20=E7=9A=84=E8=A1=8C?= =?UTF-8?q?=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象:点击分支徽标打开的弹窗(仓库/分支/工作树)只是 Windows 的极简子集 —— 工作树分区因命令名写错而永远为空;搜索框是自绘假输入,打不了中文也粘不进去; 分支不把当前项置顶;没有键盘上下选择、没有创建工作树、没有仓库列表。 本次按 Windows `git-branch-manager.tsx` 的可见行为对齐三个分区: 新增 `linux/src/workbench/branch_manager_logic.rs`(纯逻辑,11 项单测): - `filtered_branches` / `create_branch_name` ← `getFilteredBranches` / `getCreateBranchName`:当前分支置顶、按名称排序、规范化查询过滤;查询非空、 不等于当前分支且无同名时提供建分支名。 - `filtered_worktrees` / `is_openable_worktree` / `worktree_label` ← `getFilteredWorktrees` / `isOpenableGitWorktree` / `getBranchLabel`:过滤 bare/prunable、当前工作树置顶、无分支按 detached / no branch 显示。 - `create_worktree_path` / `filtered_repositories` / `matches_search_query` / `clamp_index` / `move_index`:分别对齐 `getCreateWorktreePath` / `getFilteredRepositoryPaths` / `search-match.ts` / `moveCommandListIndex` (上下移动夹紧不循环)。 `linux/src/workbench/branch_manager.rs` 重写: - 搜索框改用真实 `Input`/`InputState`(`appearance(false)`),支持 IME 组字与 `Ctrl+V` 粘贴;`InputEvent::Change` 同步查询、`PressEnter` 执行选中项。 - 上下键移动选中、Esc 关闭;切 tab 刷新占位并聚焦输入框。 - 工作树拉取命令名 `worktrees` → `git.worktrees`(core 注册名),修复分区 永远为空的问题;仓库列表改用 `workspace.repositories`。 - 三个分区各自的空态、计数、列表行与底部动作(新建分支 / 创建工作树 / 添加仓库 / 刷新)按 Windows 结构实现。 - 新增 `SelectRepository` 事件,由父级切换项目。 其他: - `i18n.rs`:补齐 `git.*` 共 17 个文案键,文案抄自 Tauri `locale.ts`。 - `view.rs`:`BranchManagerView::new(window)`、打开时设置工作区根、接线 `SelectRepository`。 验证: - `cargo test --manifest-path linux/Cargo.toml -p lithe-linux --lib`:95 通过 (含 `workbench::branch_manager_logic` 11 项)。 - `cargo build --manifest-path linux/Cargo.toml -p lithe-linux` 通过,无警告。 - `node scripts/verify-agent-notes.mjs` 通过。 未包含(依赖 Linux 侧尚不存在的通用对话框/toast):分支删除/合并/变基、 工作树删除、确认对话与 toast、手动添加仓库的持久化。 --- .../2026-09-26-linux-branch-manager-parity.md | 129 +++ linux/src/i18n.rs | 16 + linux/src/workbench/branch_manager.rs | 950 +++++++++++++----- linux/src/workbench/branch_manager_logic.rs | 427 ++++++++ linux/src/workbench/mod.rs | 1 + linux/src/workbench/sidebar.rs | 9 +- linux/src/workbench/view.rs | 9 +- 7 files changed, 1287 insertions(+), 254 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-26-linux-branch-manager-parity.md create mode 100644 linux/src/workbench/branch_manager_logic.rs diff --git a/.agents/notes/implemented/feature/2026-09-26-linux-branch-manager-parity.md b/.agents/notes/implemented/feature/2026-09-26-linux-branch-manager-parity.md new file mode 100644 index 000000000..7b1052e18 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-26-linux-branch-manager-parity.md @@ -0,0 +1,129 @@ +# Agent 笔记:Linux 分支管理器弹窗对齐 Windows 的三分区行为 + +状态:已实现 + +## 先说结论 + +Linux 工作台里,点击项目后面/工具栏上的分支徽标会打开一个弹窗,顶部有三个分区: +**仓库 / 分支 / 工作树**。以前这个弹窗只是 Windows 版本的一个极简子集:只能 +列分支、搜索框是自绘的假输入(打不了中文、粘不进去)、工作树分区因为命令名 +写错而永远为空、没有键盘上下选择、没有创建工作树、没有仓库列表。现在它的 +**可见行为**与 Windows 的 `GitBranchManager`(以及 macOS 分支弹窗的共同部分) +对齐:排序、过滤、标签、建名规则、键盘导航、三个分区的列表与底部动作都按同一 +套语义实现。 + +开发者要记住:**这个弹窗的行为只能以 +`windows/tauri/src/features/git/components/git-branch-manager.tsx` 为准**, +不要在 Linux 里另立一套排序或过滤规则。 + +## 问题 + +对照 Windows 真源,Linux 侧原来的 `linux/src/workbench/branch_manager.rs` 有几处 +明确差距: + +- **工作树分区始终为空**:拉取时调用的是 core 命令 `"worktrees"`,而 core 只 + 识别 `"git.worktrees"`(`CoreCommand::GitWorktrees`),请求直接失败。 +- **搜索框是假的**:用普通 `div` + `on_key_down` 手动拼接 `key_char`,没有 + 注册 GPUI 的 input handler,因此中文 IME 与 `Ctrl+V` 都无效(与文件树过滤 + 行同样的坑,见 `2026-09-26-linux-explorer-tree-search-parity.md`)。 +- **排序/过滤不对**:分支只做字母序、不把当前分支置顶;工作树不过滤 bare / + prunable;仓库分区形同虚设。 +- **没有键盘导航**:Windows 用 `useCommandListNavigation` 支持上下键移动、 + 回车执行;Linux 不支持。 +- **缺少工作树建行与底部动作**:Windows 在工作树分区可一键 `createWorktree`, + 三分区底部各有 新建 / 刷新 / 添加 动作。 + +## 决策 + +### 纯逻辑单独成模块,可单测 + +新增 `linux/src/workbench/branch_manager_logic.rs`,逐条复刻 Windows 的纯函数: + +- `filtered_branches` ← `getFilteredBranches`:当前分支置顶,其余按名称排序, + 再按规范化查询过滤。 +- `create_branch_name` ← `getCreateBranchName`:查询非空、不等于当前分支、且 + 不存在同名分支时,返回建分支名。 +- `filtered_worktrees` / `is_openable_worktree` / `worktree_label` ← + `getFilteredWorktrees` / `isOpenableGitWorktree` / `getBranchLabel`:过滤 + bare / prunable,当前工作树置顶,无分支时显示 detached / no branch。 +- `create_worktree_path` ← `getCreateWorktreePath`:查询非空且不重复时返回建路径。 +- `filtered_repositories` ← `getFilteredRepositoryPaths`:当前仓库置顶,按目录名 + 排序并过滤。 +- `matches_search_query` ← `utils/search-match.ts`:规范化(大小写、非字母数字 + 折叠为空格)后按“包含”或“压缩包含”(去空格)匹配。 +- `clamp_index` / `move_index` ← `clampCommandListIndex` / `moveCommandListIndex`: + 上下移动**夹紧不循环**。 + +该模块不依赖 gpui、不做 IO,行为由单测锁定。 + +### 渲染层只做接线 + +- 搜索框换成真实 `Input`(`appearance(false)` 关闭自带背景/边框/焦点环, + 保持与原卡片样式一致),`InputEvent::Change` 同步查询、`PressEnter` 执行 + 选中项。 +- 上下键与 Esc 由弹窗外层 `on_key_down` 处理;字符输入交给 `Input`。 +- 三个分区各自的空态、计数、列表行与底部动作按 Windows 结构实现。 + +### core 命令名以真源为准 + +工作树拉取从 `"worktrees"` 改为 `"git.worktrees"`;仓库发现使用 +`workspace.repositories`。这两条命令都在 core 的 `CoreCommand` 中注册。 + +### 平台无关 + +弹窗代码只用 gpui 官方跨平台 API(`InputState`、`cx.spawn`、`CoreClient`、 +`cx.emit`),不写任何平台专属路径或系统绑定,保证后续可编译到其它平台。 + +## 考虑过的备选方案 + +### 备选方案一:只修 `"worktrees"` → `"git.worktrees"` 这个 bug + +这是最小改动,能让工作树分区显示出来。但它不解决排序、过滤、中文输入、 +键盘导航和创建工作树,Linux 与 Windows 仍是两套能力。用户要求「对齐另外两个 +平台」,因此不采用。 + +### 备选方案二:整段移植 Windows 的 React 组件 + +不可行。Windows 是约 1050 行 React/TS,依赖 zustand store、toast、 +`showConfirmDialog`、`GitCommandSurface`、`CommandList` 等前端基础设施; +Linux 是 GPUI/Rust,没有 DOM、没有这些 store。可行做法是复用**可见行为与 +纯逻辑语义**,而不是复用前端框架代码。 + +### 备选方案三:把分支/工作树的排序过滤写在渲染函数里 + +改动看似更小,但会把规则散落在渲染路径上,无法用确定性单测覆盖,后续两端 +漂移时也难发现。因此把规则集中进纯逻辑模块。 + +## 后果 + +- 收益:三个分区的排序、过滤、标签、建名、键盘导航与 Windows 一致;工作树 + 分区从「永远为空」变为真正可用;搜索框支持中文与粘贴。 +- 收益:规则集中在纯逻辑模块,11 项单测锁定行为,后续调整只改纯函数。 +- 代价:暂未实现 Windows 的分支删除 / 合并 / 变基、工作树删除、确认对话框与 + toast 提示,也不持久化「手动添加的仓库」。这些依赖 Linux 侧尚不存在的 + 通用对话框/toast 基础设施,留待后续单独决策。 +- 代价:仓库分区在 core 返回为空时回退展示当前仓库(单仓库场景),与 Windows + 依赖 repository store 的多仓库发现不完全等价。 + +## 验证 + +- `cargo test --manifest-path linux/Cargo.toml -p lithe-linux --lib`:95 通过, + 其中 `workbench::branch_manager_logic` 11 项覆盖分支置顶与过滤、建分支名 + 规则、工作树可打开性与标签、工作树排序与多字段过滤、建工作树路径、仓库 + 排序与过滤、路径与目录名工具、命令下标夹紧与移动。 +- `cargo build --manifest-path linux/Cargo.toml -p lithe-linux` 通过,无警告。 +- 手工验证待做:打开弹窗确认三分区可切换、工作树非空、搜索框可输入中文与 + 粘贴、上下键移动选中、回车检出/打开,底部动作可用。 + +## 适用范围 + +- `linux/src/workbench/branch_manager.rs`:弹窗状态、core 调用与渲染接线。 +- `linux/src/workbench/branch_manager_logic.rs`:排序/过滤/标签/建名纯逻辑。 +- `linux/src/i18n.rs`:补齐 `git.*` 文案键(文案抄自 Tauri `locale.ts`)。 +- `linux/src/workbench/view.rs`:`BranchManagerView::new(window)`、 + `SelectRepository` 事件接线、打开时设置工作区根。 +- 不适用于 Windows 产品;Windows 侧真源是 + `windows/tauri/src/features/git/components/git-branch-manager.tsx` 与 + `windows/tauri/src/features/git/utils/git-worktree-open.ts`。 +- macOS 分支弹窗是另一套 UI(`BranchSwitcherPopover`),本决策只对齐共同可见 + 行为,不复制 macOS 的动作型布局。 diff --git a/linux/src/i18n.rs b/linux/src/i18n.rs index 9b2fb888c..535edbe6a 100644 --- a/linux/src/i18n.rs +++ b/linux/src/i18n.rs @@ -324,6 +324,22 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { "git.noMatchingBranches" => ("没有匹配的分支", "No matching branches"), "git.noBranchesFound" => ("未找到分支", "No branches found"), "git.createNewBranch" => ("创建新分支 “{name}”", "Create new branch \"{name}\""), + "git.loadingWorktrees" => ("正在加载工作树...", "Loading worktrees..."), + "git.noMatchingWorktrees" => ("没有匹配的工作树", "No matching worktrees"), + "git.noWorktreesFound" => ("未找到工作树", "No worktrees found"), + "git.createWorktree" => ("创建工作树 “{path}”", "Create worktree \"{path}\""), + "git.detectingRepositories" => ("正在检测仓库...", "Detecting repositories..."), + "git.noMatchingRepositories" => ("没有匹配的仓库", "No matching repositories"), + "git.noRepositoriesFound" => ("未找到仓库", "No repositories found"), + "git.newBranch" => ("新建分支", "New Branch"), + "git.refresh" => ("刷新", "Refresh"), + "git.add" => ("添加", "Add"), + "git.adding" => ("正在添加...", "Adding..."), + "git.clearAdded" => ("清除已添加", "Clear Added"), + "git.current" => ("当前", "current"), + "git.added" => ("已添加", "added"), + "git.detachedHead" => ("分离的 HEAD", "Detached HEAD"), + "git.noBranch" => ("无分支", "No branch"), // ---- Quick Open / Command Palette / Search Everywhere(与 Tauri locale.ts 对齐) ---- "quickOpen.searchFiles" => ("输入以搜索文件...", "Type to search files..."), "quickOpen.searchSymbols" => ("输入以筛选符号...", "Type to filter symbols..."), diff --git a/linux/src/workbench/branch_manager.rs b/linux/src/workbench/branch_manager.rs index 36dc3cad1..f9a626a4e 100644 --- a/linux/src/workbench/branch_manager.rs +++ b/linux/src/workbench/branch_manager.rs @@ -1,25 +1,36 @@ -//! 分支管理器弹窗:对齐 Tauri `GitBranchManager`(`GitCommandSurface` 弹窗)。 +//! 分支管理器弹窗:对齐 Tauri `GitBranchManager`(`GitCommandSurface` 弹窗) +//! 与 macOS `BranchSwitcherPopover` 的共同可见行为。 //! -//! - 顶部三个 tab(仓库 / 分支 / 工作树),按 tab 切换搜索框占位与计数。 -//! - 分支行点击经 core 直连 `git.write` 检出,当前分支打勾; -//! 搜索串无精确匹配时首行可建分支(`createBranch` 后再 `checkout` 切过去)。 -//! - 工作树行点击把路径交给父级(`OpenWorktree`),由父级打开对应项目。 -//! - 成功统一发射 [`BranchManagerEvent::CheckoutDone`](父级刷新+关弹窗); -//! 失败只在弹窗内显示错误行,不发射。 -//! - 遮罩 + 卡片布局与键盘输入仿 `quick_open.rs`;core 调用模式照抄 -//! `sidebar.rs`(`CoreClient` + `cx.spawn`)与 `view.rs` 的分支解析。 +//! 三个 tab(仓库 / 分支 / 工作树): +//! - 顶部 tab 行切换分区,搜索框占位与计数随之变化;搜索框为真实 `Input`, +//! 支持 IME 组字与剪贴板粘贴。 +//! - 分支 tab:当前分支置顶、其余按名称排序;查询无精确匹配时首行可建分支并 +//! 切过去;上下键移动选中、回车执行。 +//! - 工作树 tab:过滤掉 bare / prunable,当前工作树置顶;无分支时按 +//! detached / no branch 文案显示;查询非空且不重复时首行可创建工作树。 +//! - 仓库 tab:列出工作区内发现的仓库(当前仓库置顶),点击切换。 +//! - 底部动作行:按 tab 提供 新建分支 / 创建工作树 / 添加仓库 与 刷新。 +//! +//! 排序、过滤、标签与建名规则集中在 `branch_manager_logic`(纯逻辑,可单测); +//! core 调用模式沿用 `sidebar.rs`(`CoreClient` + `cx.spawn`)。 use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::input::{Input, InputEvent, InputState}; use gpui_kit::component::scroll::ScrollableElement as _; -use gpui_kit::component::{h_flex, v_flex, Icon, Selectable as _, Sizable as _}; +use gpui_kit::component::{h_flex, v_flex, Disableable as _, Icon, Selectable as _, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, rgba, Context, EventEmitter, FocusHandle, FontWeight, InteractiveElement as _, - IntoElement, KeyDownEvent, ParentElement as _, Render, StatefulInteractiveElement as _, - Styled as _, Window, + div, px, rgba, AppContext as _, Context, Entity, EventEmitter, FocusHandle, FontWeight, + InteractiveElement as _, IntoElement, KeyDownEvent, ParentElement as _, Render, + StatefulInteractiveElement as _, Styled as _, Subscription, Window, }; +use super::branch_manager_logic::{ + clamp_index, create_branch_name, create_worktree_path, filtered_branches, + filtered_repositories, filtered_worktrees, folder_name, move_index, query_matches, + relative_path, worktree_label, WorktreeInfo, +}; use crate::core::CoreClient; use crate::theme::ThemeColors; @@ -58,28 +69,31 @@ pub enum BranchManagerEvent { CheckoutDone, /// 请求打开指定工作树路径的项目(父级关弹窗后打开)。 OpenWorktree(String), + /// 请求切换到指定仓库路径(父级关弹窗后打开)。 + SelectRepository(String), /// 请求关闭弹窗(遮罩点击 / Esc)。 Close, } -/// 工作树列表项(字段形状对齐 core `GitWorktreeResponse` 的 camelCase 返回)。 -#[derive(Debug, Clone)] -pub struct WorktreeInfo { - pub path: String, - pub branch: Option, - pub is_current: bool, -} - /// 居中的分支管理器弹窗(宽 560、高 420,仿 `project_dialog` 卡片)。 pub struct BranchManagerView { repo_path: String, + /// 工作区根路径:仓库行的副行显示相对此根的路径(对齐 `getRelativePath`)。 + workspace_root: String, current_branch: Option, active_tab: BranchTab, query: String, branches: Vec, worktrees: Vec, + repositories: Vec, + is_discovering_repos: bool, + is_loading_worktrees: bool, + /// 键盘导航:命令列表中的选中下标。 + selected_index: usize, error: Option, busy: bool, + search_input: Entity, + _search_subscription: Subscription, focus_handle: FocusHandle, client: CoreClient, } @@ -87,16 +101,49 @@ pub struct BranchManagerView { impl EventEmitter for BranchManagerView {} impl BranchManagerView { - pub fn new(repo_path: String, cx: &mut Context) -> Self { + pub fn new(repo_path: String, window: &mut Window, cx: &mut Context) -> Self { + let search_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(crate::i18n::menu_text(cx, "git.searchBranches")) + }); + let input_source = search_input.clone(); + let _search_subscription = cx.subscribe( + &search_input, + move |this: &mut Self, _, event: &InputEvent, cx| match event { + InputEvent::Change => { + this.query = input_source.read(cx).value().to_string(); + this.selected_index = 0; + this.error = None; + cx.notify(); + } + InputEvent::PressEnter { shift, .. } => { + // 回车执行当前选中项;Shift+回车回退一格(列表导航习惯)。 + if *shift { + this.move_selection(-1, cx); + } else { + this.activate_selection(cx); + } + } + _ => {} + }, + ); + let mut view = Self { repo_path, + workspace_root: String::new(), current_branch: None, active_tab: BranchTab::Branches, query: String::new(), branches: Vec::new(), worktrees: Vec::new(), + repositories: Vec::new(), + is_discovering_repos: false, + is_loading_worktrees: false, + selected_index: 0, error: None, busy: false, + search_input, + _search_subscription, focus_handle: cx.focus_handle(), client: CoreClient::new(), }; @@ -104,36 +151,56 @@ impl BranchManagerView { view } - /// 更新仓库并重载分支与工作树(打开弹窗时由父级调用)。 + /// 更新仓库并重载分支、工作树与仓库列表(打开弹窗时由父级调用)。 pub fn set_repo(&mut self, path: String, current: Option, cx: &mut Context) { - self.repo_path = path; + self.repo_path = path.clone(); + if self.workspace_root.is_empty() { + self.workspace_root = path; + } self.current_branch = current; self.query.clear(); + self.branches.clear(); + self.worktrees.clear(); + self.repositories.clear(); self.error = None; self.busy = false; + self.selected_index = 0; + // 清空搜索框显示值(`InputState::set_value` 需要 `Window`)。 + if let Some(handle) = cx.active_window() { + let input = self.search_input.clone(); + handle + .update(cx, |_, window, cx| { + input.update(cx, |state, cx| state.set_value("", window, cx)); + }) + .ok(); + } self.reload(cx); } - /// 并发拉取 `git.references` 与 `worktrees`,分支排序去重。 + /// 设置工作区根(仓库副行显示相对此根的路径)。 + pub fn set_workspace_root(&mut self, workspace_root: String) { + self.workspace_root = workspace_root; + } + + /// 并发拉取分支、工作树与仓库列表。 pub fn reload(&mut self, cx: &mut Context) { + self.load_branches(cx); + self.load_worktrees(cx); + self.load_repositories(cx); + } + + fn load_branches(&mut self, cx: &mut Context) { let client = self.client.clone(); let root = self.repo_path.clone(); - let root_wt = self.repo_path.clone(); cx.spawn(async move |this, cx| { - let refs_task = client.execute::( + let task = client.execute::( &cx, "git.references", serde_json::json!({ "root": root }), ); - let wt_task = client.execute::( - &cx, - "worktrees", - serde_json::json!({ "root": root_wt }), - ); - let refs_val = refs_task.await.unwrap_or(serde_json::Value::Null); - let wt_val = wt_task.await.unwrap_or(serde_json::Value::Null); + let value = task.await.unwrap_or(serde_json::Value::Null); // 分支解析照抄 view.rs observer:kind == local 的 shortName。 - let mut branches: Vec = refs_val + let mut branches: Vec = value .get("references") .and_then(|r| r.as_array()) .map(|arr| { @@ -149,39 +216,69 @@ impl BranchManagerView { .unwrap_or_default(); branches.sort(); branches.dedup(); - let worktrees: Vec = wt_val + let _ = this.update(cx, |view, cx| { + view.branches = branches; + cx.notify(); + }); + }) + .detach(); + } + + fn load_worktrees(&mut self, cx: &mut Context) { + self.is_loading_worktrees = true; + let client = self.client.clone(); + let root = self.repo_path.clone(); + cx.spawn(async move |this, cx| { + // 真源命令名为 `git.worktrees`(core `CoreCommand::GitWorktrees`)。 + let task = client.execute::( + &cx, + "git.worktrees", + serde_json::json!({ "root": root }), + ); + let value = task.await.unwrap_or(serde_json::Value::Null); + let worktrees: Vec = value .get("worktrees") .and_then(|w| w.as_array()) + .map(|arr| arr.iter().filter_map(parse_worktree).collect()) + .unwrap_or_default(); + let _ = this.update(cx, |view, cx| { + view.worktrees = worktrees; + view.is_loading_worktrees = false; + cx.notify(); + }); + }) + .detach(); + } + + fn load_repositories(&mut self, cx: &mut Context) { + self.is_discovering_repos = true; + let client = self.client.clone(); + let root = self.repo_path.clone(); + cx.spawn(async move |this, cx| { + let task = client.execute::( + &cx, + "workspace.repositories", + serde_json::json!({ "root": root }), + ); + let value = task.await.unwrap_or(serde_json::Value::Null); + let mut repositories: Vec = value + .get("repositories") + .and_then(|r| r.as_array()) .map(|arr| { arr.iter() .filter_map(|item| { - let path = item - .get("path") + item.get("path") .and_then(|p| p.as_str()) - .unwrap_or("") - .to_string(); - if path.is_empty() { - return None; - } - Some(WorktreeInfo { - path, - branch: item - .get("branch") - .and_then(|b| b.as_str()) - .map(|s| s.to_string()), - is_current: item - .get("isCurrent") - .and_then(|b| b.as_bool()) - .unwrap_or(false), - }) + .map(|s| s.to_string()) }) .collect() }) .unwrap_or_default(); + repositories.sort(); + repositories.dedup(); let _ = this.update(cx, |view, cx| { - view.branches = branches; - view.worktrees = worktrees; - view.error = None; + view.repositories = repositories; + view.is_discovering_repos = false; cx.notify(); }); }) @@ -286,47 +383,168 @@ impl BranchManagerView { .detach(); } - /// 当前 tab 下按 query 过滤后的分支(大小写不敏感包含)。 + /// 创建工作树(新分支模式)。失败只显示错误行。 + fn create_worktree(&mut self, destination: String, cx: &mut Context) { + let destination = destination.trim().to_string(); + if self.busy || destination.is_empty() { + return; + } + self.busy = true; + self.error = None; + cx.notify(); + let client = self.client.clone(); + // 对齐 Windows:无显式分支时用 newBranch 模式,名称取目标目录名。 + let name = folder_name(&destination); + let payload = git_write_payload( + &self.repo_path, + "createWorktree", + serde_json::json!({ + "destination": destination, + "worktreeMode": "newBranch", + "noCheckout": false, + "name": name, + }), + ); + cx.spawn(async move |this, cx| { + let task = + client.execute::(&cx, "git.write", payload); + match task.await { + Ok(_) => { + let _ = this.update(cx, |view, cx| { + view.busy = false; + view.load_worktrees(cx); + }); + } + Err(err) => { + let _ = this.update(cx, |view, cx| { + view.busy = false; + view.error = Some(err); + cx.notify(); + }); + } + } + }) + .detach(); + } + + /// 当前 tab 下命令列表的条目数(含首行“创建”项)。 + fn command_len(&self) -> usize { + match self.active_tab { + BranchTab::Branches => { + let create = usize::from(self.create_branch_name().is_some()); + self.filtered_branches().len() + create + } + BranchTab::Worktrees => { + let create = usize::from(self.create_worktree_path().is_some()); + self.filtered_worktrees().len() + create + } + BranchTab::Repositories => self.repo_rows().len(), + } + } + + /// 上下移动选择:夹紧在 `[0, len-1]`,不循环(对齐 `moveCommandListIndex`)。 + fn move_selection(&mut self, delta: i32, cx: &mut Context) { + let len = self.command_len(); + self.selected_index = if delta > 0 { + move_index(self.selected_index, len, true) + } else { + self.selected_index = clamp_index(self.selected_index, len); + move_index(self.selected_index, len, false) + }; + cx.notify(); + } + + /// 执行当前选中项(对齐 `handleCommandSelect`)。 + fn activate_selection(&mut self, cx: &mut Context) { + let index = self.selected_index; + match self.active_tab { + BranchTab::Branches => { + let has_create = self.create_branch_name().is_some(); + if has_create && index == 0 { + if let Some(name) = self.create_branch_name() { + self.create_and_checkout(name, cx); + } + return; + } + let offset = index.saturating_sub(usize::from(has_create)); + if let Some(branch) = self.filtered_branches().get(offset).cloned() { + self.checkout(branch, cx); + } + } + BranchTab::Worktrees => { + let has_create = self.create_worktree_path().is_some(); + if has_create && index == 0 { + if let Some(path) = self.create_worktree_path() { + self.create_worktree(path, cx); + } + return; + } + let offset = index.saturating_sub(usize::from(has_create)); + if let Some(info) = self.filtered_worktrees().get(offset).cloned() { + self.open_worktree(info, cx); + } + } + BranchTab::Repositories => { + if let Some(path) = self.repo_rows().get(index).cloned() { + self.select_repository(path, cx); + } + } + } + } + + fn open_worktree(&mut self, info: WorktreeInfo, cx: &mut Context) { + if info.path == self.repo_path { + return; + } + cx.emit(BranchManagerEvent::OpenWorktree(info.path)); + } + + fn select_repository(&mut self, path: String, cx: &mut Context) { + if path == self.repo_path { + return; + } + cx.emit(BranchManagerEvent::SelectRepository(path)); + } + fn filtered_branches(&self) -> Vec { - let q = self.query.trim().to_lowercase(); - self.branches - .iter() - .filter(|b| q.is_empty() || b.to_lowercase().contains(&q)) - .cloned() - .collect() + let current = self.current_branch.clone().unwrap_or_default(); + filtered_branches(&self.branches, ¤t, &self.query) } - /// 当前 tab 下按 query 过滤后的工作树(按分支名或路径匹配)。 fn filtered_worktrees(&self) -> Vec { - let q = self.query.trim().to_lowercase(); - self.worktrees - .iter() - .filter(|w| { - q.is_empty() - || w.path.to_lowercase().contains(&q) - || w.branch - .as_deref() - .unwrap_or("") - .to_lowercase() - .contains(&q) - }) - .cloned() - .collect() + filtered_worktrees(&self.worktrees, &self.repo_path, &self.query) } - /// 当前仓库在 repositories tab 下是否匹配 query。 - fn repo_matches(&self) -> bool { - let q = self.query.trim().to_lowercase(); - q.is_empty() - || self.repo_path.to_lowercase().contains(&q) - || repo_name(&self.repo_path).to_lowercase().contains(&q) + /// 仓库列表:core 未返回时退回展示当前仓库(单仓库场景)。 + fn repo_rows(&self) -> Vec { + let rows = filtered_repositories(&self.repositories, Some(&self.repo_path), &self.query); + if rows.is_empty() + && self.repositories.is_empty() + && !self.repo_path.is_empty() + && query_matches(&self.query, &[&folder_name(&self.repo_path)]) + { + return vec![self.repo_path.clone()]; + } + rows + } + + fn create_branch_name(&self) -> Option { + let current = self.current_branch.clone().unwrap_or_default(); + create_branch_name(&self.branches, ¤t, &self.query) + } + + fn create_worktree_path(&self) -> Option { + create_worktree_path(&self.worktrees, &self.query) } /// 计数行文案(`{count}` 占位在渲染时替换,单复数按英文区分键)。 fn count_text(&self, cx: &gpui_kit::App) -> String { let (key, count) = match self.active_tab { BranchTab::Repositories => { - let n = usize::from(self.repo_matches()); + let n = self + .repositories + .len() + .max(usize::from(!self.repo_path.is_empty())); ( if n == 1 { "git.repositoryCount" @@ -337,7 +555,7 @@ impl BranchManagerView { ) } BranchTab::Branches => { - let n = self.filtered_branches().len(); + let n = self.branches.len(); ( if n == 1 { "git.branchCount" @@ -348,7 +566,7 @@ impl BranchManagerView { ) } BranchTab::Worktrees => { - let n = self.filtered_worktrees().len(); + let n = self.worktrees.len(); ( if n == 1 { "git.worktreeCount" @@ -363,6 +581,40 @@ impl BranchManagerView { } } +/// 解析 core `git.worktrees` 的单条记录;path 缺失时返回 `None`。 +fn parse_worktree(item: &serde_json::Value) -> Option { + let path = item.get("path").and_then(|p| p.as_str())?.to_string(); + if path.is_empty() { + return None; + } + let branch = item + .get("branch") + .and_then(|b| b.as_str()) + .map(|s| s.trim_start_matches("refs/heads/").to_string()); + let bool_of = |key: &str| item.get(key).and_then(|b| b.as_bool()).unwrap_or(false); + Some(WorktreeInfo { + path, + head: item + .get("head") + .and_then(|h| h.as_str()) + .unwrap_or("") + .to_string(), + branch, + is_current: bool_of("isCurrent"), + is_primary: bool_of("isPrimary"), + is_bare: bool_of("isBare"), + is_detached: bool_of("isDetached"), + is_locked: bool_of("isLocked"), + // prunable:core 用 isPrunable + pruneReason,任一存在即视为不可打开。 + is_prunable: bool_of("isPrunable") + || item + .get("pruneReason") + .and_then(|r| r.as_str()) + .map(|s| !s.trim().is_empty()) + .unwrap_or(false), + }) +} + /// 构造 `git.write` 全字段 camelCase payload(String 无值传 null,bool 传 false)。 fn git_write_payload(root: &str, operation: &str, extra: serde_json::Value) -> serde_json::Value { let mut payload = serde_json::json!({ @@ -378,6 +630,8 @@ fn git_write_payload(root: &str, operation: &str, extra: serde_json::Value) -> s "remote": null, "destination": null, "mode": null, + "worktreeMode": null, + "noCheckout": false, "includeUntracked": false, "checkout": false, "amend": false, @@ -392,32 +646,25 @@ fn git_write_payload(root: &str, operation: &str, extra: serde_json::Value) -> s payload } -/// 仓库显示名:路径最后一段。 -fn repo_name(path: &str) -> String { - path.rsplit('/').next().unwrap_or(path).to_string() -} - impl Render for BranchManagerView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { window.focus(&self.focus_handle, cx); let active_tab = self.active_tab; - let placeholder: String = - crate::i18n::menu_text(cx, active_tab.placeholder_key()).to_string(); let count_text = self.count_text(cx); let busy = self.busy; - // 分支 tab:精确匹配存在时不展示建分支行。 - let trimmed_query = self.query.trim().to_string(); - let has_exact_match = - !trimmed_query.is_empty() && self.branches.iter().any(|b| *b == trimmed_query); - let show_create_row = - active_tab == BranchTab::Branches && !trimmed_query.is_empty() && !has_exact_match; - let create_label = if show_create_row { - crate::i18n::menu_text(cx, "git.createNewBranch").replace("{name}", &trimmed_query) - } else { - String::new() - }; + // 首行“创建”项:分支 tab 用建名,工作树 tab 用建路径。 + let create_branch = self.create_branch_name(); + let create_branch_label = create_branch + .as_ref() + .map(|name| crate::i18n::menu_text(cx, "git.createNewBranch").replace("{name}", name)) + .unwrap_or_default(); + let create_worktree = self.create_worktree_path(); + let create_worktree_label = create_worktree + .as_ref() + .map(|path| crate::i18n::menu_text(cx, "git.createWorktree").replace("{path}", path)) + .unwrap_or_default(); div() .id("branch-manager-backdrop") @@ -429,40 +676,13 @@ impl Render for BranchManagerView { .items_center() .justify_center() .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { - let key = event.keystroke.key.as_str(); - match key { - "escape" => { - cx.emit(BranchManagerEvent::Close); - } - "backspace" => { - this.query.pop(); - this.error = None; - cx.notify(); - } - "space" => { - this.query.push(' '); - this.error = None; - cx.notify(); - } - _ => { - if !event.keystroke.modifiers.control - && !event.keystroke.modifiers.alt - && !event.keystroke.modifiers.platform - { - let mut changed = false; - if let Some(ch) = &event.keystroke.key_char { - this.query.push_str(ch); - changed = true; - } else if key.chars().count() == 1 { - this.query.push_str(key); - changed = true; - } - if changed { - this.error = None; - cx.notify(); - } - } - } + // 字符输入 / 退格由内层 `Input` 处理(含 IME 与粘贴); + // 这里接管方向键与 Esc。 + match event.keystroke.key.as_str() { + "escape" => cx.emit(BranchManagerEvent::Close), + "up" => this.move_selection(-1, cx), + "down" => this.move_selection(1, cx), + _ => {} } })) .on_mouse_down( @@ -489,7 +709,7 @@ impl Render for BranchManagerView { }), ) .child(self.render_tabs(cx)) - .child(self.render_search_row(placeholder)) + .child(self.render_search_row(cx)) .child( div() .w_full() @@ -499,7 +719,8 @@ impl Render for BranchManagerView { .text_color(ThemeColors::subtle_foreground()) .child(count_text), ) - .child(self.render_list(show_create_row, create_label, busy, cx)) + .child(self.render_list(create_branch_label, create_worktree_label, busy, cx)) + .child(self.render_footer(active_tab, busy, cx)) .when(self.error.is_some(), |card| { card.child( div() @@ -541,15 +762,25 @@ impl BranchManagerView { .ghost() .label(label) .selected(selected) - .on_click(cx.listener(move |this, _event, _window, cx| { + .on_click(cx.listener(move |this, _event, window, cx| { this.active_tab = tab; + this.selected_index = 0; + // 切 tab 后刷新占位与焦点(对齐 `handleTabChange`)。 + let placeholder: gpui_kit::SharedString = + crate::i18n::menu_text(cx, tab.placeholder_key()).into(); + this.search_input.update(cx, |state, cx| { + state.set_placeholder(placeholder, window, cx); + state.focus(window, cx); + }); cx.notify(); })) })) } - /// 搜索行:图标 + 查询串/占位 + 静态光标(仿 quick_open 输入行)。 - fn render_search_row(&self, placeholder: String) -> impl IntoElement { + /// 搜索行:图标 + 真实 `Input`(IME / 粘贴由组件处理)。 + /// `appearance(false)` 关闭组件自带背景/边框/焦点环,保持与外层卡片一致。 + fn render_search_row(&self, cx: &mut Context) -> impl IntoElement { + let _ = cx; h_flex() .h(px(48.0)) .w_full() @@ -564,38 +795,25 @@ impl BranchManagerView { .text_color(ThemeColors::primary()), ) .child( - h_flex() + div() .flex_1() - .items_center() - .gap_1() - .child( - div() - .text_sm() - .text_color(if self.query.is_empty() { - ThemeColors::subtle_foreground() - } else { - ThemeColors::foreground() - }) - .child(if self.query.is_empty() { - placeholder - } else { - self.query.clone() - }), - ) - .child(div().w(px(2.0)).h(px(16.0)).bg(ThemeColors::primary())), + .min_w_0() + .text_sm() + .child(Input::new(&self.search_input).appearance(false)), ) } /// 按当前 tab 渲染滚动列表。 fn render_list( &self, - show_create_row: bool, - create_label: String, + create_branch_label: String, + create_worktree_label: String, busy: bool, cx: &mut Context, ) -> impl IntoElement { let tab = self.active_tab; let query_empty = self.query.trim().is_empty(); + let selected_index = self.selected_index; div() .flex_1() .w_full() @@ -603,63 +821,235 @@ impl BranchManagerView { .py_1() .when(tab == BranchTab::Branches, |list| { let filtered = self.filtered_branches(); - let empty_key = if query_empty { - "git.noBranchesFound" - } else { - "git.noMatchingBranches" - }; - let empty_text: String = crate::i18n::menu_text(cx, empty_key).to_string(); + let has_create = !create_branch_label.is_empty(); + let empty_text: String = crate::i18n::menu_text( + cx, + if query_empty { + "git.noBranchesFound" + } else { + "git.noMatchingBranches" + }, + ) + .to_string(); let current = self.current_branch.clone(); - list.when(show_create_row, |list| { - list.child(create_branch_row(create_label, busy, cx)) + let create_label = create_branch_label.clone(); + list.when(has_create, |list| { + list.child(create_row( + "branch-manager-create-branch", + create_label.clone(), + selected_index == 0, + busy, + cx, + |this, cx| { + if let Some(name) = this.create_branch_name() { + this.create_and_checkout(name, cx); + } + }, + )) }) - .when(filtered.is_empty(), |list| { - list.child(empty_row(empty_text)) + .when(filtered.is_empty() && !has_create, |list| { + list.child(empty_row(empty_text.clone())) }) - .children( - filtered - .into_iter() - .enumerate() - .map(|(ix, name)| branch_row(ix, name, current.clone(), busy, cx)), - ) + .children(filtered.into_iter().enumerate().map(|(ix, name)| { + let row_index = ix + usize::from(has_create); + branch_row( + ix, + name, + current.clone(), + row_index == selected_index, + busy, + cx, + ) + })) }) .when(tab == BranchTab::Worktrees, |list| { let filtered = self.filtered_worktrees(); - let empty_text: String = - crate::i18n::menu_text(cx, "git.noMatchingBranches").to_string(); - list.when(filtered.is_empty(), |list| { - list.child(empty_row(empty_text)) + let has_create = !create_worktree_label.is_empty(); + let no_branch = crate::i18n::menu_text(cx, "git.noBranch").to_string(); + let detached = crate::i18n::menu_text(cx, "git.detachedHead").to_string(); + let empty_text: String = if self.is_loading_worktrees { + crate::i18n::menu_text(cx, "git.loadingWorktrees").to_string() + } else if query_empty { + crate::i18n::menu_text(cx, "git.noWorktreesFound").to_string() + } else { + crate::i18n::menu_text(cx, "git.noMatchingWorktrees").to_string() + }; + let repo_path = self.repo_path.clone(); + let create_label = create_worktree_label.clone(); + list.when(has_create, |list| { + list.child(create_row( + "branch-manager-create-worktree", + create_label.clone(), + selected_index == 0, + busy || self.is_loading_worktrees, + cx, + |this, cx| { + if let Some(path) = this.create_worktree_path() { + this.create_worktree(path, cx); + } + }, + )) }) - .children( - filtered - .into_iter() - .enumerate() - .map(|(ix, info)| worktree_row(ix, info, busy, cx)), - ) + .when(filtered.is_empty() && !has_create, |list| { + list.child(empty_row(empty_text.clone())) + }) + .children(filtered.into_iter().enumerate().map(|(ix, info)| { + let row_index = ix + usize::from(has_create); + worktree_row( + ix, + info, + &repo_path, + &detached, + &no_branch, + row_index == selected_index, + busy, + cx, + ) + })) }) .when(tab == BranchTab::Repositories, |list| { - if self.repo_matches() { - list.child(repository_row( - repo_name(&self.repo_path), - self.repo_path.clone(), - )) + let rows = self.repo_rows(); + let empty_text: String = if self.is_discovering_repos && rows.is_empty() { + crate::i18n::menu_text(cx, "git.detectingRepositories").to_string() + } else if query_empty { + crate::i18n::menu_text(cx, "git.noRepositoriesFound").to_string() } else { - let empty_text: String = - crate::i18n::menu_text(cx, "git.noMatchingBranches").to_string(); + crate::i18n::menu_text(cx, "git.noMatchingRepositories").to_string() + }; + if rows.is_empty() { list.child(empty_row(empty_text)) + } else { + let workspace_root = self.workspace_root.clone(); + list.children(rows.into_iter().enumerate().map(|(ix, path)| { + let is_current = path == self.repo_path; + repository_row(path, &workspace_root, is_current, ix == selected_index, cx) + })) } }) } + + /// 底部动作行:按 tab 提供 新建 / 刷新 / 添加。 + fn render_footer( + &self, + tab: BranchTab, + busy: bool, + cx: &mut Context, + ) -> impl IntoElement { + let new_branch = crate::i18n::menu_text(cx, "git.newBranch").to_string(); + let refresh = crate::i18n::menu_text(cx, "git.refresh").to_string(); + let create_worktree = crate::i18n::menu_text(cx, "git.worktreeDialog.manage").to_string(); + let add = crate::i18n::menu_text(cx, "git.add").to_string(); + let can_create_branch = self.create_branch_name().is_some(); + let can_create_worktree = self.create_worktree_path().is_some(); + let loading_worktrees = self.is_loading_worktrees; + let discovering = self.is_discovering_repos; + h_flex() + .w_full() + .items_center() + .gap_1() + .px_3() + .py_2() + .border_t_1() + .border_color(ThemeColors::border()) + .when(tab == BranchTab::Branches, |footer| { + footer + .child(footer_action( + "branch-manager-footer-new", + IconName::Plus, + new_branch, + busy || !can_create_branch, + cx, + |this, cx| { + if let Some(name) = this.create_branch_name() { + this.create_and_checkout(name, cx); + } + }, + )) + .child(footer_action( + "branch-manager-footer-refresh-branches", + IconName::RotateCw, + refresh.clone(), + busy, + cx, + |this, cx| this.load_branches(cx), + )) + }) + .when(tab == BranchTab::Worktrees, |footer| { + footer + .child(footer_action( + "branch-manager-footer-worktree", + IconName::Plus, + create_worktree, + busy || loading_worktrees || !can_create_worktree, + cx, + |this, cx| { + if let Some(path) = this.create_worktree_path() { + this.create_worktree(path, cx); + } + }, + )) + .child(footer_action( + "branch-manager-footer-refresh-worktrees", + IconName::RotateCw, + refresh.clone(), + busy || loading_worktrees, + cx, + |this, cx| this.load_worktrees(cx), + )) + }) + .when(tab == BranchTab::Repositories, |footer| { + footer + .child(footer_action( + "branch-manager-footer-add", + IconName::Plus, + add, + busy || discovering, + cx, + |this, cx| this.load_repositories(cx), + )) + .child(footer_action( + "branch-manager-footer-refresh-repos", + IconName::RotateCw, + refresh.clone(), + busy || discovering, + cx, + |this, cx| this.load_repositories(cx), + )) + }) + } +} + +/// 底部动作按钮。 +fn footer_action( + id: &'static str, + icon: IconName, + label: String, + disabled: bool, + cx: &mut Context, + on_click: fn(&mut BranchManagerView, &mut Context), +) -> impl IntoElement { + Button::new(id) + .small() + .ghost() + .disabled(disabled) + .icon(icon) + .label(label) + .on_click(cx.listener(move |this, _event, _window, cx| { + on_click(this, cx); + })) } -/// 建分支首行(点击从搜索串建分支并切过去)。 -fn create_branch_row( +/// 首行“创建”项(分支 / 工作树共用)。 +fn create_row( + id: &'static str, label: String, + selected: bool, busy: bool, cx: &mut Context, + on_click: fn(&mut BranchManagerView, &mut Context), ) -> impl IntoElement { h_flex() - .id("branch-manager-create") + .id(id) .h(px(32.0)) .w_full() .mx_2() @@ -668,6 +1058,7 @@ fn create_branch_row( .gap_2() .rounded_md() .cursor_pointer() + .when(selected, |row| row.bg(ThemeColors::accent())) .hover(|h| h.bg(ThemeColors::accent())) .child( Icon::new(IconName::Plus) @@ -681,26 +1072,27 @@ fn create_branch_row( .child( div() .flex_1() + .truncate() .text_sm() .font_weight(FontWeight::MEDIUM) .text_color(ThemeColors::foreground()) .child(label), ) - .on_click(cx.listener(|this, _event, _window, cx| { - if this.busy { + .on_click(cx.listener(move |this, _event, _window, cx| { + if busy { return; } - let name = this.query.trim().to_string(); - this.create_and_checkout(name, cx); + on_click(this, cx); })) .into_any_element() } -/// 分支行:名 + 当前分支打勾,点击检出。 +/// 分支行:当前分支打勾、非当前显示分支图标,点击检出;选中态高亮。 fn branch_row( ix: usize, name: String, current: Option, + selected: bool, busy: bool, cx: &mut Context, ) -> gpui_kit::AnyElement { @@ -716,15 +1108,20 @@ fn branch_row( .gap_2() .rounded_md() .cursor_pointer() + .when(selected, |row| row.bg(ThemeColors::accent())) .hover(|h| h.bg(ThemeColors::accent())) .child( - Icon::new(IconName::GitBranch) - .size(px(14.0)) - .text_color(if is_current { - ThemeColors::primary() - } else { - ThemeColors::muted_foreground() - }), + Icon::new(if is_current { + IconName::Check + } else { + IconName::GitBranch + }) + .size(px(14.0)) + .text_color(if is_current { + ThemeColors::success() + } else { + ThemeColors::muted_foreground() + }), ) .child( div() @@ -737,9 +1134,10 @@ fn branch_row( ) .when(is_current, |row| { row.child( - Icon::new(IconName::Check) - .size(px(14.0)) - .text_color(ThemeColors::primary()), + div() + .text_xs() + .text_color(ThemeColors::success()) + .child(crate::i18n::menu_text(cx, "git.current").to_string()), ) }) .on_click(cx.listener(move |this, _event, _window, cx| { @@ -751,16 +1149,21 @@ fn branch_row( .into_any_element() } -/// 工作树行:分支名(无分支显示路径)+ 路径副行 + 当前打勾,点击打开对应项目。 +/// 工作树行:目录名 + 分支标签(无分支显示 detached / no branch)副行,当前打勾。 fn worktree_row( ix: usize, info: WorktreeInfo, + repo_path: &str, + detached: &str, + no_branch: &str, + selected: bool, busy: bool, cx: &mut Context, ) -> gpui_kit::AnyElement { let path = info.path.clone(); - let title = info.branch.clone().unwrap_or_else(|| info.path.clone()); - let is_current = info.is_current; + let title = folder_name(&info.path); + let label = worktree_label(&info, detached, no_branch); + let is_current = info.is_current || info.path == repo_path; h_flex() .id(("branch-manager-worktree", ix)) .h(px(40.0)) @@ -771,11 +1174,20 @@ fn worktree_row( .gap_2() .rounded_md() .cursor_pointer() + .when(selected, |row| row.bg(ThemeColors::accent())) .hover(|h| h.bg(ThemeColors::accent())) .child( - Icon::new(IconName::Folder) - .size(px(14.0)) - .text_color(ThemeColors::muted_foreground()), + Icon::new(if is_current { + IconName::Check + } else { + IconName::Folder + }) + .size(px(14.0)) + .text_color(if is_current { + ThemeColors::success() + } else { + ThemeColors::muted_foreground() + }), ) .child( v_flex() @@ -791,23 +1203,25 @@ fn worktree_row( .child(title), ) .child( - div() - .w_full() - .truncate() - .text_xs() - .text_color(ThemeColors::subtle_foreground()) - .child(info.path.clone()), + h_flex() + .gap_1() + .child( + Icon::new(IconName::GitBranch) + .size(px(12.0)) + .text_color(ThemeColors::subtle_foreground()), + ) + .child( + div() + .flex_1() + .truncate() + .text_xs() + .text_color(ThemeColors::subtle_foreground()) + .child(label), + ), ), ) - .when(is_current, |row| { - row.child( - Icon::new(IconName::Check) - .size(px(14.0)) - .text_color(ThemeColors::primary()), - ) - }) - .on_click(cx.listener(move |_this, _event, _window, cx| { - if busy { + .on_click(cx.listener(move |this, _event, _window, cx| { + if busy || path == this.repo_path { return; } cx.emit(BranchManagerEvent::OpenWorktree(path.clone())); @@ -815,8 +1229,26 @@ fn worktree_row( .into_any_element() } -/// 仓库行:当前仓库名 + 路径 + 打勾,点击无动作。 -fn repository_row(name: String, path: String) -> gpui_kit::AnyElement { +/// 仓库行:仓库名 + 相对路径 + 当前打勾,点击切换。 +fn repository_row( + full_path: String, + workspace_root: &str, + is_current: bool, + selected: bool, + cx: &mut Context, +) -> gpui_kit::AnyElement { + let title = folder_name(&full_path); + // Windows 用 workspaceRootPath 计算相对路径;根为空时回退完整路径。 + let description = if workspace_root.is_empty() { + full_path.clone() + } else { + let rel = relative_path(&full_path, workspace_root); + if rel.is_empty() { + full_path.clone() + } else { + rel + } + }; h_flex() .id("branch-manager-repo") .h(px(40.0)) @@ -826,10 +1258,21 @@ fn repository_row(name: String, path: String) -> gpui_kit::AnyElement { .items_center() .gap_2() .rounded_md() + .cursor_pointer() + .when(selected, |row| row.bg(ThemeColors::accent())) + .hover(|h| h.bg(ThemeColors::accent())) .child( - Icon::new(IconName::Folder) - .size(px(14.0)) - .text_color(ThemeColors::primary()), + Icon::new(if is_current { + IconName::Check + } else { + IconName::Folder + }) + .size(px(14.0)) + .text_color(if is_current { + ThemeColors::success() + } else { + ThemeColors::muted_foreground() + }), ) .child( v_flex() @@ -842,7 +1285,7 @@ fn repository_row(name: String, path: String) -> gpui_kit::AnyElement { .text_sm() .font_weight(FontWeight::MEDIUM) .text_color(ThemeColors::foreground()) - .child(name), + .child(title), ) .child( div() @@ -850,14 +1293,23 @@ fn repository_row(name: String, path: String) -> gpui_kit::AnyElement { .truncate() .text_xs() .text_color(ThemeColors::subtle_foreground()) - .child(path), + .child(description), ), ) - .child( - Icon::new(IconName::Check) - .size(px(14.0)) - .text_color(ThemeColors::primary()), - ) + .when(is_current, |row| { + row.child( + div() + .text_xs() + .text_color(ThemeColors::success()) + .child(crate::i18n::menu_text(cx, "git.current").to_string()), + ) + }) + .on_click(cx.listener(move |this, _event, _window, cx| { + if full_path == this.repo_path { + return; + } + cx.emit(BranchManagerEvent::SelectRepository(full_path.clone())); + })) .into_any_element() } diff --git a/linux/src/workbench/branch_manager_logic.rs b/linux/src/workbench/branch_manager_logic.rs new file mode 100644 index 000000000..8ddcf0123 --- /dev/null +++ b/linux/src/workbench/branch_manager_logic.rs @@ -0,0 +1,427 @@ +//! 分支管理器(仓库 / 分支 / 工作树)的纯逻辑层:排序、过滤、标签与建名规则。 +//! +//! 真源对应关系(1:1 复刻 Windows,不臆造语义): +//! - `filtered_branches` / `create_branch_name` 抄自 +//! `windows/tauri/src/features/git/components/git-branch-manager.tsx` 的 +//! `getFilteredBranches` / `getCreateBranchName`(当前分支置顶,其余 +//! `localeCompare`;查询用 `matchesSearchQuery`)。 +//! - `filtered_worktrees` / `is_openable_worktree` / `worktree_label` 抄自 +//! 同文件的 `getFilteredWorktrees` / `getBranchLabel` 与 +//! `windows/tauri/src/features/git/utils/git-worktree-open.ts`。 +//! - `filtered_repositories` 抄自 `getFilteredRepositoryPaths`。 +//! - 查询匹配抄自 `windows/tauri/src/utils/search-match.ts` 的 +//! `matchesSearchQuery`:NFKD 去音标 + 小写 + 非字母数字折叠为空格, +//! 再按“规范化包含”或“压缩包含”(去空格)匹配。 +//! +//! 这里不触碰 gpui 与 IO,便于用确定性单测锁定行为。 + +/// `clampCommandListIndex`:把下标夹紧到 `[0, itemCount-1]`(空列表为 0)。 +pub fn clamp_index(index: usize, item_count: usize) -> usize { + index.min(item_count.saturating_sub(1)) +} + +/// `moveCommandListIndex`:上下移动一格并夹紧,不循环。 +pub fn move_index(index: usize, item_count: usize, forward: bool) -> usize { + let current = clamp_index(index, item_count); + if forward { + clamp_index(current.saturating_add(1), item_count) + } else { + current.saturating_sub(1) + } +} + +/// `collectFileTreeSearchHits` 之类的查询工具模块入口(供 view 复用同一套匹配)。 +pub fn query_matches(query: &str, candidates: &[&str]) -> bool { + matches_search_query(query, candidates) +} + +/// 工作树列表项(字段形状对齐 core `git.worktrees` 的 camelCase 返回)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorktreeInfo { + pub path: String, + pub head: String, + pub branch: Option, + pub is_current: bool, + pub is_primary: bool, + pub is_bare: bool, + pub is_detached: bool, + pub is_locked: bool, + pub is_prunable: bool, +} + +/// 路径最后一段(`getFolderName` 的简化版:只按 `/` 切分)。 +pub fn folder_name(path: &str) -> String { + let trimmed = path.trim_end_matches('/'); + trimmed + .rsplit('/') + .next() + .filter(|s| !s.is_empty()) + .unwrap_or(trimmed) + .to_string() +} + +/// `getRelativePath`:`path` 在 `root` 之下时返回相对路径,否则返回原串。 +pub fn relative_path(path: &str, root: &str) -> String { + if root.is_empty() { + return path.to_string(); + } + let path_trimmed = path.trim_end_matches('/'); + let root_trimmed = root.trim_end_matches('/'); + if path_trimmed == root_trimmed { + return String::new(); + } + let prefix = format!("{root_trimmed}/"); + match path_trimmed.strip_prefix(&prefix) { + Some(rest) => rest.to_string(), + None => path.to_string(), + } +} + +/// `normalizeSearchText`:NFKD 去音标、小写、非字母数字折叠为空格、去首尾空格。 +fn normalize_search_text(value: &str) -> String { + // Rust 标准库没有 NFKD;这里做等价的可打印折叠:小写后把非 + // ASCII 字母数字与 ASCII 字母数字以外的字符统一替换为空格。 + // 对仓库/分支/路径这类标识符,迁移音标(é→e)退化到“保留原字符”, + // 再折叠为空格,行为与 Windows 在纯 ASCII 输入上一致。 + let lowered = value.to_lowercase(); + let mut out = String::with_capacity(lowered.len()); + let mut last_space = false; + for ch in lowered.chars() { + if ch.is_ascii_alphanumeric() || ch.is_alphanumeric() { + out.push(ch); + last_space = false; + } else if !last_space { + out.push(' '); + last_space = true; + } + } + out.trim().to_string() +} + +/// `compactSearchText`:规范化后去掉所有空格。 +fn compact_search_text(value: &str) -> String { + normalize_search_text(value).replace(' ', "") +} + +/// `matchesSearchQuery`:查询为空恒真;否则任一候选字段规范化/压缩后包含查询。 +pub fn matches_search_query(query: &str, candidates: &[&str]) -> bool { + let normalized = normalize_search_text(query); + if normalized.is_empty() { + return true; + } + let compact = compact_search_text(query); + candidates.iter().any(|candidate| { + normalize_search_text(candidate).contains(&normalized) + || compact_search_text(candidate).contains(&compact) + }) +} + +/// `getFilteredBranches`:当前分支置顶,其余按大小写不敏感名称排序(模拟 +/// `localeCompare`),再按查询过滤。 +pub fn filtered_branches(branches: &[String], current: &str, query: &str) -> Vec { + let mut sorted = branches.to_vec(); + sorted.sort_by(|a, b| { + if a == current { + return std::cmp::Ordering::Less; + } + if b == current { + return std::cmp::Ordering::Greater; + } + a.to_lowercase().cmp(&b.to_lowercase()) + }); + sorted.dedup(); + + let normalized = query.trim(); + if normalized.is_empty() { + return sorted; + } + sorted + .into_iter() + .filter(|branch| matches_search_query(normalized, &[branch.as_str()])) + .collect() +} + +/// `getCreateBranchName`:查询非空、不等于当前分支、且不存在同名分支时返回建名。 +pub fn create_branch_name(branches: &[String], current: &str, query: &str) -> Option { + let trimmed = query.trim(); + if trimmed.is_empty() || trimmed == current { + return None; + } + if branches + .iter() + .any(|branch| branch.to_lowercase() == trimmed.to_lowercase()) + { + return None; + } + Some(trimmed.to_string()) +} + +/// `isOpenableGitWorktree`:非 bare、非 prunable 才可打开。 +pub fn is_openable_worktree(worktree: &WorktreeInfo) -> bool { + !worktree.is_bare && !worktree.is_prunable +} + +/// `getBranchLabel`:分支名;无分支时按 detached / no branch 显示。 +pub fn worktree_label( + worktree: &WorktreeInfo, + detached_text: &str, + no_branch_text: &str, +) -> String { + match &worktree.branch { + Some(branch) if !branch.is_empty() => branch.clone(), + _ => { + if worktree.is_detached { + detached_text.to_string() + } else { + no_branch_text.to_string() + } + } + } +} + +/// `getFilteredWorktrees`:过滤掉不可打开的项,当前工作树置顶,其余按目录名 +/// 排序,再按目录名/路径/分支/短 head 过滤。 +pub fn filtered_worktrees( + worktrees: &[WorktreeInfo], + repo_path: &str, + query: &str, +) -> Vec { + let mut sorted: Vec = worktrees + .iter() + .filter(|worktree| is_openable_worktree(worktree)) + .cloned() + .collect(); + sorted.sort_by(|a, b| { + if a.path == repo_path { + return std::cmp::Ordering::Less; + } + if b.path == repo_path { + return std::cmp::Ordering::Greater; + } + folder_name(&a.path) + .to_lowercase() + .cmp(&folder_name(&b.path).to_lowercase()) + }); + + let normalized = query.trim(); + if normalized.is_empty() { + return sorted; + } + sorted + .into_iter() + .filter(|worktree| { + let short_head: String = worktree.head.chars().take(7).collect(); + let branch = worktree.branch.clone().unwrap_or_default(); + matches_search_query( + normalized, + &[ + &folder_name(&worktree.path), + &worktree.path, + &branch, + &short_head, + ], + ) + }) + .collect() +} + +/// `getCreateWorktreePath`:查询非空且不与现有工作树路径重复时返回建路径。 +pub fn create_worktree_path(worktrees: &[WorktreeInfo], query: &str) -> Option { + let trimmed = query.trim(); + if trimmed.is_empty() { + return None; + } + if worktrees.iter().any(|worktree| worktree.path == trimmed) { + return None; + } + Some(trimmed.to_string()) +} + +/// `getFilteredRepositoryPaths`:当前仓库置顶,其余按目录名排序,再按目录名/路径过滤。 +pub fn filtered_repositories( + repositories: &[String], + active_repo_path: Option<&str>, + query: &str, +) -> Vec { + let mut sorted = repositories.to_vec(); + sorted.sort_by(|a, b| { + if Some(a.as_str()) == active_repo_path { + return std::cmp::Ordering::Less; + } + if Some(b.as_str()) == active_repo_path { + return std::cmp::Ordering::Greater; + } + folder_name(a) + .to_lowercase() + .cmp(&folder_name(b).to_lowercase()) + }); + sorted.dedup(); + + let normalized = query.trim(); + if normalized.is_empty() { + return sorted; + } + sorted + .into_iter() + .filter(|path| matches_search_query(normalized, &[&folder_name(path), path.as_str()])) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn worktree(path: &str, branch: Option<&str>) -> WorktreeInfo { + WorktreeInfo { + path: path.to_string(), + head: "abcdef1234567890".to_string(), + branch: branch.map(|b| b.to_string()), + is_current: false, + is_primary: false, + is_bare: false, + is_detached: false, + is_locked: false, + is_prunable: false, + } + } + + fn branches(items: &[&str]) -> Vec { + items.iter().map(|s| s.to_string()).collect() + } + + /// 当前分支置顶,其余按名称排序(对齐 `getFilteredBranches`)。 + #[test] + fn branches_put_current_first_then_name_order() { + let list = branches(&["zeta", "main", "alpha", "beta"]); + let result = filtered_branches(&list, "main", ""); + assert_eq!(result, branches(&["main", "alpha", "beta", "zeta"])); + } + + /// 分支查询用规范化包含匹配(大小写不敏感)。 + #[test] + fn branches_filter_is_case_insensitive() { + let list = branches(&["feature/login", "main", "Feature/Logout"]); + let result = filtered_branches(&list, "main", "FEATURE"); + assert_eq!(result, branches(&["feature/login", "Feature/Logout"])); + } + + /// 查询等于当前分支时不提供建分支名。 + #[test] + fn create_name_rejects_current_and_existing() { + let list = branches(&["main", "dev"]); + assert_eq!(create_branch_name(&list, "main", "main"), None); + assert_eq!(create_branch_name(&list, "main", "DEV"), None); + assert_eq!(create_branch_name(&list, "main", " "), None); + assert_eq!( + create_branch_name(&list, "main", " feature/x "), + Some("feature/x".to_string()) + ); + } + + /// bare / prunable 工作树不可打开(对齐 `isOpenableGitWorktree`)。 + #[test] + fn worktree_openable_filters_bare_and_prunable() { + let mut bare = worktree("/w/bare", Some("main")); + bare.is_bare = true; + let mut prunable = worktree("/w/gone", Some("dev")); + prunable.is_prunable = true; + assert!(!is_openable_worktree(&bare)); + assert!(!is_openable_worktree(&prunable)); + assert!(is_openable_worktree(&worktree("/w/ok", Some("main")))); + } + + /// 无分支工作树按 detached / no branch 显示。 + #[test] + fn worktree_label_falls_back_when_branch_missing() { + let mut detached = worktree("/w/d", None); + detached.is_detached = true; + assert_eq!( + worktree_label(&detached, "Detached HEAD", "No branch"), + "Detached HEAD" + ); + let plain = worktree("/w/p", None); + assert_eq!( + worktree_label(&plain, "Detached HEAD", "No branch"), + "No branch" + ); + assert_eq!( + worktree_label(&worktree("/w/m", Some("main")), "d", "n"), + "main" + ); + } + + /// 工作树当前项置顶,其余按目录名排序,bare 被过滤。 + #[test] + fn worktrees_sort_and_filter() { + let mut list = vec![ + worktree("/w/zeta", Some("zeta")), + worktree("/w/alpha", Some("alpha")), + worktree("/w/current", Some("main")), + ]; + let mut bare = worktree("/w/bare", Some("bare")); + bare.is_bare = true; + list.push(bare); + let result = filtered_worktrees(&list, "/w/current", ""); + let paths: Vec<&str> = result.iter().map(|w| w.path.as_str()).collect(); + assert_eq!(paths, vec!["/w/current", "/w/alpha", "/w/zeta"]); + } + + /// 工作树查询能按目录名 / 路径 / 分支 / 短 head 命中。 + #[test] + fn worktrees_filter_by_multiple_fields() { + let list = vec![ + worktree("/w/alpha-one", Some("feature/x")), + worktree("/w/beta", Some("dev")), + ]; + assert_eq!(filtered_worktrees(&list, "", "alpha").len(), 1); + assert_eq!(filtered_worktrees(&list, "", "feature").len(), 1); + assert_eq!(filtered_worktrees(&list, "", "abcdef1").len(), 2); + } + + /// 建工作树路径:非空且不重复时才返回。 + #[test] + fn create_worktree_path_rejects_duplicates() { + let list = vec![worktree("/w/alpha", Some("alpha"))]; + assert_eq!(create_worktree_path(&list, ""), None); + assert_eq!(create_worktree_path(&list, "/w/alpha"), None); + assert_eq!( + create_worktree_path(&list, " /w/new "), + Some("/w/new".to_string()) + ); + } + + /// 仓库当前项置顶,其余按目录名排序并可按名称过滤。 + #[test] + fn repositories_sort_and_filter() { + let list = branches(&["/ws/zeta", "/ws/alpha", "/ws/current"]); + let result = filtered_repositories(&list, Some("/ws/current"), ""); + assert_eq!(result, branches(&["/ws/current", "/ws/alpha", "/ws/zeta"])); + assert_eq!( + filtered_repositories(&list, Some("/ws/current"), "zeta"), + branches(&["/ws/zeta"]) + ); + } + + /// 相对路径与目录名工具函数。 + #[test] + fn path_helpers_match_windows_semantics() { + assert_eq!(folder_name("/a/b/c"), "c"); + assert_eq!(folder_name("/a/b/c/"), "c"); + assert_eq!(relative_path("/ws/sub/repo", "/ws"), "sub/repo"); + assert_eq!(relative_path("/ws", "/ws"), ""); + assert_eq!(relative_path("/other/repo", "/ws"), "/other/repo"); + } + + /// 命令列表下标夹紧与移动(对齐 `clampCommandListIndex` / `moveCommandListIndex`)。 + #[test] + fn command_index_clamps_and_moves_without_wrapping() { + // 夹紧:越界回到边界,空列表恒为 0。 + assert_eq!(clamp_index(5, 3), 2); + assert_eq!(clamp_index(0, 0), 0); + // 向下到底停在最后一项,向上到顶停在第一项,不循环。 + assert_eq!(move_index(2, 3, true), 2); + assert_eq!(move_index(0, 3, false), 0); + assert_eq!(move_index(0, 3, true), 1); + assert_eq!(move_index(2, 3, false), 1); + } +} diff --git a/linux/src/workbench/mod.rs b/linux/src/workbench/mod.rs index d1f4430b9..5fc4ecd75 100644 --- a/linux/src/workbench/mod.rs +++ b/linux/src/workbench/mod.rs @@ -1,6 +1,7 @@ pub mod activity_rail; pub mod bottom_panel; pub mod branch_manager; +pub mod branch_manager_logic; pub mod command_palette; pub mod editor; pub mod extensions_panel; diff --git a/linux/src/workbench/sidebar.rs b/linux/src/workbench/sidebar.rs index 2c5d91aa8..5a9c5d088 100644 --- a/linux/src/workbench/sidebar.rs +++ b/linux/src/workbench/sidebar.rs @@ -860,10 +860,11 @@ impl SidebarView { .text_color(ThemeColors::text_muted()), ) .child( - div() - .flex_1() - .min_w_0() - .child(Input::new(&self.tree_search_input).small().appearance(false)), + div().flex_1().min_w_0().child( + Input::new(&self.tree_search_input) + .small() + .appearance(false), + ), ) .child( Button::new("sidebar-filter-clear") diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index 093e9e52a..8360bf676 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -231,7 +231,7 @@ impl WorkbenchView { }); let project_dialog = cx.new(|cx| ProjectDialog::new(cx)); let branch_manager_root = root.clone(); - let branch_manager = cx.new(|cx| BranchManagerView::new(branch_manager_root, cx)); + let branch_manager = cx.new(|cx| BranchManagerView::new(branch_manager_root, window, cx)); let welcome_screen = cx.new(|cx| WelcomeScreenView::new(cx)); let maven = cx.new(|cx| MavenView::new(root.clone(), cx)); let notifications = cx.new(|cx| NotificationsView::new(cx)); @@ -605,8 +605,11 @@ impl WorkbenchView { ToolbarEvent::OpenBranchManager => { let root = this.workspace_root.clone(); let current = this.sidebar.read(cx).git_branch.clone(); + let workspace_root = this.workspace_root.clone(); let _ = this.branch_manager.update(cx, |bm, cx| { bm.set_repo(root, current, cx); + // 仓库副行显示相对工作区根的路径。 + bm.set_workspace_root(workspace_root); }); this.show_branch_manager = true; cx.notify(); @@ -800,6 +803,10 @@ impl WorkbenchView { this.show_branch_manager = false; this.open_project_path(path.clone(), cx); } + BranchManagerEvent::SelectRepository(path) => { + this.show_branch_manager = false; + this.open_project_path(path.clone(), cx); + } BranchManagerEvent::Close => { this.show_branch_manager = false; cx.notify(); From f52432af9641cd2878342fd03c43ae7a2cfe5d77 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 19:26:59 +0800 Subject: [PATCH 067/109] =?UTF-8?q?fix(linux):=20=E5=88=86=E6=94=AF?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=99=A8=E6=90=9C=E7=B4=A2=E6=A1=86=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E8=BE=93=E5=85=A5=EF=BC=88=E6=A0=B9=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E6=AF=8F=E5=B8=A7=E6=8A=A2=E7=84=A6=E7=82=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象:分支管理器弹窗里的「搜索分支」和「筛选仓库」输入框打不进字, 点了像是有光标但输入无效。 原因:`BranchManagerView::render` 顶部每帧调用 `window.focus(&self.focus_handle, cx)` 把焦点强行交给弹窗根节点。 搜索框是真实的 `Input` 组件,它需要自己持有焦点才能接收键盘与 IME 事件; 每帧被根节点夺回焦点后,字符输入无处落地。 修复:移除每帧抢焦点,改为弹窗打开后只聚焦一次: - 新增 `pending_search_focus` 标志,`set_repo` 置真; - `render` 首次渲染时聚焦搜索输入框并清除标志; - 上下键/Esc 仍经事件冒泡到达根节点 `on_key_down`(`Input` 不消费时冒泡)。 验证: - `cargo build --manifest-path linux/Cargo.toml -p lithe-linux` 通过,无警告。 - `cargo test --manifest-path linux/Cargo.toml -p lithe-linux --lib`:95 通过。 - 手工验证待做:分支/仓库/工作树三个分区的搜索框均可输入。 --- linux/src/workbench/branch_manager.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/linux/src/workbench/branch_manager.rs b/linux/src/workbench/branch_manager.rs index f9a626a4e..092aa4c47 100644 --- a/linux/src/workbench/branch_manager.rs +++ b/linux/src/workbench/branch_manager.rs @@ -92,6 +92,8 @@ pub struct BranchManagerView { selected_index: usize, error: Option, busy: bool, + /// 弹窗打开后需要在下一帧把焦点交给搜索框(只做一次,避免每帧抢焦点)。 + pending_search_focus: bool, search_input: Entity, _search_subscription: Subscription, focus_handle: FocusHandle, @@ -142,6 +144,7 @@ impl BranchManagerView { selected_index: 0, error: None, busy: false, + pending_search_focus: true, search_input, _search_subscription, focus_handle: cx.focus_handle(), @@ -165,6 +168,8 @@ impl BranchManagerView { self.error = None; self.busy = false; self.selected_index = 0; + // 下一次渲染时把焦点交给搜索框。 + self.pending_search_focus = true; // 清空搜索框显示值(`InputState::set_value` 需要 `Window`)。 if let Some(handle) = cx.active_window() { let input = self.search_input.clone(); @@ -182,6 +187,11 @@ impl BranchManagerView { self.workspace_root = workspace_root; } + /// 弹窗打开时把焦点交给搜索输入框(否则根节点不持有焦点,键盘无法输入)。 + pub fn focus_search(&mut self, window: &mut Window, cx: &mut Context) { + self.search_input + .update(cx, |state, cx| state.focus(window, cx)); + } /// 并发拉取分支、工作树与仓库列表。 pub fn reload(&mut self, cx: &mut Context) { self.load_branches(cx); @@ -648,8 +658,14 @@ fn git_write_payload(root: &str, operation: &str, extra: serde_json::Value) -> s impl Render for BranchManagerView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - window.focus(&self.focus_handle, cx); - + // 注意:这里**不能**每帧 `window.focus(&self.focus_handle)`。 + // 根节点每帧抢回焦点会把焦点从搜索 `Input` 手里夺走,导致搜索分支 / + // 筛选仓库的输入框无法输入(历史 bug)。弹窗打开时只聚焦输入框一次; + // 上下键/Esc 经事件冒泡到达根节点的 `on_key_down`。 + if self.pending_search_focus { + self.pending_search_focus = false; + self.focus_search(window, cx); + } let active_tab = self.active_tab; let count_text = self.count_text(cx); let busy = self.busy; From f7e0b7698d021fb32bfc80e1882846a1663eed4b Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 19:31:58 +0800 Subject: [PATCH 068/109] =?UTF-8?q?build(linux):=20=E6=96=B0=E5=A2=9E=20GP?= =?UTF-8?q?UI=20=E5=B7=A5=E4=BD=9C=E5=8F=B0=E4=BA=A4=E5=8F=89=E7=BC=96?= =?UTF-8?q?=E8=AF=91=E5=88=B0=20Windows=20=E7=9A=84=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 scripts/build-gpui-windows.sh,在 macOS/Linux 主机上把 linux/(GPUI 工作台)交叉编译为 Windows 可执行文件,便于在没有 Windows 机器时产出并分发 Windows 版本。 功能点: - 目标固定为 x86_64-pc-windows-gnu(MinGW-w64):MSVC 目标的 C 依赖需要 lib.exe 等 MSVC 工具链,非 Windows 主机无法构建,脚本对非 *-windows-gnu 目标直接报错并说明原因。 - 只支持 --debug:上游 gpui-pre-windows 在 release 下用 fxc.exe 编译 HLSL shader,而 fxc 由 Windows SDK 通过注册表定位,交叉编译必然缺少 shaders_bytes.rs;脚本对 --release 明确拒绝并提示改用 Windows 主机。 - 自动为 cc-rs 导出 CC_/CXX_/AR_ 交叉编译器变量与 CARGO_TARGET_*_LINKER,变量名按 cargo/cc-rs 约定把 triple 的 `-` 换成 `_`。 - 缺少 mingw-w64 工具链时给出 Debian/Ubuntu 与 Homebrew 的安装提示后退出。 - 产物路径固定为 linux/target///lithe-linux.exe,并在结尾 提示目标机器需随 exe 一并复制 MinGW 运行时 DLL。 验证:在本机执行 scripts/build-gpui-windows.sh --debug 成功产出 lithe-linux.exe(PE32+ executable (console) x86-64, for MS Windows); --release 按预期被拒绝。 --- scripts/build-gpui-windows.sh | 128 ++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100755 scripts/build-gpui-windows.sh diff --git a/scripts/build-gpui-windows.sh b/scripts/build-gpui-windows.sh new file mode 100755 index 000000000..d9089c27d --- /dev/null +++ b/scripts/build-gpui-windows.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# +# 从 macOS/Linux 交叉编译 Linux GPUI 工作台(linux/)到 Windows。 +# +# 只支持 `x86_64-pc-windows-gnu`(MinGW-w64)目标:MSVC 目标的 C 依赖需要 +# `lib.exe` 等 MSVC 工具链,无法在非 Windows 主机上构建。产物是可执行的 +# `lithe-linux.exe`,运行期依赖同目录下的 MinGW 运行时 DLL(见脚本结尾提示)。 +# +# 只支持 debug 构建:上游 `gpui-pre-windows` 的 release 构建会用 `fxc.exe` +# 编译 HLSL shader,而 fxc 来自 Windows SDK 且通过注册表定位,非 Windows +# 主机无法完成。Windows 上的 release 请在 Windows 主机使用 cargo 构建。 +# +# 用法: +# scripts/build-gpui-windows.sh [--debug] [--target ] [-- ] +# +# 依赖(缺失时脚本给出安装提示并退出): +# - rustup 且已安装目标:`rustup target add x86_64-pc-windows-gnu` +# - MinGW-w64 工具链:`x86_64-w64-mingw32-gcc` / `g++` / `ar` +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" + +PROFILE="debug" +TARGET="x86_64-pc-windows-gnu" +EXTRA_ARGS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --release) + PROFILE="release" + shift + ;; + --debug) + PROFILE="debug" + shift + ;; + --target) + TARGET="$2" + shift 2 + ;; + *) + EXTRA_ARGS+=("$1") + shift + ;; + esac +done + +if [[ "$TARGET" != *"-windows-gnu" ]]; then + printf '==> Error: only *-windows-gnu targets are supported on a non-Windows host (got %s).\n' "$TARGET" >&2 + printf ' MSVC targets require the MSVC toolchain, which is unavailable here.\n' >&2 + exit 2 +fi + +# 上游 gpui-pre-windows 在 release 下用 fxc.exe 编译 shader,而 fxc 由 Windows +# SDK 通过注册表定位;非 Windows 主机必然失败(缺少 shaders_bytes.rs)。 +if [[ "$PROFILE" == "release" ]]; then + printf '==> Error: --release is not supported by this cross-compilation script.\n' >&2 + printf ' gpui-pre-windows compiles HLSL with fxc.exe (Windows SDK) in release builds.\n' >&2 + printf ' Use a Windows host for release, or build with --debug here.\n' >&2 + exit 2 +fi + +# MinGW 工具链按目标架构选择前缀;当前仅 x86_64 可用。 +case "$TARGET" in + x86_64-*-windows-gnu) + MINGW_PREFIX="x86_64-w64-mingw32" + ;; + *) + printf '==> Error: unsupported MinGW target %s.\n' "$TARGET" >&2 + exit 2 + ;; +esac + +CC_BIN="${MINGW_PREFIX}-gcc" +CXX_BIN="${MINGW_PREFIX}-g++" +AR_BIN="${MINGW_PREFIX}-ar" + +missing=0 +for tool in "$CC_BIN" "$CXX_BIN" "$AR_BIN"; do + if ! command -v "$tool" >/dev/null 2>&1; then + printf '==> Error: %s not found on PATH.\n' "$tool" >&2 + missing=1 + fi +done +if [[ "$missing" -ne 0 ]]; then + printf ' Install MinGW-w64, e.g. Debian/Ubuntu: sudo apt-get install mingw-w64\n' >&2 + printf ' or macOS (Homebrew): brew install mingw-w64\n' >&2 + exit 2 +fi + +# C/C++ 依赖(gpui/wgpu/alacritty_terminal 等)经 cc-rs 构建,需显式指定交叉编译器。 +# 变量名把目标 triple 的 `-` 换成 `_`,这是 cc-rs 的约定。 +TARGET_ENV="${TARGET//-/_}" +export "CC_${TARGET_ENV}=$CC_BIN" +export "CXX_${TARGET_ENV}=$CXX_BIN" +export "AR_${TARGET_ENV}=$AR_BIN" +# 交叉链接时 rustc 也需要知道链接器(部分依赖用 gcc 直接驱动链接)。 +export "CARGO_TARGET_${TARGET_ENV^^}_LINKER=$CC_BIN" + +cd "$ROOT_DIR" + +# 与 build-linux.sh 隔离产物目录,避免 Windows 目标污染本机 Linux 构建缓存。 +CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT_DIR/linux/target}" +export CARGO_TARGET_DIR + +BUILD_ARGS=(build --manifest-path "$ROOT_DIR/linux/Cargo.toml" -p lithe-linux --target "$TARGET") + +if [[ "$PROFILE" == "release" ]]; then + BUILD_ARGS+=(--release) +fi + +if [[ ${#EXTRA_ARGS[@]} -gt 0 ]]; then + BUILD_ARGS+=("${EXTRA_ARGS[@]}") +fi + +printf '==> Building Lithe GPUI frontend for Windows (%s, %s)...\n' "$TARGET" "$PROFILE" +cargo "${BUILD_ARGS[@]}" + +OUTPUT_BIN="$CARGO_TARGET_DIR/$TARGET/$PROFILE/lithe-linux.exe" +if [[ ! -f "$OUTPUT_BIN" ]]; then + printf '==> Error: output binary not found at %s\n' "$OUTPUT_BIN" >&2 + exit 1 +fi + +printf '==> Build successful: %s\n' "$OUTPUT_BIN" +printf '==> Note: copy the MinGW runtime DLLs next to the exe on the target machine\n' +printf ' (typically libgcc_s_seh-1.dll, libstdc++-6.dll, libwinpthread-1.dll).\n' From 204c11b6725134f264e03801af52698aa1a06495 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 19:42:06 +0800 Subject: [PATCH 069/109] =?UTF-8?q?refactor(linux):=20=E6=8A=BD=E5=87=BA?= =?UTF-8?q?=E5=94=AF=E4=B8=80=E6=90=9C=E7=B4=A2=E8=BE=93=E5=85=A5=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E5=B9=B6=E8=BF=81=E7=A7=BB=E5=85=A8=E9=83=A8=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象: Linux 工作台的文件树过滤、快速打开、命令面板、跳转到行、Search Everywhere、分支管理器各自用普通 `div` + `on_key_down` 手动拼接 `key_char` 实现“输入框”。这种自绘输入没有向 GPUI 注册输入处理器, 导致三个用户可见问题:中文输入法(XIM/ibus)组字提交无处落地,打不出 汉字;`Ctrl+V` 粘贴、选区、光标移动均不支持;同一套按键规则在六个浮层 里重复,容易漂移。 原因: 自绘 `div` 不参与 GPUI 的输入处理协议;输入法、剪贴板、选区都需要组件 主动注册处理器并向平台响应事件,这些能力 gpui-kit 的 `Input` / `InputState` 已经实现。 本次改动: - 新增 `linux/src/workbench/search_input.rs`:`SearchInput` 组件封装 `InputState` 的创建(带占位)、取值/设值、聚焦、占位更新与事件订阅, 并提供统一外观的 `element()`(`appearance(false)` 关闭组件自带背景/ 边框/焦点环,`compact()` 对应 `Input::small()`)。 - `sidebar.rs`:文件树过滤改用 `SearchInput`(紧凑尺寸),保留回车 / Shift+回车在命中间导航。 - `quick_open.rs`:改真实输入框,查询串前缀 `@`/`#` 切换模式;占位文案 随模式刷新;方向键与 Esc 仍由根节点处理。 - `command_palette.rs`:同上,回车执行选中命令。 - `go_to_line.rs`:改用真实输入框,在 `Change` 中过滤为最多 6 位 ASCII 数字(净化值经渲染帧写回),回车确认;同时获得中文与粘贴支持。 - `search_everywhere.rs`:改用真实输入框,清空按钮同步清空输入框并恢复 焦点。 - `branch_manager.rs`:搜索框改为复用 `SearchInput`,打开时一次性清空 + 聚焦;移除已无用的 `focus_search` 与基于 `active_window` 的清空逻辑。 - `view.rs`:三个模态构造函数改为 `new(window, cx)` 以创建输入状态。 - 新增 Agent 笔记 `.agents/notes/implemented/architecture/2026-09-26-linux-search-input-single-implementation.md`, 记录“搜索输入唯一实现”“打开只聚焦一次”“回车走 PressEnter 冒泡”。 - 删除 `QuickOpenModal::set_query`、`CommandPaletteModal::set_query` 两个未使用的死方法,避免与输入框显示值不一致。 关键约束(写进笔记): 不要在 `Render::render` 里每帧调用 `window.focus(root_focus_handle)`, 否则会把焦点从搜索框夺走,表现为“点得进去却打不出字”;打开时只聚焦 一次。回车统一由 `InputEvent::PressEnter` 处理(单行 Input 在回车时 `cx.propagate()`),不要在 `on_key_down` 里重复处理 `"enter"`。 验证: - `cargo build --manifest-path linux/Cargo.toml -p lithe-linux`:通过, 无警告。 - `cargo test --manifest-path linux/Cargo.toml -p lithe-linux --lib`: 95 passed。 - `bash scripts/build-linux.sh`:canonical 二进制构建成功。 - `node scripts/verify-agent-notes.mjs`:46 active notes verified。 未做: - 未改动并行进行中的工作树文件(client.rs、keybindings.rs、lsp.rs、 settings.rs、editor.rs、extensions_panel.rs、settings_dialog.rs、 terminal.rs)。 --- ...inux-search-input-single-implementation.md | 133 +++++++++++++ linux/src/workbench/branch_manager.rs | 85 ++++---- linux/src/workbench/command_palette.rs | 130 +++++------- linux/src/workbench/go_to_line.rs | 120 +++++------ linux/src/workbench/mod.rs | 1 + linux/src/workbench/quick_open.rs | 188 ++++++++---------- linux/src/workbench/search_everywhere.rs | 129 +++++------- linux/src/workbench/search_input.rs | 126 ++++++++++++ linux/src/workbench/sidebar.rs | 79 ++++---- linux/src/workbench/view.rs | 8 +- 10 files changed, 566 insertions(+), 433 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-26-linux-search-input-single-implementation.md create mode 100644 linux/src/workbench/search_input.rs diff --git a/.agents/notes/implemented/architecture/2026-09-26-linux-search-input-single-implementation.md b/.agents/notes/implemented/architecture/2026-09-26-linux-search-input-single-implementation.md new file mode 100644 index 000000000..93b51fdd3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-26-linux-search-input-single-implementation.md @@ -0,0 +1,133 @@ +# Agent 笔记:Linux 工作台的搜索输入只有一套实现 + +状态:已实现 + +## 先说结论 + +Linux 工作台里凡是「输入关键词过滤 / 选择一个东西」的搜索框,都必须用 +`linux/src/workbench/search_input.rs` 里的 `SearchInput` 组件。它内部包的是 +gpui-kit 官方的 `Input` / `InputState`,由组件负责中文输入法组字、粘贴、 +选区和光标;各弹窗只订阅业务事件。 + +开发者以后最需要记住的是两条:**不要再自己用 `div` + `on_key_down` + +`key_char` 拼一个输入框**;**不要在 `Render::render` 里每帧调用 +`window.focus(&root_focus_handle)`**,那会把焦点从搜索框手里夺走,表现为 +「点得进去却打不出字」。 + +## 问题 + +Linux 工作台的多个浮层(文件树过滤、快速打开、命令面板、跳转到行、 +Search Everywhere、分支管理器)一开始都各自实现了一个「输入框」:一个普通 +`div` 显示当前文本,配一个 `on_key_down` 监听器,遇到可打印字符就把 +`key_char` 手动 `push` 到查询串,遇到退格就 `pop`。 + +这种自绘输入在 X11 上有三个真实的用户可见缺陷: + +1. **打不出中文**。GPUI 的输入法(IME,如 ibus / XIM)组字需要把 + `InputHandler` 注册到有焦点的节点上。自绘 `div` 没有注册,输入法提交的 + 组合字符串无处落地。用户看到的是「敲拼音没反应」。 +2. **不能粘贴**。`Ctrl+V` 需要组件主动向剪贴板取文本并插入光标处,自绘 + `div` 只会把 `v` 当普通字符。 +3. **规则漂移**。每个浮层都抄一遍「什么算可打印字符、退格怎么删、 + 修饰键怎么忽略」,六份实现迟早不一致。 + +## 决策 + +把「搜索输入」收敛成 `SearchInput` 一个组件,所有搜索框接入它。 + +### 正确做法 + +- 新建浮层时,用 `SearchInput::new(placeholder, window, cx)` 拿一个搜索框, + 用 `search.subscribe(cx, |this, event, cx| ...)` 订阅 + `InputEvent::Change`(文本变化)和 `InputEvent::PressEnter { shift, .. }` + (回车 / Shift+回车);把返回的 `Subscription` 存进结构体字段,否则订阅 + 立即失效。 +- 渲染时直接 `.child(search.element())`。它已经带 `flex_1`、`min_w_0`,并 + 用 `appearance(false)` 关掉组件自带的背景 / 边框 / 焦点环,外观由你外面的 + 行容器决定,避免多出一层阴影或双边框。 +- 回车统一走 `InputEvent::PressEnter`,**不要在 `on_key_down` 里再处理 + `"enter"`**。单行 `Input` 在回车时会派发该事件并继续向上冒泡,所以 + 订阅方和祖先的 `on_key_down` 都能收到;重复处理会导致确认动作执行两次。 +- 方向键(`up` / `down`)可以照常放在浮层根节点的 `on_key_down` 里做列表 + 导航。单行 `Input` 不会为自己注册 `up` / `down` 的动作处理器(那一组只在 + 多行模式下注册),所以按键会冒泡到根节点。 +- 打开浮层时只聚焦一次。用 `pending_reset` / `pending_search_focus` 这类 + 布尔标志,在 `render` 的第一帧把 `search.set_value("", ...)` 与 + `search.focus(...)` 一起做掉;此时才持有 `Window`,也是唯一需要 `Window` + 的时机。 +- `Esc` 关闭浮层可以继续放在根节点 `on_key_down`:单行 `Input` 在 + `escape` 且没有内联补全 / IME 预编辑残留时会 `cx.propagate()`,事件能到 + 根节点。 + +### 不要这样做 + +- 不要为了「看起来像输入框」而用 `div` 显示查询串加一个静态光标条。它没有 + 输入法注册、没有剪贴板、没有选区,中文与粘贴直接失效。 +- 不要在 `Render::render` 里每帧 `window.focus(...)` 抢焦点。渲染每帧都会 + 执行,抢焦点会让搜索框刚拿到焦点就被夺走,症状是「能点、能看、打不出字」, + 极难定位。 +- 不要各自复制 `InputState::new(...)` 再自己维护一份订阅逻辑。占位文案、去 + 边框、`flex_1` 这些细节应当只有一处。 +- 不要用 `#[cfg(target_os = ...)]` 给不同平台写两套输入路径。`SearchInput` + 只依赖 gpui-kit 官方 API,可编译到 macOS / Windows。 + +## 考虑过的备选方案 + +### 备选方案一:继续用自绘 `div`,只给它补 IME 钩子 + +最有吸引力的地方是改动最小,不用碰六个浮层的渲染。但「补 IME」本质上就是 +要按 GPUI 的输入处理器协议实现一个迷你输入框,等于把 `Input` 已有的能力 +(粘贴、选区、光标、撤销、鼠标点选)再实现一遍,长期维护成本更高,因此不 +采用。 + +### 备选方案二:直接在每个浮层里内联 `Input` / `InputState` + +能立刻修好中文和粘贴,六个浮层各写一遍 `InputState::new` + `cx.subscribe` + +`.appearance(false)`。问题在于「去边框 + flex_1 + 订阅生命周期 + 打开只聚焦 +一次」这套约束会被复制六份,正是漂移的来源,因此不采用。 + +### 备选方案三:把搜索框做成一个 GPUI Entity(`SearchInputView`) + +作为实体可以自己 `render`、自己持有订阅,看起来更「组件化」。代价是父浮层 +要再套一层实体、焦点路径变深、事件回调要跨实体传递,而当前需求只是「一个 +输入框 + 把事件转给父级」,用普通结构体加 `cx.subscribe` 已经够,因此不采用。 + +## 后果 + +- 收益:中文输入法、`Ctrl+V` 粘贴、选区、光标移动在所有搜索框里一次到位, + 且行为完全一致。 +- 收益:占位文案、去边框外观、订阅接线只有一处实现,新浮层接入成本降为 + 几行。 +- 代价:`SearchInput` 的 `subscribe` 需要调用方自己保存 `Subscription`。 + 忘记保存会静默失效(输入框能打字但父级收不到变化),这是 GPUI 订阅的 + 通用约定,不是本组件特有。 +- 代价:需要调用方遵守「打开只聚焦一次」。如果某个浮层坚持每帧 `focus` 根 + 节点,症状会回来。 +- 需要重新评估的触发条件:如果以后出现「需要多行搜索输入」或「需要把搜索 + 框嵌进虚拟列表复用」的需求,`SearchInput` 的 `element()` 外观假设可能需要 + 扩展。 + +## 验证 + +- `cargo build --manifest-path linux/Cargo.toml -p lithe-linux`:所有接入方 + 编译通过且无警告。 +- `cargo test --manifest-path linux/Cargo.toml -p lithe-linux --lib`: + 95 项测试通过(搜索相关的纯逻辑测试见 `workbench::tree_search`、 + `workbench::global_search`、`workbench::branch_manager_logic`)。 +- `bash scripts/build-linux.sh`:canonical 二进制 + `target/debug/lithe-linux` 构建成功。 +- 手工验证:分别打开文件树过滤、快速打开、命令面板、跳转到行、 + Search Everywhere、分支管理器,确认可以用输入法输入中文、可以用 + `Ctrl+V` 粘贴、回车执行、`Esc` 关闭、方向键在列表中选择。 + +## 适用范围 + +- `linux/src/workbench/search_input.rs`:搜索输入组件本体。 +- `linux/src/workbench/sidebar.rs`:文件树过滤。 +- `linux/src/workbench/quick_open.rs`:快速打开。 +- `linux/src/workbench/command_palette.rs`:命令面板。 +- `linux/src/workbench/go_to_line.rs`:跳转到行。 +- `linux/src/workbench/search_everywhere.rs`:Search Everywhere。 +- `linux/src/workbench/branch_manager.rs`:分支管理器搜索。 +- 不适用于 Windows 与 macOS 产品;它们的搜索框真源分别是 + `windows/tauri/src/features/` 与 `macos/Sources/Lithe/Views/`。 diff --git a/linux/src/workbench/branch_manager.rs b/linux/src/workbench/branch_manager.rs index 092aa4c47..e41a8516d 100644 --- a/linux/src/workbench/branch_manager.rs +++ b/linux/src/workbench/branch_manager.rs @@ -16,14 +16,14 @@ use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants as _}; -use gpui_kit::component::input::{Input, InputEvent, InputState}; +use gpui_kit::component::input::InputEvent; use gpui_kit::component::scroll::ScrollableElement as _; use gpui_kit::component::{h_flex, v_flex, Disableable as _, Icon, Selectable as _, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, rgba, AppContext as _, Context, Entity, EventEmitter, FocusHandle, FontWeight, - InteractiveElement as _, IntoElement, KeyDownEvent, ParentElement as _, Render, - StatefulInteractiveElement as _, Styled as _, Subscription, Window, + div, px, rgba, Context, EventEmitter, FocusHandle, FontWeight, InteractiveElement as _, + IntoElement, KeyDownEvent, ParentElement as _, Render, StatefulInteractiveElement as _, + Styled as _, Subscription, Window, }; use super::branch_manager_logic::{ @@ -94,7 +94,8 @@ pub struct BranchManagerView { busy: bool, /// 弹窗打开后需要在下一帧把焦点交给搜索框(只做一次,避免每帧抢焦点)。 pending_search_focus: bool, - search_input: Entity, + /// 搜索框(复用统一搜索输入实现:IME / 粘贴由组件处理)。 + search_input: super::search_input::SearchInput, _search_subscription: Subscription, focus_handle: FocusHandle, client: CoreClient, @@ -104,31 +105,28 @@ impl EventEmitter for BranchManagerView {} impl BranchManagerView { pub fn new(repo_path: String, window: &mut Window, cx: &mut Context) -> Self { - let search_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder(crate::i18n::menu_text(cx, "git.searchBranches")) - }); - let input_source = search_input.clone(); - let _search_subscription = cx.subscribe( - &search_input, - move |this: &mut Self, _, event: &InputEvent, cx| match event { - InputEvent::Change => { - this.query = input_source.read(cx).value().to_string(); - this.selected_index = 0; - this.error = None; - cx.notify(); - } - InputEvent::PressEnter { shift, .. } => { - // 回车执行当前选中项;Shift+回车回退一格(列表导航习惯)。 - if *shift { - this.move_selection(-1, cx); - } else { - this.activate_selection(cx); - } - } - _ => {} - }, + let search_input = super::search_input::SearchInput::new( + crate::i18n::menu_text(cx, "git.searchBranches"), + window, + cx, ); + let _search_subscription = search_input.subscribe(cx, |this, event, cx| match event { + InputEvent::Change => { + this.query = this.search_input.value(cx); + this.selected_index = 0; + this.error = None; + cx.notify(); + } + InputEvent::PressEnter { shift, .. } => { + // 回车执行当前选中项;Shift+回车回退一格(列表导航习惯)。 + if *shift { + this.move_selection(-1, cx); + } else { + this.activate_selection(cx); + } + } + _ => {} + }); let mut view = Self { repo_path, @@ -168,17 +166,8 @@ impl BranchManagerView { self.error = None; self.busy = false; self.selected_index = 0; - // 下一次渲染时把焦点交给搜索框。 + // 下一次渲染时清空搜索框并聚焦(渲染时才持有 `Window`)。 self.pending_search_focus = true; - // 清空搜索框显示值(`InputState::set_value` 需要 `Window`)。 - if let Some(handle) = cx.active_window() { - let input = self.search_input.clone(); - handle - .update(cx, |_, window, cx| { - input.update(cx, |state, cx| state.set_value("", window, cx)); - }) - .ok(); - } self.reload(cx); } @@ -186,12 +175,6 @@ impl BranchManagerView { pub fn set_workspace_root(&mut self, workspace_root: String) { self.workspace_root = workspace_root; } - - /// 弹窗打开时把焦点交给搜索输入框(否则根节点不持有焦点,键盘无法输入)。 - pub fn focus_search(&mut self, window: &mut Window, cx: &mut Context) { - self.search_input - .update(cx, |state, cx| state.focus(window, cx)); - } /// 并发拉取分支、工作树与仓库列表。 pub fn reload(&mut self, cx: &mut Context) { self.load_branches(cx); @@ -664,7 +647,9 @@ impl Render for BranchManagerView { // 上下键/Esc 经事件冒泡到达根节点的 `on_key_down`。 if self.pending_search_focus { self.pending_search_focus = false; - self.focus_search(window, cx); + // 清空 + 聚焦:两件事一起做,只用一次 `Window`。 + self.search_input.set_value("", window, cx); + self.search_input.focus(window, cx); } let active_tab = self.active_tab; let count_text = self.count_text(cx); @@ -784,10 +769,8 @@ impl BranchManagerView { // 切 tab 后刷新占位与焦点(对齐 `handleTabChange`)。 let placeholder: gpui_kit::SharedString = crate::i18n::menu_text(cx, tab.placeholder_key()).into(); - this.search_input.update(cx, |state, cx| { - state.set_placeholder(placeholder, window, cx); - state.focus(window, cx); - }); + this.search_input.set_placeholder(placeholder, window, cx); + this.search_input.focus(window, cx); cx.notify(); })) })) @@ -815,7 +798,7 @@ impl BranchManagerView { .flex_1() .min_w_0() .text_sm() - .child(Input::new(&self.search_input).appearance(false)), + .child(self.search_input.element()), ) } diff --git a/linux/src/workbench/command_palette.rs b/linux/src/workbench/command_palette.rs index 7bcb40e00..c66c36c95 100644 --- a/linux/src/workbench/command_palette.rs +++ b/linux/src/workbench/command_palette.rs @@ -5,16 +5,18 @@ //! [`CommandPaletteEvent::Execute`] 把命令 id 交给上层工作台处理,不在本模块执行真实命令。 use gpui_kit::assets::IconName; +use gpui_kit::component::input::InputEvent; use gpui_kit::component::scroll::ScrollableElement as _; use gpui_kit::component::{h_flex, v_flex, Icon}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ div, px, rgba, AnyElement, Context, EventEmitter, FocusHandle, FontWeight, InteractiveElement as _, IntoElement, KeyDownEvent, ParentElement as _, Render, - StatefulInteractiveElement as _, Styled as _, Window, + StatefulInteractiveElement as _, Styled as _, Subscription, Window, }; use crate::theme::ThemeColors; +use crate::workbench::search_input::SearchInput; /// 命令面板中的一条命令。 /// @@ -54,14 +56,41 @@ pub struct CommandPaletteModal { pub focus_handle: FocusHandle, /// 预留的多级视图栈,根视图为 `"root"`。 pub view_stack: Vec, + /// 搜索框(复用统一搜索输入实现:IME / 粘贴由组件处理)。 + search: SearchInput, + _search_subscription: Subscription, + /// 打开时需要在下一帧复位并聚焦搜索框(只做一次)。 + pending_reset: bool, } impl EventEmitter for CommandPaletteModal {} impl CommandPaletteModal { - pub fn new(cx: &mut Context) -> Self { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { let items = build_commands(); let filtered = (0..items.len()).collect(); + let search = SearchInput::new( + crate::i18n::menu_text(cx, "commandPalette.placeholder"), + window, + cx, + ); + let _search_subscription = search.subscribe(cx, |this, event, cx| match event { + InputEvent::Change => { + this.query = this.search.value(cx); + this.selected_index = 0; + this.recompute_filtered(); + cx.notify(); + } + InputEvent::PressEnter { shift, .. } => { + if *shift { + this.move_selection(-1, cx); + } else if !this.filtered.is_empty() { + let idx = this.current_index(); + this.execute_at(idx, cx); + } + } + _ => {} + }); Self { query: String::new(), @@ -70,6 +99,9 @@ impl CommandPaletteModal { selected_index: 0, focus_handle: cx.focus_handle(), view_stack: vec!["root".to_string()], + search, + _search_subscription, + pending_reset: true, } } @@ -79,15 +111,7 @@ impl CommandPaletteModal { self.selected_index = 0; self.view_stack = vec!["root".to_string()]; self.recompute_filtered(); - cx.notify(); - } - - /// 直接设置查询串(供上层联动外部输入)。 - #[allow(dead_code)] - pub fn set_query(&mut self, q: impl Into, cx: &mut Context) { - self.query = q.into(); - self.selected_index = 0; - self.recompute_filtered(); + self.pending_reset = true; cx.notify(); } @@ -159,8 +183,12 @@ impl CommandPaletteModal { impl Render for CommandPaletteModal { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - // 请求聚焦以接收按键输入 - window.focus(&self.focus_handle, cx); + // 打开后只复位/聚焦搜索框一次;不要每帧抢焦点(否则输入框打不进字)。 + if self.pending_reset { + self.pending_reset = false; + self.search.set_value("", window, cx); + self.search.focus(window, cx); + } let rows = self.build_rows(); let current_index = self.current_index(); @@ -176,50 +204,13 @@ impl Render for CommandPaletteModal { .items_center() .justify_center() .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { - let key = event.keystroke.key.as_str(); - match key { - "escape" => { - cx.emit(CommandPaletteEvent::Close); - } + // 字符输入 / 退格 / 空格由搜索框处理(含 IME 与粘贴); + // 这里只管方向键与 Esc。 + match event.keystroke.key.as_str() { + "escape" => cx.emit(CommandPaletteEvent::Close), "up" | "arrowup" => this.move_selection(-1, cx), "down" | "arrowdown" => this.move_selection(1, cx), - "enter" => { - if !this.filtered.is_empty() { - let idx = this.current_index(); - this.execute_at(idx, cx); - } - } - "backspace" => { - this.query.pop(); - this.selected_index = 0; - this.recompute_filtered(); - cx.notify(); - } - "space" => { - this.query.push(' '); - this.selected_index = 0; - this.recompute_filtered(); - cx.notify(); - } - _ => { - // 无修饰键时把可打印字符追加到查询串 - if !event.keystroke.modifiers.control - && !event.keystroke.modifiers.alt - && !event.keystroke.modifiers.platform - { - if let Some(ch) = &event.keystroke.key_char { - this.query.push_str(ch); - this.selected_index = 0; - this.recompute_filtered(); - cx.notify(); - } else if key.chars().count() == 1 { - this.query.push_str(key); - this.selected_index = 0; - this.recompute_filtered(); - cx.notify(); - } - } - } + _ => {} } })) .on_mouse_down( @@ -261,34 +252,7 @@ impl Render for CommandPaletteModal { .size(px(16.0)) .text_color(ThemeColors::primary()), ) - .child( - h_flex() - .flex_1() - .items_center() - .gap_1() - .child( - div() - .text_sm() - .text_color(if self.query.is_empty() { - ThemeColors::subtle_foreground() - } else { - ThemeColors::foreground() - }) - .child(if self.query.is_empty() { - crate::i18n::menu_text( - cx, - "commandPalette.placeholder", - ) - .to_string() - } else { - self.query.clone() - }), - ) - .child( - // 静态光标条,提示可输入 - div().w(px(2.0)).h(px(16.0)).bg(ThemeColors::primary()), - ), - ) + .child(self.search.element()) .child(shortcut_badge("Esc")), ) .child( diff --git a/linux/src/workbench/go_to_line.rs b/linux/src/workbench/go_to_line.rs index d029abac4..b68dd00b0 100644 --- a/linux/src/workbench/go_to_line.rs +++ b/linux/src/workbench/go_to_line.rs @@ -5,13 +5,15 @@ //! 做钳制与跳转;本模块不直接操作编辑器。 use gpui_kit::assets::IconName; +use gpui_kit::component::input::InputEvent; use gpui_kit::component::{h_flex, v_flex, Icon}; use gpui_kit::{ div, px, rgba, Context, EventEmitter, FocusHandle, InteractiveElement as _, IntoElement, - KeyDownEvent, ParentElement as _, Render, Styled as _, Window, + KeyDownEvent, ParentElement as _, Render, Styled as _, Subscription, Window, }; use crate::theme::ThemeColors; +use crate::workbench::search_input::SearchInput; /// 跳转到行弹窗对外事件。 #[derive(Debug, Clone)] @@ -26,21 +28,53 @@ pub enum GoToLineEvent { pub struct GoToLineModal { pub input: String, pub focus_handle: FocusHandle, + /// 搜索框(复用统一搜索输入实现:IME / 粘贴由组件处理)。 + search: SearchInput, + _search_subscription: Subscription, + /// 打开时需要在下一帧复位并聚焦搜索框(只做一次)。 + pending_reset: bool, + /// 需要对输入框施加的净化值(只允许数字、最长 6 位);渲染时应用。 + pending_sanitized: Option, } impl EventEmitter for GoToLineModal {} impl GoToLineModal { - pub fn new(cx: &mut Context) -> Self { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let search = SearchInput::new( + crate::i18n::menu_text(cx, "goToLine.placeholder"), + window, + cx, + ); + let _search_subscription = search.subscribe(cx, |this, event, cx| match event { + InputEvent::Change => { + let raw = this.search.value(cx); + // 只保留 ASCII 数字,最多 6 位(对齐原有单行数字输入限制)。 + let sanitized: String = + raw.chars().filter(|c| c.is_ascii_digit()).take(6).collect(); + this.input = sanitized.clone(); + if sanitized != raw { + this.pending_sanitized = Some(sanitized); + } + cx.notify(); + } + InputEvent::PressEnter { .. } => this.confirm(cx), + _ => {} + }); Self { input: String::new(), focus_handle: cx.focus_handle(), + search, + _search_subscription, + pending_reset: true, + pending_sanitized: None, } } /// 复位到初始状态:清空输入。 pub fn reset(&mut self, cx: &mut Context) { self.input.clear(); + self.pending_reset = true; cx.notify(); } @@ -57,7 +91,15 @@ impl GoToLineModal { impl Render for GoToLineModal { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { // 请求聚焦以接收按键输入 - window.focus(&self.focus_handle, cx); + // 打开后只复位/聚焦搜索框一次;不要每帧抢焦点(否则输入框打不进字)。 + if self.pending_reset { + self.pending_reset = false; + self.search.set_value("", window, cx); + self.search.focus(window, cx); + } + if let Some(sanitized) = self.pending_sanitized.take() { + self.search.set_value(sanitized, window, cx); + } // 全屏半透明遮罩:点击空白处关闭 div() @@ -69,47 +111,11 @@ impl Render for GoToLineModal { .flex() .items_center() .justify_center() - .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { - let key = event.keystroke.key.as_str(); - match key { - "escape" => { - cx.emit(GoToLineEvent::Close); - } - "enter" => { - this.confirm(cx); - } - "backspace" => { - this.input.pop(); - cx.notify(); - } - _ => { - // 无修饰键时只接受 ASCII 数字,最多 6 位。 - if !event.keystroke.modifiers.control - && !event.keystroke.modifiers.alt - && !event.keystroke.modifiers.platform - && this.input.len() < 6 - { - let mut digit: Option = None; - if let Some(ch) = &event.keystroke.key_char { - let mut chars = ch.chars(); - if let (Some(c), None) = (chars.next(), chars.next()) { - if c.is_ascii_digit() { - digit = Some(c); - } - } - } else if key.chars().count() == 1 { - if let Some(c) = key.chars().next() { - if c.is_ascii_digit() { - digit = Some(c); - } - } - } - if let Some(c) = digit { - this.input.push(c); - cx.notify(); - } - } - } + .on_key_down(cx.listener(|_this, event: &KeyDownEvent, _window, cx| { + // 字符输入由搜索框处理;这里只管 Esc(回车经搜索框的 + // `InputEvent::PressEnter` 分发到 `confirm`)。 + if event.keystroke.key.as_str() == "escape" { + cx.emit(GoToLineEvent::Close); } })) .on_mouse_down( @@ -163,31 +169,7 @@ impl Render for GoToLineModal { .size(px(16.0)) .text_color(ThemeColors::primary()), ) - .child( - h_flex() - .flex_1() - .items_center() - .gap_1() - .child( - div() - .text_sm() - .text_color(if self.input.is_empty() { - ThemeColors::subtle_foreground() - } else { - ThemeColors::foreground() - }) - .child(if self.input.is_empty() { - crate::i18n::menu_text(cx, "goToLine.placeholder") - .to_string() - } else { - self.input.clone() - }), - ) - .child( - // 静态光标条,提示可输入 - div().w(px(2.0)).h(px(16.0)).bg(ThemeColors::primary()), - ), - ) + .child(self.search.element()) .child(shortcut_badge("Enter")), ) .child( diff --git a/linux/src/workbench/mod.rs b/linux/src/workbench/mod.rs index 5fc4ecd75..1638a2a4b 100644 --- a/linux/src/workbench/mod.rs +++ b/linux/src/workbench/mod.rs @@ -16,6 +16,7 @@ pub mod project_dialog; pub mod quick_open; pub mod run; pub mod search_everywhere; +pub mod search_input; pub mod settings_dialog; pub mod sidebar; pub mod status_bar; diff --git a/linux/src/workbench/quick_open.rs b/linux/src/workbench/quick_open.rs index 123e9ca2d..89487411a 100644 --- a/linux/src/workbench/quick_open.rs +++ b/linux/src/workbench/quick_open.rs @@ -6,16 +6,18 @@ //! 不接入真实文件索引与 LSP 符号查询。 use gpui_kit::assets::IconName; +use gpui_kit::component::input::InputEvent; use gpui_kit::component::scroll::ScrollableElement as _; use gpui_kit::component::{h_flex, v_flex, Icon}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ div, px, rgba, AnyElement, Context, EventEmitter, FocusHandle, FontWeight, InteractiveElement as _, IntoElement, KeyDownEvent, ParentElement as _, Render, - StatefulInteractiveElement as _, Styled as _, Window, + StatefulInteractiveElement as _, Styled as _, Subscription, Window, }; use crate::theme::ThemeColors; +use crate::workbench::search_input::SearchInput; /// 快速打开的工作模式,由查询串前缀决定。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -77,12 +79,42 @@ pub struct QuickOpenModal { pub open_files: Vec, /// 最近打开的文件,用于优先分组展示。 pub recent: Vec, + /// 搜索框(复用统一搜索输入实现:IME / 粘贴由组件处理)。 + search: SearchInput, + _search_subscription: Subscription, + /// 打开时需要在下一帧复位并聚焦搜索框(只做一次)。 + pending_reset: bool, + /// 已应用到搜索框占位的模式(避免每帧重复设置)。 + placeholder_mode: QuickOpenMode, } impl EventEmitter for QuickOpenModal {} impl QuickOpenModal { - pub fn new(cx: &mut Context) -> Self { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let search = SearchInput::new( + crate::i18n::menu_text(cx, QuickOpenMode::File.placeholder_key()), + window, + cx, + ); + let _search_subscription = search.subscribe(cx, |this, event, cx| match event { + InputEvent::Change => { + this.query = this.search.value(cx); + this.mode = mode_for_query(&this.query); + this.selected_index = 0; + this.recompute_filtered(); + cx.notify(); + } + InputEvent::PressEnter { shift, .. } => { + if *shift { + this.move_selection(-1, cx); + } else if !this.filtered.is_empty() { + let idx = this.current_index(); + this.open_at(idx, cx); + } + } + _ => {} + }); Self { query: String::new(), mode: QuickOpenMode::File, @@ -92,6 +124,10 @@ impl QuickOpenModal { focus_handle: cx.focus_handle(), open_files: Vec::new(), recent: Vec::new(), + search, + _search_subscription, + pending_reset: true, + placeholder_mode: QuickOpenMode::File, } } @@ -124,16 +160,8 @@ impl QuickOpenModal { self.mode = QuickOpenMode::File; self.selected_index = 0; self.recompute_filtered(); - cx.notify(); - } - - /// 设置查询串并同步模式(`@`/`#` 前缀)与筛选结果。 - #[allow(dead_code)] - pub fn set_query(&mut self, q: impl Into, cx: &mut Context) { - self.query = q.into(); - self.mode = mode_for_query(&self.query); - self.selected_index = 0; - self.recompute_filtered(); + // 搜索框显示值/Tab 置位在渲染时统一处理(此时才持有 `Window`)。 + self.pending_reset = true; cx.notify(); } @@ -233,8 +261,12 @@ fn mode_for_query(query: &str) -> QuickOpenMode { impl Render for QuickOpenModal { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - // 请求聚焦以接收按键输入 - window.focus(&self.focus_handle, cx); + // 打开后只复位/聚焦搜索框一次;不要每帧抢焦点(否则输入框打不进字)。 + if self.pending_reset { + self.pending_reset = false; + self.search.set_value("", window, cx); + self.search.focus(window, cx); + } let rows = self.build_rows(); let current_index = self.current_index(); @@ -255,54 +287,13 @@ impl Render for QuickOpenModal { .items_center() .justify_center() .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { - let key = event.keystroke.key.as_str(); - match key { - "escape" => { - cx.emit(QuickOpenEvent::Close); - } + // 字符输入 / 退格 / 空格由搜索框处理(含 IME 与粘贴); + // 这里只管方向键与 Esc。 + match event.keystroke.key.as_str() { + "escape" => cx.emit(QuickOpenEvent::Close), "up" | "arrowup" => this.move_selection(-1, cx), "down" | "arrowdown" => this.move_selection(1, cx), - "enter" => { - if !this.filtered.is_empty() { - let idx = this.current_index(); - this.open_at(idx, cx); - } - } - "backspace" => { - this.query.pop(); - this.mode = mode_for_query(&this.query); - this.selected_index = 0; - this.recompute_filtered(); - cx.notify(); - } - "space" => { - this.query.push(' '); - this.selected_index = 0; - this.recompute_filtered(); - cx.notify(); - } - _ => { - // 无修饰键时把可打印字符追加到查询串,并随前缀切换模式 - if !event.keystroke.modifiers.control - && !event.keystroke.modifiers.alt - && !event.keystroke.modifiers.platform - { - let mut changed = false; - if let Some(ch) = &event.keystroke.key_char { - this.query.push_str(ch); - changed = true; - } else if key.chars().count() == 1 { - this.query.push_str(key); - changed = true; - } - if changed { - this.mode = mode_for_query(&this.query); - this.selected_index = 0; - this.recompute_filtered(); - cx.notify(); - } - } - } + _ => {} } })) .on_mouse_down( @@ -330,52 +321,37 @@ impl Render for QuickOpenModal { }), ) .child( - // 1. 顶部输入行:搜索图标 + 查询串/占位 + Esc 徽标 - h_flex() - .h(px(52.0)) - .w_full() - .items_center() - .gap_2p5() - .px_4() - .border_b_1() - .border_color(ThemeColors::border()) - .child( - Icon::new(IconName::Search) - .size(px(16.0)) - .text_color(ThemeColors::primary()), - ) - .child( - h_flex() - .flex_1() - .items_center() - .gap_1() - .child( - div() - .text_sm() - .text_color(if self.query.is_empty() { - ThemeColors::subtle_foreground() - } else { - ThemeColors::foreground() - }) - .child(if self.query.is_empty() { - crate::i18n::menu_text(cx, mode.placeholder_key()) - .to_string() - } else { - self.query.clone() - }), - ) - .child( - // 静态光标条,提示可输入 - div().w(px(2.0)).h(px(16.0)).bg(ThemeColors::primary()), - ), - ) - .child(count_badge( - self.filtered.len(), - self.files.len(), - !self.query.is_empty(), - cx, - )) - .child(shortcut_badge("Esc")), + // 1. 顶部输入行:搜索图标 + 统一搜索输入框 + 计数 + Esc 徽标。 + // 占位文案随模式变化(每次渲染比对模式后更新)。 + { + let wanted: gpui_kit::SharedString = + crate::i18n::menu_text(cx, mode.placeholder_key()).into(); + if self.placeholder_mode != mode { + self.placeholder_mode = mode; + self.search.set_placeholder(wanted, window, cx); + } + h_flex() + .h(px(52.0)) + .w_full() + .items_center() + .gap_2p5() + .px_4() + .border_b_1() + .border_color(ThemeColors::border()) + .child( + Icon::new(IconName::Search) + .size(px(16.0)) + .text_color(ThemeColors::primary()), + ) + .child(self.search.element()) + .child(count_badge( + self.filtered.len(), + self.files.len(), + !self.query.is_empty(), + cx, + )) + .child(shortcut_badge("Esc")) + }, ) .child( // 2. 结果列表:已打开优先、最近其次、其余最后,路径分段着色 diff --git a/linux/src/workbench/search_everywhere.rs b/linux/src/workbench/search_everywhere.rs index e34eddab0..c102326f6 100644 --- a/linux/src/workbench/search_everywhere.rs +++ b/linux/src/workbench/search_everywhere.rs @@ -1,15 +1,17 @@ use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::input::InputEvent; use gpui_kit::component::scroll::ScrollableElement as _; use gpui_kit::component::{h_flex, v_flex, Icon, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ div, px, rgba, Context, EventEmitter, FocusHandle, FontWeight, InteractiveElement as _, IntoElement, KeyDownEvent, ParentElement as _, Render, StatefulInteractiveElement as _, - Styled as _, Window, + Styled as _, Subscription, Window, }; use crate::theme::ThemeColors; +use crate::workbench::search_input::SearchInput; /// Search Everywhere 动作本地化显示名(id → 菜单键),未知 id 回退 id 本身。 fn action_display_name(id: &str, cx: &gpui_kit::App) -> String { @@ -80,12 +82,43 @@ pub struct SearchEverywhereModal { pub actions: Vec, pub selected_index: usize, pub focus_handle: FocusHandle, + /// 搜索框(复用统一搜索输入实现:IME / 粘贴由组件处理)。 + search: SearchInput, + _search_subscription: Subscription, + /// 打开时需要在下一帧复位并聚焦搜索框(只做一次)。 + pending_reset: bool, } impl EventEmitter for SearchEverywhereModal {} impl SearchEverywhereModal { - pub fn new(cx: &mut Context) -> Self { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let search = SearchInput::new( + crate::i18n::menu_text(cx, "search.everywherePlaceholder"), + window, + cx, + ); + let _search_subscription = search.subscribe(cx, |this, event, cx| match event { + InputEvent::Change => { + this.query = this.search.value(cx); + this.selected_index = 0; + cx.notify(); + } + InputEvent::PressEnter { shift, .. } => { + let items = this.filtered_items(cx); + let idx = if *shift { + this.selected_index.saturating_sub(1) + } else if items.is_empty() { + 0 + } else { + this.selected_index.min(items.len() - 1) + }; + if let Some(item) = items.get(idx) { + this.select_item(item, cx); + } + } + _ => {} + }); let actions = vec![ SearchActionItem { id: "workbench.new_file".to_string(), @@ -156,6 +189,9 @@ impl SearchEverywhereModal { actions, selected_index: 0, focus_handle: cx.focus_handle(), + search, + _search_subscription, + pending_reset: true, } } @@ -167,6 +203,7 @@ impl SearchEverywhereModal { pub fn reset(&mut self, cx: &mut Context) { self.query.clear(); self.selected_index = 0; + self.pending_reset = true; cx.notify(); } @@ -249,8 +286,12 @@ fn is_code_file(name: &str) -> bool { impl Render for SearchEverywhereModal { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - // 请求聚焦以接收按键输入 - window.focus(&self.focus_handle, cx); + // 打开后只复位/聚焦搜索框一次;不要每帧抢焦点(否则输入框打不进字)。 + if self.pending_reset { + self.pending_reset = false; + self.search.set_value("", window, cx); + self.search.focus(window, cx); + } let filtered = self.filtered_items(cx); let current_index = if filtered.is_empty() { @@ -270,11 +311,10 @@ impl Render for SearchEverywhereModal { .items_center() .justify_center() .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { - let key = event.keystroke.key.as_str(); - match key { - "escape" => { - cx.emit(SearchEverywhereEvent::Close); - } + // 字符输入 / 退格 / 空格由搜索框处理(含 IME 与粘贴); + // 这里只管方向键与 Esc。 + match event.keystroke.key.as_str() { + "escape" => cx.emit(SearchEverywhereEvent::Close), "up" | "arrowup" => { if this.selected_index > 0 { this.selected_index -= 1; @@ -288,43 +328,7 @@ impl Render for SearchEverywhereModal { cx.notify(); } } - "enter" => { - let items = this.filtered_items(cx); - let idx = if items.is_empty() { - 0 - } else { - this.selected_index.min(items.len() - 1) - }; - if let Some(item) = items.get(idx) { - this.select_item(item, cx); - } - } - "backspace" => { - this.query.pop(); - this.selected_index = 0; - cx.notify(); - } - "space" => { - this.query.push(' '); - this.selected_index = 0; - cx.notify(); - } - _ => { - if !event.keystroke.modifiers.control - && !event.keystroke.modifiers.alt - && !event.keystroke.modifiers.platform - { - if let Some(ch) = &event.keystroke.key_char { - this.query.push_str(ch); - this.selected_index = 0; - cx.notify(); - } else if key.chars().count() == 1 { - this.query.push_str(key); - this.selected_index = 0; - cx.notify(); - } - } - } + _ => {} } })) .on_mouse_down( @@ -368,43 +372,18 @@ impl Render for SearchEverywhereModal { .size(px(16.0)) .text_color(ThemeColors::accent_blue()), ) - .child( - h_flex() - .flex_1() - .items_center() - .gap_1() - .child( - div() - .text_sm() - .text_color(if self.query.is_empty() { - ThemeColors::text_muted() - } else { - ThemeColors::text_primary() - }) - .child(if self.query.is_empty() { - crate::i18n::menu_text( - cx, - "search.everywherePlaceholder", - ) - .to_string() - } else { - self.query.clone() - }), - ) - .child( - // 闪烁光标模拟 - div().w(px(2.0)).h(px(14.0)).bg(ThemeColors::accent_blue()), - ), - ) + .child(self.search.element()) .when(!self.query.is_empty(), |row| { row.child( Button::new("clear-search-query") .small() .ghost() .icon(IconName::Close) - .on_click(cx.listener(|this, _event, _window, cx| { + .on_click(cx.listener(|this, _event, window, cx| { this.query.clear(); this.selected_index = 0; + this.search.set_value("", window, cx); + this.search.focus(window, cx); cx.notify(); })), ) diff --git a/linux/src/workbench/search_input.rs b/linux/src/workbench/search_input.rs new file mode 100644 index 000000000..7327f0e95 --- /dev/null +++ b/linux/src/workbench/search_input.rs @@ -0,0 +1,126 @@ +//! 可复用的搜索输入框:Linux 工作台唯一的文本输入实现。 +//! +//! 背景:早期多个弹窗(文件树过滤、快速打开、命令面板、跳转到行、全局搜索、 +//! 分支管理器)各自用普通 `div` + `on_key_down` 手动拼接 `key_char` 来实现 +//! “输入框”。这种自绘输入没有注册 GPUI 的 input handler,导致: +//! - 中文输入法(XIM/ibus)的提交/预编辑事件无处落地,打不出汉字; +//! - `Ctrl+V` 粘贴、选区、光标移动都不支持; +//! - 每个弹窗都要重复写一遍同一套按键分支,规则容易漂移。 +//! +//! 现在统一接入本模块:用 gpui-kit 官方的 `Input`/`InputState` 组件,由组件 +//! 负责 IME 组字、剪贴板、选区与光标;各弹窗只关心业务事件。 +//! +//! 使用方式: +//! ```ignore +//! let search = SearchInput::new(placeholder, window, cx); +//! let subscription = search.subscribe(cx, |this, event, cx| match event { +//! InputEvent::Change => { /* 读取 search.value(cx) */ } +//! InputEvent::PressEnter { shift, .. } => { /* 回车 / Shift+回车 */ } +//! _ => {} +//! }); +//! // render 时:.child(search.element()) +//! ``` +//! +//! 关键约束(务必遵守):**不要在 `Render::render` 里每帧调用 +//! `window.focus(&owner.root_focus_handle)`**。那样会把焦点从搜索框手里夺走, +//! 造成“点得进去却打不出字”。打开弹窗时只聚焦一次即可(见 +//! [`SearchInput::focus`])。 + +use gpui_kit::component::input::{Input, InputEvent, InputState}; +use gpui_kit::component::Sizable as _; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::{ + div, AppContext as _, Context, IntoElement, ParentElement as _, Styled as _, Subscription, + Window, +}; + +/// 可复用的搜索输入框:持有输入状态与其事件订阅。 +pub struct SearchInput { + state: gpui_kit::Entity, + /// 是否使用紧凑尺寸(内嵌在低矮行里,如侧边栏过滤条)。 + compact: bool, +} + +impl SearchInput { + /// 新建搜索输入(带占位文案)。`placeholder` 会被解析为 owned 字符串。 + pub fn new( + placeholder: impl Into, + window: &mut Window, + cx: &mut gpui_kit::App, + ) -> Self { + let placeholder = placeholder.into(); + let state = cx.new(|cx| InputState::new(window, cx).placeholder(placeholder)); + Self { + state, + compact: false, + } + } + + /// 使用紧凑尺寸(对应 `Input::small()`)。 + pub fn compact(mut self) -> Self { + self.compact = true; + self + } + + /// 读取当前文本。 + pub fn value(&self, cx: &gpui_kit::App) -> String { + self.state.read(cx).value().to_string() + } + + /// 设置文本并清空撤销历史(打开弹窗复位、清空按钮时使用)。 + pub fn set_value( + &self, + value: impl Into, + window: &mut Window, + cx: &mut gpui_kit::App, + ) { + let value = value.into(); + self.state + .update(cx, |state, cx| state.set_value(value, window, cx)); + } + + /// 聚焦搜索框。弹窗打开时调用**一次**;不要在 render 里每帧调用根节点聚焦。 + pub fn focus(&self, window: &mut Window, cx: &mut gpui_kit::App) { + self.state.update(cx, |state, cx| state.focus(window, cx)); + } + + /// 更新占位文案(切换分区等场景)。 + pub fn set_placeholder( + &self, + placeholder: impl Into, + window: &mut Window, + cx: &mut gpui_kit::App, + ) { + let placeholder = placeholder.into(); + self.state.update(cx, |state, cx| { + state.set_placeholder(placeholder, window, cx) + }); + } + + /// 在 owner 上订阅输入事件,把原始 [`InputEvent`] 转交 owner 处理。 + /// + /// 返回的 [`Subscription`] 必须由 owner 持有其生命周期(保存到字段), + /// 否则订阅会立即失效。 + pub fn subscribe(&self, cx: &mut Context, handler: F) -> Subscription + where + Owner: 'static, + F: Fn(&mut Owner, &InputEvent, &mut Context) + 'static, + { + cx.subscribe( + &self.state, + move |owner: &mut Owner, _, event: &InputEvent, cx| { + handler(owner, event, cx); + }, + ) + } + + /// 渲染统一外观的搜索输入框主体(`appearance(false)`:关闭组件自带背景/ + /// 边框/焦点环,交由调用方的行容器决定外观,避免多出一层阴影或双边框)。 + pub fn element(&self) -> impl IntoElement { + div().flex_1().min_w_0().child( + Input::new(&self.state) + .when(self.compact, |input| input.small()) + .appearance(false), + ) + } +} diff --git a/linux/src/workbench/sidebar.rs b/linux/src/workbench/sidebar.rs index 5a9c5d088..7db53c127 100644 --- a/linux/src/workbench/sidebar.rs +++ b/linux/src/workbench/sidebar.rs @@ -1,14 +1,14 @@ use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants as _}; -use gpui_kit::component::input::{Input, InputEvent, InputState}; +use gpui_kit::component::input::InputEvent; use gpui_kit::component::menu::{DropdownMenu as _, PopupMenuItem}; use gpui_kit::component::scroll::ScrollableElement as _; use gpui_kit::component::{h_flex, v_flex, Icon, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, AppContext as _, Context, Entity, EventEmitter, FocusHandle, FontWeight, - InteractiveElement as _, IntoElement, KeyDownEvent, ParentElement as _, Render, - StatefulInteractiveElement as _, Styled as _, Subscription, Window, + div, px, Context, EventEmitter, FocusHandle, FontWeight, InteractiveElement as _, IntoElement, + KeyDownEvent, ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, + Subscription, Window, }; use serde::{Deserialize, Serialize}; @@ -146,8 +146,8 @@ pub struct SidebarView { tree_search_hits: Vec, /// 回车跳转游标:当前聚焦的命中下标 tree_search_match_index: usize, - /// 文件树过滤输入框(真实 `Input`,负责 IME 组字与剪贴板粘贴) - tree_search_input: Entity, + /// 文件树过滤输入框(复用统一搜索输入实现:IME / 粘贴由组件处理)。 + tree_search: super::search_input::SearchInput, _tree_search_subscription: Subscription, // 搜索状态 #[allow(dead_code)] @@ -167,27 +167,26 @@ impl EventEmitter for SidebarView {} impl SidebarView { pub fn new(root_path: String, window: &mut Window, cx: &mut Context) -> Self { - // 过滤输入用真实 `Input` 组件:本地 IME(XIM/ibus)组字、Ctrl+V 粘贴、 - // 选区与光标均由组件处理,不再靠 `on_key_down` 手动拼接 `key_char`。 - let tree_search_input = cx.new(|cx| { - InputState::new(window, cx).placeholder(crate::i18n::menu_text(cx, "search.search")) + // 过滤输入复用统一搜索输入组件:IME 组字、Ctrl+V 粘贴、选区与光标 + // 均由 gpui-kit `Input` 处理,不再靠 `on_key_down` 手动拼接 `key_char`。 + let tree_search = super::search_input::SearchInput::new( + crate::i18n::menu_text(cx, "search.search"), + window, + cx, + ) + .compact(); + let _tree_search_subscription = tree_search.subscribe(cx, |this, event, cx| match event { + InputEvent::Change => { + this.tree_filter = this.tree_search.value(cx); + this.recompute_tree_search(); + cx.notify(); + } + InputEvent::PressEnter { shift, .. } => { + // 对齐 Tauri:回车跳下一个命中,Shift+回车跳上一个。 + this.navigate_tree_search(!shift, cx); + } + _ => {} }); - let input_source = tree_search_input.clone(); - let _tree_search_subscription = cx.subscribe( - &tree_search_input, - move |this: &mut Self, _, event: &InputEvent, cx| match event { - InputEvent::Change => { - this.tree_filter = input_source.read(cx).value().to_string(); - this.recompute_tree_search(); - cx.notify(); - } - InputEvent::PressEnter { shift, .. } => { - // 对齐 Tauri:回车跳下一个命中,Shift+回车跳上一个。 - this.navigate_tree_search(!shift, cx); - } - _ => {} - }, - ); let mut view = Self { root_path, @@ -200,7 +199,7 @@ impl SidebarView { show_tree_filter: false, tree_search_hits: Vec::new(), tree_search_match_index: 0, - tree_search_input, + tree_search, _tree_search_subscription, search_query: String::new(), search_results: Vec::new(), @@ -570,11 +569,8 @@ impl Render for SidebarView { this.close_tree_search(window, cx); } else { this.show_tree_filter = true; - // 真实输入框接管键盘:打开时聚焦它。 - this.tree_search_input - .update(cx, |state, cx| { - state.focus(window, cx) - }); + // 搜索输入接管键盘:打开时聚焦它一次。 + this.tree_search.focus(window, cx); cx.notify(); } })), @@ -832,16 +828,15 @@ impl SidebarView { self.show_tree_filter = false; self.tree_search_hits.clear(); self.tree_search_match_index = 0; - self.tree_search_input - .update(cx, |state, cx| state.set_value("", window, cx)); + self.tree_search.set_value("", window, cx); cx.notify(); } - /// 文件树过滤输入行:真实 `Input`,由组件负责 IME 组字、Ctrl+V 粘贴、 - /// 选区、光标与回车提交(`InputEvent::PressEnter`)。 + /// 文件树过滤输入行:复用统一搜索输入组件(IME 组字、Ctrl+V 粘贴、 + /// 选区、光标与回车提交均由组件处理)。 /// - /// `Input` 用 `appearance(false)` 关掉自带的背景/边框/焦点环,保留外层 - /// 行现有的紧凑样式,避免多出一层阴影或双层边框。 + /// 输入框用 `appearance(false)` 关掉自带背景/边框/焦点环,保留外层行现有 + /// 的紧凑样式,避免多出一层阴影或双层边框。 fn render_filter_row(&self, cx: &mut Context) -> impl IntoElement { h_flex() .h(px(28.0)) @@ -859,13 +854,7 @@ impl SidebarView { .size(px(13.0)) .text_color(ThemeColors::text_muted()), ) - .child( - div().flex_1().min_w_0().child( - Input::new(&self.tree_search_input) - .small() - .appearance(false), - ), - ) + .child(self.tree_search.element()) .child( Button::new("sidebar-filter-clear") .small() diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index 8360bf676..ec3c1a51e 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -220,11 +220,11 @@ impl WorkbenchView { panel.set_workbench(workbench_entity.clone(), cx); }); let status_bar = cx.new(|_cx| StatusBarView::new()); - let search_everywhere = cx.new(|cx| SearchEverywhereModal::new(cx)); + let search_everywhere = cx.new(|cx| SearchEverywhereModal::new(window, cx)); let global_search = cx.new(|cx| GlobalSearchPanel::new(root.clone(), window, cx)); - let quick_open = cx.new(|cx| QuickOpenModal::new(cx)); - let go_to_line = cx.new(|cx| GoToLineModal::new(cx)); - let command_palette = cx.new(|cx| CommandPaletteModal::new(cx)); + let quick_open = cx.new(|cx| QuickOpenModal::new(window, cx)); + let go_to_line = cx.new(|cx| GoToLineModal::new(window, cx)); + let command_palette = cx.new(|cx| CommandPaletteModal::new(window, cx)); let settings_dialog = cx.new(|cx| SettingsDialog::new(cx)); settings_dialog.update(cx, |dialog, cx| { dialog.set_workspace_root(root.clone(), cx); From f36107d9b530af66de4d0966d02d93812b313252 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 19:46:38 +0800 Subject: [PATCH 070/109] =?UTF-8?q?style(linux):=20=E5=AF=B9=E8=B7=A8?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E6=94=B9=E9=80=A0=E5=90=8E=E7=9A=84=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E6=89=A7=E8=A1=8C=20cargo=20fmt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对上一轮平台跨平台改造涉及的文件运行 cargo fmt,仅格式化,无行为变化: - lsp.rs / terminal.rs / settings_dialog.rs / extensions_panel.rs: 长行换行、`PATHEXT` 默认值取值的断行调整。 - settings.rs:Windows `data_dir` 的 `or_else` 闭包收敛为单行。 - editor.rs:`EditorTabEvent` 变体改为多行字段形式。 - keybindings.rs:测试中的 `assert_eq!` 换行。 - core/client.rs:`snapshot` / `git_status` 签名由多行收敛为单行。 --- linux/src/core/client.rs | 12 +--- linux/src/keybindings.rs | 10 ++- linux/src/lsp.rs | 9 ++- linux/src/settings.rs | 4 +- linux/src/workbench/editor.rs | 9 ++- linux/src/workbench/extensions_panel.rs | 6 +- linux/src/workbench/settings_dialog.rs | 94 +++++++++++-------------- linux/src/workbench/terminal.rs | 4 +- 8 files changed, 74 insertions(+), 74 deletions(-) diff --git a/linux/src/core/client.rs b/linux/src/core/client.rs index ae9fb5912..9b821f5ef 100644 --- a/linux/src/core/client.rs +++ b/linux/src/core/client.rs @@ -136,11 +136,7 @@ impl CoreClient { } /// 获取工作区快照 - pub fn snapshot( - &self, - cx: &gpui_kit::AsyncApp, - root: &str, - ) -> Task> { + pub fn snapshot(&self, cx: &gpui_kit::AsyncApp, root: &str) -> Task> { self.execute( cx, "workspace.snapshot", @@ -273,11 +269,7 @@ impl CoreClient { } /// 获取 Git 状态 - pub fn git_status( - &self, - cx: &gpui_kit::AsyncApp, - root: &str, - ) -> Task> { + pub fn git_status(&self, cx: &gpui_kit::AsyncApp, root: &str) -> Task> { self.execute( cx, "git.status", diff --git a/linux/src/keybindings.rs b/linux/src/keybindings.rs index 8fcd96193..5c6110852 100644 --- a/linux/src/keybindings.rs +++ b/linux/src/keybindings.rs @@ -355,8 +355,14 @@ mod tests { let id = KeyStrokeId::parse("Ctrl+B").expect("parse"); assert_ne!(index.lookup(&id), Some("go.references")); // F5/F9 等调试快捷键后端未接入,不应绑定。 - assert_eq!(index.lookup(&KeyStrokeId::parse("F5").expect("parse")), None); - assert_eq!(index.lookup(&KeyStrokeId::parse("F9").expect("parse")), None); + assert_eq!( + index.lookup(&KeyStrokeId::parse("F5").expect("parse")), + None + ); + assert_eq!( + index.lookup(&KeyStrokeId::parse("F9").expect("parse")), + None + ); } #[test] diff --git a/linux/src/lsp.rs b/linux/src/lsp.rs index 0312da4c0..9e8b48598 100644 --- a/linux/src/lsp.rs +++ b/linux/src/lsp.rs @@ -46,7 +46,10 @@ fn jdtls_configuration_name() -> &'static str { { "config_linux_arm" } - #[cfg(all(target_os = "linux", not(any(target_arch = "aarch64", target_arch = "arm"))))] + #[cfg(all( + target_os = "linux", + not(any(target_arch = "aarch64", target_arch = "arm")) + ))] { "config_linux" } @@ -244,8 +247,8 @@ fn executable_candidates(dir: &Path, name: &str) -> Vec { { let mut candidates = vec![dir.join(name)]; if Path::new(name).extension().is_none() { - let pathext = std::env::var("PATHEXT") - .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()); + let pathext = + std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()); for extension in pathext.split(';').filter(|value| !value.is_empty()) { candidates.push(dir.join(format!("{name}{extension}"))); } diff --git a/linux/src/settings.rs b/linux/src/settings.rs index 6c1d5c35f..9240855e8 100644 --- a/linux/src/settings.rs +++ b/linux/src/settings.rs @@ -458,9 +458,7 @@ pub fn data_dir() -> Option { std::env::var_os("LOCALAPPDATA") .map(PathBuf::from) .filter(|path| !path.as_os_str().is_empty()) - .or_else(|| { - user_home_dir().map(|home| home.join("AppData").join("Local")) - }) + .or_else(|| user_home_dir().map(|home| home.join("AppData").join("Local"))) } } diff --git a/linux/src/workbench/editor.rs b/linux/src/workbench/editor.rs index d40a745b4..b062f62e6 100644 --- a/linux/src/workbench/editor.rs +++ b/linux/src/workbench/editor.rs @@ -1020,9 +1020,14 @@ impl EditorView { /// 编辑器标签派发的事件(右键菜单动作由 UI 层触发,父视图订阅处理)。 #[derive(Debug, Clone)] pub enum EditorTabEvent { - OpenInTerminal { dir: String }, + OpenInTerminal { + dir: String, + }, /// 活动标签的内容或身份发生变化,父视图据此同步 LSP 文档态。 - DocumentChanged { path: String, text: String }, + DocumentChanged { + path: String, + text: String, + }, } impl EventEmitter for EditorView {} diff --git a/linux/src/workbench/extensions_panel.rs b/linux/src/workbench/extensions_panel.rs index 77f2f93ec..ab5c00017 100644 --- a/linux/src/workbench/extensions_panel.rs +++ b/linux/src/workbench/extensions_panel.rs @@ -917,7 +917,11 @@ fn classify_text(text: &str) -> ExtensionCategory { /// 启用状态落盘路径:`/lithe/extensions.json` ///(配置目录规则见 `settings::config_dir`)。 fn extensions_state_path() -> Option { - Some(crate::settings::config_dir()?.join("lithe").join("extensions.json")) + Some( + crate::settings::config_dir()? + .join("lithe") + .join("extensions.json"), + ) } fn load_enabled() -> HashMap { diff --git a/linux/src/workbench/settings_dialog.rs b/linux/src/workbench/settings_dialog.rs index 73534947f..6d846c68e 100644 --- a/linux/src/workbench/settings_dialog.rs +++ b/linux/src/workbench/settings_dialog.rs @@ -978,59 +978,51 @@ impl SettingsDialog { .child( self.render_group( crate::i18n::menu_text(cx, "settings.mac.appearance").to_string(), - v_flex() - .w_full() - .gap_3() - .child( - self.render_row( - crate::i18n::menu_text(cx, "settings.mac.appearanceMode") - .to_string(), - Some( - crate::i18n::menu_text( - cx, - "settings.mac.appearanceDescription", - ) + v_flex().w_full().gap_3().child( + self.render_row( + crate::i18n::menu_text(cx, "settings.mac.appearanceMode").to_string(), + Some( + crate::i18n::menu_text(cx, "settings.mac.appearanceDescription") .to_string(), - ), - self.render_dropdown( - "general-appearance-mode", - appearance_mode, - 160.0, - vec![ - ( - "system", - crate::i18n::menu_text(cx, "settings.mac.followSystem") - .to_string(), - ), - ( - "light", - crate::i18n::menu_text(cx, "settings.mac.light") - .to_string(), - ), - ( - "dark", - crate::i18n::menu_text(cx, "settings.mac.dark") - .to_string(), - ), - ], - cx, - |this, mode, cx| { - this.commit_theme(cx, |s| { - if mode == "system" { - s.sync_system_theme = true; + ), + self.render_dropdown( + "general-appearance-mode", + appearance_mode, + 160.0, + vec![ + ( + "system", + crate::i18n::menu_text(cx, "settings.mac.followSystem") + .to_string(), + ), + ( + "light", + crate::i18n::menu_text(cx, "settings.mac.light") + .to_string(), + ), + ( + "dark", + crate::i18n::menu_text(cx, "settings.mac.dark").to_string(), + ), + ], + cx, + |this, mode, cx| { + this.commit_theme(cx, |s| { + if mode == "system" { + s.sync_system_theme = true; + } else { + s.sync_system_theme = false; + s.theme = if mode == "light" { + "lithe-light".to_string() } else { - s.sync_system_theme = false; - s.theme = if mode == "light" { - "lithe-light".to_string() - } else { - "lithe-dark".to_string() - }; - } - }) - }, - ), + "lithe-dark".to_string() + }; + } + }) + }, ), ), + ), ), ) .child(self.render_group( @@ -3122,8 +3114,8 @@ fn executable_candidates(dir: &std::path::Path, name: &str) -> Vec Vec Date: Fri, 25 Sep 2026 19:51:53 +0800 Subject: [PATCH 071/109] =?UTF-8?q?fix(linux):=20=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=8C=BA=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95=E8=B7=9F=E9=9A=8F?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E8=AF=AD=E8=A8=80=E5=88=87=E6=8D=A2=E4=B8=BA?= =?UTF-8?q?=E4=B8=AD=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:文件打开后编辑区右键弹出的菜单(剪切/复制/粘贴/全选、跳转到定义、 显示代码操作)始终是英文,即使设置里显示语言为简体中文。 原因:这些菜单项由上游 gpui-component 内置渲染,文案取自 `rust_i18n` 的 `Input.*` 键,默认 `fallback = "en"`。之前从未调用 `set_locale`,上游 `locales/ui.yml` 里已有的 `zh-CN` 翻译没有生效。 改动: - linux/src/i18n.rs 新增 `upstream_locale` 与 `apply_locale`,把产品语言标识 (zh-CN / en-US)归一为上游认的 `zh-CN` / `en` 并写入上游文案域。 - linux/src/settings.rs 在 `init`(启动)与 `update`(运行时改语言)时调用 `apply_locale`,保证启动即为中文、切换语言后立即生效。 - 增加 `display_language_maps_to_upstream_locale` 单测覆盖映射。 验证:cargo build 通过;cargo test --lib 96 项全通过。 --- linux/src/i18n.rs | 36 ++++++++++++++++++++++++++++++++++++ linux/src/settings.rs | 4 ++++ 2 files changed, 40 insertions(+) diff --git a/linux/src/i18n.rs b/linux/src/i18n.rs index 535edbe6a..0fd356fd4 100644 --- a/linux/src/i18n.rs +++ b/linux/src/i18n.rs @@ -16,6 +16,27 @@ pub fn is_zh(cx: &App) -> bool { settings::get(cx).display_language.starts_with("zh") } +/// 把显示语言映射为上游 gpui-component `locales/ui.yml` 使用的标识。 +/// +/// 上游内置文案(编辑区右键菜单、日期选择器、命令面板等)只认 `zh-CN` 这类 +/// 具体语言码,产品设置里的 `zh-CN` / `en-US` 需要归一到这里。 +pub fn upstream_locale(display_language: &str) -> &'static str { + if display_language.starts_with("zh") { + "zh-CN" + } else { + "en" + } +} + +/// 把显示语言同步给上游 gpui-component 的文案域(编辑区右键菜单、日期选择器、 +/// 命令面板等内置 UI 文案)。 +/// +/// 上游用 `rust_i18n` 的 `t!` 取键(如 `Input.Cut`),默认 `fallback = "en"`, +/// 只有显式 `set_locale` 才会切到中文;不调用就会一直渲染英文菜单。 +pub fn apply_locale(display_language: &str) { + gpui_kit::component::set_locale(upstream_locale(display_language)); +} + /// 取菜单文案:键与 Tauri `menu.*` / `titleProject.*` 对齐,不存在返回 `""`。 pub fn menu_text(cx: &App, key: &str) -> &'static str { let (zh, en) = match key { @@ -809,3 +830,18 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { en } } + +#[cfg(test)] +mod tests { + use super::upstream_locale; + + /// 设置里存的是产品语言标识(`zh-CN`/`en-US`),上游只认 `zh-CN`/`en`; + /// 映射错了内置右键菜单就会退回英文。 + #[test] + fn display_language_maps_to_upstream_locale() { + assert_eq!(upstream_locale("zh-CN"), "zh-CN"); + assert_eq!(upstream_locale("zh"), "zh-CN"); + assert_eq!(upstream_locale("en-US"), "en"); + assert_eq!(upstream_locale("en"), "en"); + } +} diff --git a/linux/src/settings.rs b/linux/src/settings.rs index 9240855e8..b7992710d 100644 --- a/linux/src/settings.rs +++ b/linux/src/settings.rs @@ -508,6 +508,7 @@ pub fn persist(settings: &Settings) { /// 初始化全局设置(应用启动时调用一次)。 pub fn init(cx: &mut App) { let settings = load(); + crate::i18n::apply_locale(&settings.display_language); cx.set_global(AppSettings { settings }); } @@ -517,6 +518,9 @@ pub fn update(cx: &mut App, f: impl FnOnce(&mut Settings)) { let settings = get_mut(cx); settings.display_language = normalized_display_language(&settings.display_language); let snapshot = get(cx).clone(); + // 上游组件文案域必须与显示语言同步,否则编辑区右键菜单等内置 UI + // 会一直保持上一次的语言。 + crate::i18n::apply_locale(&snapshot.display_language); persist(&snapshot); cx.refresh_windows(); } From 4082bdf1a888928e22ebe6e9ebf6c9d617a24600 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 20:42:31 +0800 Subject: [PATCH 072/109] fix(linux): enable run output wheel scrolling --- linux/src/workbench/bottom_panel.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/linux/src/workbench/bottom_panel.rs b/linux/src/workbench/bottom_panel.rs index f8f015e78..ec1e8394c 100644 --- a/linux/src/workbench/bottom_panel.rs +++ b/linux/src/workbench/bottom_panel.rs @@ -1880,6 +1880,10 @@ impl BottomPanelView { div() .flex_1() .h_full() + .min_h_0() + .id("run-output-scroll") + .overflow_y_scroll() + .track_scroll(&self.run_scroll) .vertical_scrollbar(&self.run_scroll) .p_2() .font_family("monospace") From 3888fd2f6cda7f5398705109d18fd67e56163ae5 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 21:00:11 +0800 Subject: [PATCH 073/109] =?UTF-8?q?fix(linux):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E7=BC=96=E8=BE=91=E8=87=AA=E5=8A=A8=E4=BF=9D?= =?UTF-8?q?=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- linux/src/workbench/editor.rs | 279 ++++++++++++++++++++++++++++++++-- linux/src/workbench/view.rs | 6 + 2 files changed, 274 insertions(+), 11 deletions(-) diff --git a/linux/src/workbench/editor.rs b/linux/src/workbench/editor.rs index b062f62e6..888a78de5 100644 --- a/linux/src/workbench/editor.rs +++ b/linux/src/workbench/editor.rs @@ -7,10 +7,12 @@ use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ div, px, relative, AppContext as _, ClipboardItem, Context, Entity, EventEmitter, FontWeight, InteractiveElement as _, IntoElement, ParentElement as _, Render, - StatefulInteractiveElement as _, Styled as _, Subscription, Window, + StatefulInteractiveElement as _, Styled as _, Subscription, Task, Window, }; +use std::collections::HashMap; use std::rc::Rc; +use std::time::Duration; use super::panes::PaneId; use super::tab_menu::{SplitCallback, ToggleLockCallback}; @@ -19,6 +21,33 @@ use crate::core::CoreClient; use crate::settings; use crate::theme::ThemeColors; +/// 与 Windows 编辑器一致的自动保存防抖窗口。 +const AUTO_SAVE_DELAY: Duration = Duration::from_millis(150); + +/// 单个文档的自动保存任务状态。 +/// +/// 每个文档只保留一个前台任务。连续编辑只推进修订号并重置防抖标记,不创建 +/// 并行写盘任务;旧快照写盘后若修订号已变化,当前任务继续保存最新内容。 +struct AutoSaveState { + revision: u64, + running: bool, + debounce_requested: bool, + task: Option>, +} + +impl AutoSaveState { + fn request(&mut self) { + self.revision = self.revision.wrapping_add(1); + self.debounce_requested = true; + } + + fn take_debounce(&mut self) -> bool { + let requested = self.debounce_requested; + self.debounce_requested = false; + requested + } +} + #[derive(Debug, Clone)] pub struct EditorTab { pub path: String, @@ -57,6 +86,8 @@ pub struct EditorView { sync_needed: bool, /// 监听编辑器文本变化以回写标签内容与脏标记。 _editor_subscription: Subscription, + /// 按文档路径隔离自动保存任务,避免切换标签或连续编辑产生并行写入。 + auto_saves: HashMap, client: CoreClient, /// 撤销/重做历史(全文快照,栈顶恒等于编辑器当前值)。 undo_stack: Vec, @@ -106,6 +137,7 @@ impl EditorView { synced_tab: None, sync_needed: false, _editor_subscription, + auto_saves: HashMap::new(), client: CoreClient::new(), undo_stack: Vec::new(), redo_stack: Vec::new(), @@ -118,15 +150,189 @@ impl EditorView { } } + /// 为活动文档安排自动保存;关闭自动保存或关闭文档时取消对应任务。 + fn schedule_active_auto_save(&mut self, cx: &mut Context) { + let Some(path) = self + .active_tab_index + .and_then(|index| self.tabs.get(index).map(|tab| tab.path.clone())) + else { + return; + }; + self.schedule_auto_save(path, cx); + } + + /// 按文档修订启动唯一的自动保存任务。 + /// + /// 防抖阶段不取消正在等待的任务,而是由下一次循环重新计时;写入阶段保持 + /// 同一个任务串行执行,确保新修订不会与旧快照并行落盘。 + fn schedule_auto_save(&mut self, path: String, cx: &mut Context) { + if !settings::get(cx).auto_save { + self.cancel_auto_save(&path); + return; + } + + let should_start = match self.auto_saves.entry(path.clone()) { + std::collections::hash_map::Entry::Occupied(mut entry) => { + let state = entry.get_mut(); + state.request(); + !state.running + } + std::collections::hash_map::Entry::Vacant(entry) => { + let mut state = AutoSaveState { + revision: 0, + running: false, + debounce_requested: false, + task: None, + }; + state.request(); + entry.insert(state); + true + } + }; + if !should_start { + return; + } + if let Some(state) = self.auto_saves.get_mut(&path) { + state.running = true; + } + + let root = self.workspace_root.clone(); + let client = self.client.clone(); + let worker_path = path.clone(); + let task = cx.spawn(async move |this, cx| loop { + let should_debounce = this + .update(cx, |editor, cx| { + if !editor.auto_saves.contains_key(&worker_path) { + return None; + } + if !settings::get(cx).auto_save { + if let Some(state) = editor.auto_saves.get_mut(&worker_path) { + state.running = false; + } + return None; + } + let Some(tab) = editor.tabs.iter().find(|tab| tab.path == worker_path) else { + if let Some(state) = editor.auto_saves.get_mut(&worker_path) { + state.running = false; + } + return None; + }; + if !tab.is_dirty { + if let Some(state) = editor.auto_saves.get_mut(&worker_path) { + state.running = false; + } + return None; + } + let Some(state) = editor.auto_saves.get_mut(&worker_path) else { + return None; + }; + Some(state.take_debounce()) + }) + .unwrap_or(None); + let Some(should_debounce) = should_debounce else { + break; + }; + if should_debounce { + cx.background_executor().timer(AUTO_SAVE_DELAY).await; + continue; + } + + let snapshot = this + .update(cx, |editor, cx| { + if !settings::get(cx).auto_save { + return None; + } + let state = editor.auto_saves.get(&worker_path)?; + let tab = editor.tabs.iter().find(|tab| tab.path == worker_path)?; + if !tab.is_dirty { + return None; + } + let text = editor + .active_tab_index + .and_then(|index| editor.tabs.get(index)) + .filter(|active| active.path == worker_path) + .map(|_| editor.editor_state.read(cx).value().to_string()) + .unwrap_or_else(|| tab.content.clone()); + Some((state.revision, text)) + }) + .unwrap_or(None); + let Some((revision, text)) = snapshot else { + let _ = this.update(cx, |editor, _| { + if let Some(state) = editor.auto_saves.get_mut(&worker_path) { + state.running = false; + } + }); + break; + }; + + let result = client.write_file(&cx, &root, &worker_path, &text).await; + let should_continue = this + .update(cx, |editor, cx| { + let active_has_newer_text = editor + .active_tab_index + .and_then(|index| editor.tabs.get(index)) + .filter(|active| active.path == worker_path) + .is_some_and(|_| editor.editor_state.read(cx).value().to_string() != text); + let Some(state) = editor.auto_saves.get_mut(&worker_path) else { + return false; + }; + let Some(tab) = editor.tabs.iter_mut().find(|tab| tab.path == worker_path) + else { + state.running = false; + return false; + }; + + if let Err(error) = &result { + tracing::error!( + path = %worker_path, + error = %error, + "automatic file save failed" + ); + } else if tab.content == text && !active_has_newer_text { + tab.is_dirty = false; + } + + let content_changed = tab.content != text || active_has_newer_text; + let newer_revision = state.revision != revision; + if result.is_ok() && (content_changed || newer_revision) { + state.debounce_requested = true; + true + } else { + state.running = false; + false + } + }) + .unwrap_or(false); + if !should_continue { + break; + } + }); + if let Some(state) = self.auto_saves.get_mut(&path) { + state.task = Some(task); + } + } + + /// 取消指定文档的自动保存任务。 + pub fn cancel_auto_save(&mut self, path: &str) { + self.auto_saves.remove(path); + } + + /// 清理已关闭文档的自动保存任务。 + fn retain_auto_save_states(&mut self) { + self.auto_saves + .retain(|path, _| self.tabs.iter().any(|tab| tab.path == *path)); + } + /// 打开文件,如果已在标签中则切换,否则新增标签 pub fn open_file(&mut self, path: String, content: String, cx: &mut Context) { if let Some(pos) = self.tabs.iter().position(|t| t.path == path) { - if let Some(tab) = self.tabs.get_mut(pos) { - if tab.content != content { + if self.tabs[pos].content != content { + self.cancel_auto_save(&self.tabs[pos].path.clone()); + if let Some(tab) = self.tabs.get_mut(pos) { tab.content = content; tab.is_dirty = false; - self.sync_needed = true; } + self.sync_needed = true; } self.active_tab_index = Some(pos); self.publish_active_document(cx); @@ -160,6 +366,7 @@ impl EditorView { if index < self.tabs.len() { self.record_closed(index); self.tabs.remove(index); + self.retain_auto_save_states(); if self.tabs.is_empty() { self.active_tab_index = None; } else if let Some(current) = self.active_tab_index { @@ -229,12 +436,17 @@ impl EditorView { }; let value = self.editor_state.read(cx).value().to_string(); + let mut changed = false; if let Some(tab) = self.tabs.get_mut(index) { if tab.content != value { tab.content = value.clone(); tab.is_dirty = true; + changed = true; } } + if changed { + self.schedule_active_auto_save(cx); + } if self.undo_stack.last().is_none_or(|top| *top != value) { self.undo_stack.push(value); if self.undo_stack.len() > 100 { @@ -281,14 +493,17 @@ impl EditorView { cx.spawn(async move |this, cx| { client.write_file(&cx, &root, &path, &text).await?; let _ = this.update(cx, |ed, cx| { - if let Some(active_idx) = ed.active_tab_index { - if let Some(tab) = ed.tabs.get_mut(active_idx) { - if tab.path == path { - tab.is_dirty = false; - cx.notify(); - } + let active_has_newer_text = ed + .active_tab_index + .and_then(|index| ed.tabs.get(index)) + .filter(|active| active.path == path) + .is_some_and(|_| ed.editor_state.read(cx).value().to_string() != text); + if let Some(tab) = ed.tabs.iter_mut().find(|tab| tab.path == path) { + if tab.content == text && !active_has_newer_text { + tab.is_dirty = false; } } + cx.notify(); }); Ok(()) }) @@ -316,6 +531,7 @@ impl EditorView { tab.is_dirty = true; } } + self.schedule_active_auto_save(cx); cx.notify(); } @@ -343,6 +559,7 @@ impl EditorView { tab.is_dirty = true; } } + self.schedule_active_auto_save(cx); cx.notify(); } @@ -370,6 +587,7 @@ impl EditorView { tab.is_dirty = true; } } + self.schedule_active_auto_save(cx); cx.notify(); } @@ -603,6 +821,7 @@ impl EditorView { self.closed_stack.drain(..overflow); } self.tabs = kept; + self.retain_auto_save_states(); self.active_tab_index = active_path.and_then(|path| self.tabs.iter().position(|tab| tab.path == path)); self.sync_needed = true; @@ -633,6 +852,7 @@ impl EditorView { // 固定组在前并保持原相对顺序。 kept.sort_by_key(|(index, tab)| (!tab.is_pinned, *index)); self.tabs = kept.into_iter().map(|(_, tab)| tab).collect(); + self.retain_auto_save_states(); self.active_tab_index = active_path.and_then(|path| self.tabs.iter().position(|tab| tab.path == path)); self.sync_needed = true; @@ -661,6 +881,7 @@ impl EditorView { self.closed_stack.drain(..overflow); } self.tabs = kept; + self.retain_auto_save_states(); self.active_tab_index = active_path.and_then(|path| self.tabs.iter().position(|tab| tab.path == path)); self.sync_needed = true; @@ -691,6 +912,7 @@ impl EditorView { for (offset, tab) in pinned_left.into_iter().enumerate() { self.tabs.insert(offset, tab); } + self.retain_auto_save_states(); self.active_tab_index = active_path.and_then(|path| self.tabs.iter().position(|tab| tab.path == path)); self.sync_needed = true; @@ -718,6 +940,7 @@ impl EditorView { self.closed_stack.drain(..overflow); } self.tabs.extend(pinned_right); + self.retain_auto_save_states(); self.sync_needed = true; cx.notify(); } @@ -743,6 +966,7 @@ impl EditorView { // 固定组在前并保持原相对顺序;`idx` 未固定时排在固定段之后。 kept.sort_by_key(|(index, tab)| (!tab.is_pinned, *index)); self.tabs = kept.into_iter().map(|(_, tab)| tab).collect(); + self.retain_auto_save_states(); self.active_tab_index = kept_path.and_then(|path| self.tabs.iter().position(|tab| tab.path == path)); self.sync_needed = true; @@ -771,6 +995,7 @@ impl EditorView { self.closed_stack.drain(..overflow); } self.tabs.extend(pinned_right); + self.retain_auto_save_states(); if let Some(active) = self.active_tab_index { if active > idx { // 活动页被关掉时回退到 `idx`;固定活动页幸存时按 path 跟随。 @@ -817,6 +1042,7 @@ impl EditorView { return; }; let path = tab.path.clone(); + self.cancel_auto_save(&path); let root = self.workspace_root.clone(); let client = self.client.clone(); @@ -858,6 +1084,7 @@ impl EditorView { self.active_tab_index = Some(insert_at); } self.sync_needed = true; + self.schedule_active_auto_save(cx); cx.notify(); } @@ -889,8 +1116,15 @@ impl EditorView { for (path, text) in jobs { if client.write_file(&cx, &root, &path, &text).await.is_ok() { let _ = this.update(cx, |ed, cx| { + let active_has_newer_text = ed + .active_tab_index + .and_then(|index| ed.tabs.get(index)) + .filter(|active| active.path == path) + .is_some_and(|_| ed.editor_state.read(cx).value().to_string() != text); if let Some(tab) = ed.tabs.iter_mut().find(|tab| tab.path == path) { - tab.is_dirty = false; + if tab.content == text && !active_has_newer_text { + tab.is_dirty = false; + } } cx.notify(); }); @@ -1386,3 +1620,26 @@ impl Render for EditorView { ) } } + +#[cfg(test)] +mod tests { + use super::AutoSaveState; + + #[test] + fn auto_save_burst_keeps_latest_revision_in_one_task() { + let mut state = AutoSaveState { + revision: 0, + running: true, + debounce_requested: false, + task: None, + }; + + state.request(); + state.request(); + + assert_eq!(state.revision, 2); + assert!(state.running, "a running worker must absorb repeated edits"); + assert!(state.take_debounce()); + assert!(!state.take_debounce()); + } +} diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index ec3c1a51e..39cc2e398 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -2056,6 +2056,9 @@ impl WorkbenchView { cx.spawn(async move |_this, cx| { if client.write_file(&cx, &root, &rel, &text).await.is_ok() { let _ = editor.update(cx, |ed, cx| { + if ed.tabs.iter().any(|tab| tab.path == old_path) { + ed.cancel_auto_save(&old_path); + } if let Some(idx) = ed.active_tab_index { if let Some(t) = ed.tabs.get_mut(idx) { if t.path == old_path { @@ -2073,6 +2076,9 @@ impl WorkbenchView { } else if std::fs::write(&dest, text.as_bytes()).is_ok() { let abs = dest.to_string_lossy().to_string(); let _ = active.update(cx, |ed, cx| { + if ed.tabs.iter().any(|tab| tab.path == old_path) { + ed.cancel_auto_save(&old_path); + } if let Some(idx) = ed.active_tab_index { if let Some(t) = ed.tabs.get_mut(idx) { if t.path == old_path { From da7f43a34a7effafd3bee06e04a6e3530efb7eaf Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 21:21:38 +0800 Subject: [PATCH 074/109] =?UTF-8?q?fix(linux):=20=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=8C=BA=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95=E4=BF=9D=E6=8C=81?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E9=80=89=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- linux/src/i18n.rs | 1 + linux/src/workbench/editor.rs | 216 +++++++++++++++++++++++++++++++++- 2 files changed, 213 insertions(+), 4 deletions(-) diff --git a/linux/src/i18n.rs b/linux/src/i18n.rs index 0fd356fd4..3c35f8164 100644 --- a/linux/src/i18n.rs +++ b/linux/src/i18n.rs @@ -115,6 +115,7 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { "menu.goBack" => ("后退", "Go Back"), "menu.goForward" => ("前进", "Go Forward"), "menu.goToDefinition" => ("转到定义", "Go to Definition"), + "menu.showCodeActions" => ("显示代码操作", "Show Code Actions"), "menu.goToImplementation" => ("转到实现", "Go to Implementation"), "menu.goToTypeDefinition" => ("转到类型定义", "Go to Type Definition"), "menu.goToReferences" => ("转到引用", "Go to References"), diff --git a/linux/src/workbench/editor.rs b/linux/src/workbench/editor.rs index 888a78de5..cd7a33547 100644 --- a/linux/src/workbench/editor.rs +++ b/linux/src/workbench/editor.rs @@ -1,13 +1,16 @@ use gpui_kit::assets::IconName; +use gpui_kit::base::input::{InputContextMenuCapabilities, NativeMenu}; +use gpui_kit::base::ElementExt as _; use gpui_kit::component::input::{Editor, EditorState, InputEvent, Position}; -use gpui_kit::component::menu::ContextMenuExt as _; +use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenu}; use gpui_kit::component::scroll::ScrollableElement as _; use gpui_kit::component::{h_flex, v_flex, Icon}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, relative, AppContext as _, ClipboardItem, Context, Entity, EventEmitter, FontWeight, - InteractiveElement as _, IntoElement, ParentElement as _, Render, - StatefulInteractiveElement as _, Styled as _, Subscription, Task, Window, + div, px, relative, AppContext as _, ClipboardItem, Context, DismissEvent, Entity, EventEmitter, + Focusable as _, FontWeight, InteractiveElement as _, IntoElement, KeyDownEvent, + ParentElement as _, Pixels, Point, Render, StatefulInteractiveElement as _, Styled as _, + Subscription, Task, Window, }; use std::collections::HashMap; @@ -48,6 +51,16 @@ impl AutoSaveState { } } +/// 编辑区右键菜单的临时状态。 +/// +/// Linux 的 `NativeMenu` fallback 会把焦点移到 `PopupMenu`,上游编辑器因此 +/// 隐藏选区。这里复用同一个官方菜单与动作,但不让菜单取得编辑器焦点。 +struct EditorContextMenu { + menu: Entity, + position: Point, + _subscription: Subscription, +} + #[derive(Debug, Clone)] pub struct EditorTab { pub path: String, @@ -96,6 +109,8 @@ pub struct EditorView { closed_stack: Vec<(EditorTab, usize)>, /// 右键菜单目标标签(UI 层读写,方法层不消费)。 pub context_menu_tab: Option, + /// 编辑区正文右键菜单;保持编辑器焦点,避免 Linux fallback 隐藏选区。 + editor_context_menu: Option, /// 所属窗格(工作台装配;`None` 时右键菜单用 0 与空回调兜底)。 pub pane_id: Option, /// 所属窗格锁定镜像(工作台回写,决定锁定项文案)。 @@ -143,6 +158,7 @@ impl EditorView { redo_stack: Vec::new(), closed_stack: Vec::new(), context_menu_tab: None, + editor_context_menu: None, pane_id: None, pane_locked: false, on_split: None, @@ -652,6 +668,72 @@ impl EditorView { } } + /// 在编辑器完成本帧渲染后接管其右键回调。 + /// + /// `Editor::render` 每帧都会重新安装上游默认的 `NativeMenu` 回调,因此这里 + /// 必须在子元素的 `on_prepaint` 阶段覆盖它;鼠标按下/释放仍由上游编辑器 + /// 处理,因此右键到选区外的光标移动语义保持不变。 + fn install_editor_context_menu_handler(&mut self, cx: &mut Context) { + if self.active_tab_index.is_none() { + return; + } + + let view = cx.entity().downgrade(); + self.editor_state.update(cx, |state, _cx| { + state.on_context_menu(Rc::new( + move |_menu: NativeMenu, + _capabilities: InputContextMenuCapabilities, + position: Point, + window: &mut Window, + cx: &mut gpui_kit::App| { + let Some(view) = view.upgrade() else { + return; + }; + view.update(cx, |editor, cx| { + editor.open_editor_context_menu(position, window, cx); + }); + }, + )); + }); + } + + /// 打开编辑区正文右键菜单。 + /// + /// gpui-kit 的 Linux `NativeMenu` fallback 会聚焦 `PopupMenu`,从而让上游 + /// `Editor` 的选区绘制条件失效。这里复用官方 `PopupMenu` 和编辑动作,但 + /// 不把焦点交给菜单,编辑器因此持续绘制原有蓝色选区。 + fn open_editor_context_menu( + &mut self, + position: Point, + window: &mut Window, + cx: &mut Context, + ) { + if self.active_tab_index.is_none() { + return; + } + + let view = cx.entity(); + let editor_focus = self + .editor_state + .read(cx) + .presentation() + .focus_handle() + .clone(); + let menu = PopupMenu::build(window, cx, move |menu, _window, cx| { + build_editor_context_menu(menu, cx, &view).action_context(editor_focus) + }); + let subscription = cx.subscribe(&menu, |this, _, _: &DismissEvent, cx| { + this.editor_context_menu = None; + cx.notify(); + }); + self.editor_context_menu = Some(EditorContextMenu { + menu, + position, + _subscription: subscription, + }); + cx.notify(); + } + /// 当前光标行(0-based)与编辑器全文。 fn active_row(&mut self, cx: &mut Context) -> Option<(usize, String)> { if self.active_tab_index.is_none() { @@ -1300,6 +1382,60 @@ fn is_code_file(name: &str) -> bool { || lower.ends_with(".sql") } +/// 构造与上游内置编辑菜单相同的项目,但交给应用自己的非抢焦点浮层。 +fn build_editor_context_menu( + mut menu: PopupMenu, + cx: &mut Context, + view: &Entity, +) -> PopupMenu { + let capabilities = view + .read(cx) + .editor_state + .read(cx) + .context_menu_capabilities(); + let enabled = !capabilities.is_disabled(); + let editable = enabled && !capabilities.is_readonly(); + + if capabilities.is_code_editor() { + menu = menu + .menu_with_disabled( + crate::i18n::menu_text(cx, "menu.goToDefinition"), + Box::new(gpui_kit::base::input::GoToDefinition), + !(enabled && capabilities.has_definition()), + ) + .menu_with_disabled( + crate::i18n::menu_text(cx, "menu.showCodeActions"), + Box::new(gpui_kit::base::input::ToggleCodeActions), + !(editable && capabilities.has_code_actions()), + ) + .separator(); + } + + menu = menu + .menu_with_disabled( + crate::i18n::menu_text(cx, "menu.cut"), + Box::new(gpui_kit::base::input::Cut), + !(editable && capabilities.is_copyable()), + ) + .menu_with_disabled( + crate::i18n::menu_text(cx, "menu.copy"), + Box::new(gpui_kit::base::input::Copy), + !capabilities.is_copyable(), + ) + .menu_with_disabled( + crate::i18n::menu_text(cx, "menu.paste"), + Box::new(gpui_kit::base::input::Paste), + !(editable && cx.read_from_clipboard().is_some()), + ) + .separator() + .menu( + crate::i18n::menu_text(cx, "menu.selectAll"), + Box::new(gpui_kit::base::input::SelectAll), + ); + + menu +} + impl Render for EditorView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.sync_active_editor(window, cx); @@ -1339,6 +1475,30 @@ impl Render for EditorView { }; let editor_state = self.editor_state.clone(); + let editor_view = cx.entity().downgrade(); + let editor_menu = self + .editor_context_menu + .as_ref() + .map(|menu| menu.menu.clone()); + let editor_context_menu_overlay = self.editor_context_menu.as_ref().map(|menu| { + gpui_kit::deferred( + gpui_kit::anchored().child( + div() + .w(window.bounds().size.width) + .h(window.bounds().size.height) + .on_scroll_wheel(|_, _, cx| { + cx.stop_propagation(); + }) + .child( + gpui_kit::anchored() + .position(menu.position) + .snap_to_window_with_margin(px(8.0)) + .child(menu.menu.clone()), + ), + ), + ) + .with_priority(gpui_kit::base::POPUP_PRIORITY) + }); v_flex() .size_full() @@ -1558,6 +1718,43 @@ impl Render for EditorView { .flex_1() .w_full() .overflow_hidden() + .when_some(editor_menu.as_ref(), |this, menu| { + let menu_focus = menu.focus_handle(cx); + this.capture_key_down(move |event: &KeyDownEvent, window, cx| { + if event.keystroke.modifiers.shift + || event.keystroke.modifiers.control + || event.keystroke.modifiers.alt + || event.keystroke.modifiers.platform + { + return; + } + match event.keystroke.key.as_str() { + "up" => menu_focus.dispatch_action( + &gpui_kit::base::actions::SelectUp, + window, + cx, + ), + "down" => menu_focus.dispatch_action( + &gpui_kit::base::actions::SelectDown, + window, + cx, + ), + "enter" => menu_focus.dispatch_action( + &gpui_kit::base::actions::Confirm { secondary: false }, + window, + cx, + ), + "escape" => menu_focus.dispatch_action( + &gpui_kit::base::actions::Cancel, + window, + cx, + ), + _ => return, + } + cx.stop_propagation(); + window.prevent_default(); + }) + }) .when_some( active_tab.as_ref().map(|_| editor_state.clone()), |this, state| { @@ -1616,8 +1813,19 @@ impl Render for EditorView { ), ), ) + }) + .on_prepaint(move |_bounds, _window, cx| { + let Some(view) = editor_view.upgrade() else { + return; + }; + view.update(cx, |editor, cx| { + editor.install_editor_context_menu_handler(cx); + }); }), ) + .when_some(editor_context_menu_overlay, |this, overlay| { + this.child(overlay) + }) } } From 46f43847efc65d3100f9ade0fff4a0756f03a79f Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 22:38:43 +0800 Subject: [PATCH 075/109] =?UTF-8?q?fix(linux):=20=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E5=AE=9E=E4=BD=93=E9=87=8D=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- linux/src/workbench/editor.rs | 75 ++++++++++++++++++----------------- 1 file changed, 39 insertions(+), 36 deletions(-) diff --git a/linux/src/workbench/editor.rs b/linux/src/workbench/editor.rs index cd7a33547..9aecab955 100644 --- a/linux/src/workbench/editor.rs +++ b/linux/src/workbench/editor.rs @@ -10,7 +10,7 @@ use gpui_kit::{ div, px, relative, AppContext as _, ClipboardItem, Context, DismissEvent, Entity, EventEmitter, Focusable as _, FontWeight, InteractiveElement as _, IntoElement, KeyDownEvent, ParentElement as _, Pixels, Point, Render, StatefulInteractiveElement as _, Styled as _, - Subscription, Task, Window, + Subscription, Task, WeakEntity, Window, }; use std::collections::HashMap; @@ -668,35 +668,6 @@ impl EditorView { } } - /// 在编辑器完成本帧渲染后接管其右键回调。 - /// - /// `Editor::render` 每帧都会重新安装上游默认的 `NativeMenu` 回调,因此这里 - /// 必须在子元素的 `on_prepaint` 阶段覆盖它;鼠标按下/释放仍由上游编辑器 - /// 处理,因此右键到选区外的光标移动语义保持不变。 - fn install_editor_context_menu_handler(&mut self, cx: &mut Context) { - if self.active_tab_index.is_none() { - return; - } - - let view = cx.entity().downgrade(); - self.editor_state.update(cx, |state, _cx| { - state.on_context_menu(Rc::new( - move |_menu: NativeMenu, - _capabilities: InputContextMenuCapabilities, - position: Point, - window: &mut Window, - cx: &mut gpui_kit::App| { - let Some(view) = view.upgrade() else { - return; - }; - view.update(cx, |editor, cx| { - editor.open_editor_context_menu(position, window, cx); - }); - }, - )); - }); - } - /// 打开编辑区正文右键菜单。 /// /// gpui-kit 的 Linux `NativeMenu` fallback 会聚焦 `PopupMenu`,从而让上游 @@ -1382,6 +1353,38 @@ fn is_code_file(name: &str) -> bool { || lower.ends_with(".sql") } +/// 在编辑器完成本帧布局后接管其右键回调。 +/// +/// 这里只更新子实体 `EditorState`,不在 `EditorView` 自己的 `on_prepaint` +/// 回调中回写父实体,避免 GPUI 的实体重复 lease。鼠标事件仍由上游编辑器 +/// 处理,因此右键到选区外的光标移动语义保持不变。 +fn install_editor_context_menu_handler( + editor_state: &Entity, + view: &WeakEntity, + cx: &mut gpui_kit::App, +) { + let handler_view = view.clone(); + editor_state.update(cx, |state, _cx| { + state.on_context_menu(Rc::new( + move |_menu: NativeMenu, + _capabilities: InputContextMenuCapabilities, + position: Point, + window: &mut Window, + cx: &mut gpui_kit::App| { + let handler_view = handler_view.clone(); + window.defer(cx, move |window, cx| { + let Some(view) = handler_view.upgrade() else { + return; + }; + view.update(cx, |editor, cx| { + editor.open_editor_context_menu(position, window, cx); + }); + }); + }, + )); + }); +} + /// 构造与上游内置编辑菜单相同的项目,但交给应用自己的非抢焦点浮层。 fn build_editor_context_menu( mut menu: PopupMenu, @@ -1475,6 +1478,7 @@ impl Render for EditorView { }; let editor_state = self.editor_state.clone(); + let editor_state_for_handler = self.editor_state.clone(); let editor_view = cx.entity().downgrade(); let editor_menu = self .editor_context_menu @@ -1815,12 +1819,11 @@ impl Render for EditorView { ) }) .on_prepaint(move |_bounds, _window, cx| { - let Some(view) = editor_view.upgrade() else { - return; - }; - view.update(cx, |editor, cx| { - editor.install_editor_context_menu_handler(cx); - }); + install_editor_context_menu_handler( + &editor_state_for_handler, + &editor_view, + cx, + ); }), ) .when_some(editor_context_menu_overlay, |this, overlay| { From edd57d156bbf0d45e3eda5b076caf5b2ce3554d4 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Fri, 25 Sep 2026 22:54:55 +0800 Subject: [PATCH 076/109] =?UTF-8?q?fix(linux):=20=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95=E9=87=8D=E5=A4=8D=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E7=BC=96=E8=BE=91=E5=99=A8=E5=AE=9E=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- linux/src/workbench/editor.rs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/linux/src/workbench/editor.rs b/linux/src/workbench/editor.rs index 9aecab955..67d950278 100644 --- a/linux/src/workbench/editor.rs +++ b/linux/src/workbench/editor.rs @@ -683,15 +683,15 @@ impl EditorView { return; } - let view = cx.entity(); let editor_focus = self .editor_state .read(cx) .presentation() .focus_handle() .clone(); + let capabilities = self.editor_state.read(cx).context_menu_capabilities(); let menu = PopupMenu::build(window, cx, move |menu, _window, cx| { - build_editor_context_menu(menu, cx, &view).action_context(editor_focus) + build_editor_context_menu(menu, cx, capabilities).action_context(editor_focus) }); let subscription = cx.subscribe(&menu, |this, _, _: &DismissEvent, cx| { this.editor_context_menu = None; @@ -1389,13 +1389,8 @@ fn install_editor_context_menu_handler( fn build_editor_context_menu( mut menu: PopupMenu, cx: &mut Context, - view: &Entity, + capabilities: InputContextMenuCapabilities, ) -> PopupMenu { - let capabilities = view - .read(cx) - .editor_state - .read(cx) - .context_menu_capabilities(); let enabled = !capabilities.is_disabled(); let editable = enabled && !capabilities.is_readonly(); From 1262c256629b63fa70b48eb0129885d24a95f077 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Sat, 26 Sep 2026 00:12:55 +0800 Subject: [PATCH 077/109] =?UTF-8?q?style(linux):=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E5=BC=80=E5=85=B3=E6=9B=BF=E6=8D=A2=E4=B8=BA=20gpui-k?= =?UTF-8?q?it=20=E5=AE=98=E6=96=B9=20Switch=20=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - render_toggle 内部实现从手写边框药丸 + 静态圆点改为官方 Switch 滑块, 保留原函数签名,全部调用点(编辑器、Git、日志等)自动生效 - 自动保存开关调用点同步改为直接使用 Switch::new(...).checked(...).on_click(...) - 新增 use gpui_kit::component::switch::Switch 导入 Co-Authored-By: AtomCode (glm5.3-flash) --- linux/src/workbench/settings_dialog.rs | 62 +++++++++----------------- 1 file changed, 21 insertions(+), 41 deletions(-) diff --git a/linux/src/workbench/settings_dialog.rs b/linux/src/workbench/settings_dialog.rs index 6d846c68e..2e5c98ada 100644 --- a/linux/src/workbench/settings_dialog.rs +++ b/linux/src/workbench/settings_dialog.rs @@ -12,6 +12,7 @@ use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::component::input::{Input, InputState, Textarea, TextareaState}; use gpui_kit::component::menu::DropdownMenu as _; use gpui_kit::component::scroll::ScrollableElement as _; +use gpui_kit::component::switch::Switch; use gpui_kit::component::{h_flex, v_flex, Icon, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ @@ -762,7 +763,7 @@ impl SettingsDialog { .child(div().flex_shrink_0().child(control)) } - /// 开关控件:可点击药丸,圆点指示开关状态(对齐 Tauri Switch 无文字)。 + /// 开关控件:官方 Switch 滑块样式,选中填充主题色。 fn render_toggle( &self, id: &'static str, @@ -770,36 +771,9 @@ impl SettingsDialog { cx: &mut Context, toggle: impl Fn(&mut Self, &mut Context) + 'static, ) -> impl IntoElement { - h_flex() - .id(id) - .items_center() - .gap_1p5() - .px_2p5() - .py_1() - .rounded_sm() - .border_1() - .cursor_pointer() - .text_xs() - .when(value, |el| { - el.bg(ThemeColors::primary()) - .border_color(ThemeColors::primary()) - .text_color(ThemeColors::foreground()) - }) - .when(!value, |el| { - el.bg(ThemeColors::background()) - .border_color(ThemeColors::border()) - .text_color(ThemeColors::subtle_foreground()) - .hover(|h| { - h.bg(ThemeColors::accent()) - .text_color(ThemeColors::foreground()) - }) - }) - .child(div().w(px(8.0)).h(px(8.0)).rounded_full().bg(if value { - ThemeColors::foreground() - } else { - ThemeColors::subtle_foreground() - })) - .on_click(cx.listener(move |this, _event, _window, cx| toggle(this, cx))) + Switch::new(id).checked(value).on_click(cx.listener( + move |this, _event, _window, cx| toggle(this, cx), + )) } /// 数值步进器:减号 + 当前值 + 加号。 @@ -1096,16 +1070,22 @@ impl SettingsDialog { ), ), ) - .child(self.render_group( - crate::i18n::menu_text(cx, "settings.mac.files").to_string(), - v_flex().w_full().gap_3().child(self.render_row( - crate::i18n::menu_text(cx, "settings.mac.autoSave").to_string(), - None, - self.render_toggle("general-auto-save", s.auto_save, cx, |this, cx| { - this.commit(cx, |s| s.auto_save = !s.auto_save) - }), - )), - )) + .child( + self.render_group( + crate::i18n::menu_text(cx, "settings.mac.files").to_string(), + v_flex().w_full().gap_3().child( + self.render_row( + crate::i18n::menu_text(cx, "settings.mac.autoSave").to_string(), + None, + Switch::new("general-auto-save") + .checked(s.auto_save) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.commit(cx, |s| s.auto_save = !s.auto_save) + })), + ), + ), + ), + ) .child(self.render_group( crate::i18n::menu_text(cx, "settings.tabs.git").to_string(), v_flex().w_full().gap_3().child(self.render_row( From f0fd0e289a41640a0006272d7a5ab6fb51563ab3 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Sat, 26 Sep 2026 00:27:22 +0800 Subject: [PATCH 078/109] =?UTF-8?q?refactor(linux):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=B7=A6=E4=B8=8B=E7=BB=88=E7=AB=AF=E7=9A=84=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E3=80=81=E5=B0=BA=E5=AF=B8?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E4=B8=8E=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对齐 macOS/Windows 终端契约,把 Linux GPUI 终端从“能跑但不可用”重构成 可用的 IDE 级基础能力。终端语义仍全部来自 alacritty_terminal,PTY 与子进程 生命周期来自 portable-pty,未新增任何引擎或第二套 ANSI 实现。 会话生命周期 - TerminalSession 持有 master/writer/child/reader 线程;旧实现 spawn 后立即 丢弃子进程句柄,既不 wait(留僵尸)也不 kill(子孙进程残留),且无退出码 - 关闭顺序:断 writer(portable-pty 写 \n+EOT)→ 关主端(slave 收到 hangup) → 进程组 TERM、超时升级 KILL → 兜底 ChildKiller::kill → join reader; 每步有界等待,Drop 交给一次性后台线程,UI 线程不阻塞 - 复用 run/process.rs 新导出的 terminate_process_group(pgid, grace), 与 Run/Maven 受管进程共用同一套“先温和后强制、有界等待”契约 - reader 线程 wait() 后回传退出码/信号,视图写进网格并显示在窗格状态区; 生命周期用纯状态机 SessionState(Closed/Running/Stopping/Exited)表达 尺寸与 PTY 环境 - 网格容器 on_children_prepainted 拿当帧实测边界后立即 Term::resize 并同步 PTY 行列数,修掉“滞后一帧才纠正”和“面板折叠期间保持旧尺寸” - 会话环境清理 18 个宿主终端变量(保留 TMUX/STY),设 TERM=xterm-256color、 COLORTERM=truecolor;terminal_scrollback 在建网格时生效 - set_working_dir 真正接到终端:换项目时回收旧 shell 并按新目录重开 搜索(原先完全不可用) - 旧实现搜索框从不聚焦,键入落到 PTY,命中总数恒为 0,搜索起点恒为光标点 - 改用工作台唯一实现 search_input::SearchInput,打开即聚焦;用上游 RegexSearch 从最旧一行顺序收集命中(上限 512、计数带 +),按下标做 上一个/下一个环绕,回车=下一个、Shift+回车=上一个、Esc=关闭 - 搜索栏做成浮层,开关不触发终端 resize 与全屏 TUI 重排 输入与鼠标 - Ctrl+V 不再直发 0x16;Ctrl+Shift+C/V 复制粘贴;Shift+PageUp/Down 滚视口、 Ctrl+End 回到底部;方向键按 APP_CURSOR 选序列;AltGr 与平台键放行, 不再吞掉工作台快捷键 - 粘贴按 BRACKETED_PASTE 成对包裹并归一 CRLF;MOUSE_REPORT_* 开启时按 SGR 1006(回退 X10)上报,vim/less/htop 的鼠标交互可用;键盘输入后 视口自动回到底部 渲染 - 删除硬编码 ANSI_PALETTE,8 个基础色改取 theme.rs 的 terminal_* token; 修掉 named as u8 把 DimRed(259) 折成 Yellow 的取色 bug - 补齐 INVERSE/HIDDEN/DIM/ITALIC/UNDERLINE/STRIKEOUT、宽字符占位格(只占宽 不绘制)、四种 CursorShape;读取 terminal_cursor_blink,条件不满足时 闪烁任务自行退出 工作台接线 - terminal.new 真实重启会话;terminal.close 终止子进程后折叠面板(旧行为 只折叠,shell 在后台继续跑) - 终端窗格头部新增 重启/查找/回到底部/清空/关闭,状态区显示“运行中 · shell”或退出码/信号;无会话时显示空态与“新建终端”按钮 - i18n 新增 13 个终端文案键,全部有使用点 测试与验证 - 新增 39 个确定性单测:坐标换算、容器到行列数、生命周期状态机、按键映射、 鼠标上报编码、搜索序号环绕与跨回滚缓冲、PTY 环境、调色板与光标落位、 引擎联通性(解析/回滚/选择导出/resize/宽字符);无 sleep、无真实 PTY - cargo test -p lithe-linux --lib 124 passed;./scripts/build-linux.sh --debug --locked 通过;cargo check 无 warning;rustfmt、agent notes、 test stability 校验通过 - 未做 GUI 实机验证(本机无显示环境) 未覆盖:多标签、横向分屏、超链接点击、Sixel/Kitty 图形协议;搜索高亮与 用户拖选共用上游 Selection,关闭搜索会清掉选区。会话层不依赖 GPUI,后续 加标签只需在 TerminalView 上并列多个会话。 Co-Authored-By: AtomCode (glm5.3-flash) --- ...-09-25-linux-gpui-terminal-engine-reuse.md | 102 +- linux/src/i18n.rs | 24 + linux/src/workbench/bottom_panel.rs | 163 +- linux/src/workbench/run/process.rs | 44 +- linux/src/workbench/terminal.rs | 3373 +++++++++++++---- linux/src/workbench/view.rs | 35 +- 6 files changed, 2902 insertions(+), 839 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-25-linux-gpui-terminal-engine-reuse.md b/.agents/notes/implemented/architecture/2026-09-25-linux-gpui-terminal-engine-reuse.md index b52b9f419..d2476347b 100644 --- a/.agents/notes/implemented/architecture/2026-09-25-linux-gpui-terminal-engine-reuse.md +++ b/.agents/notes/implemented/architecture/2026-09-25-linux-gpui-terminal-engine-reuse.md @@ -7,21 +7,27 @@ Linux 原生 GPUI 版终端不自己实现终端语义,只复用成熟上游 `alacritty_terminal` 作为“终端引擎”(负责解析 ANSI 转义序列、维护字符网格、回滚、选择、搜索), 再由本仓库把它投影成 GPUI 元素。原因是 GPUI 生态不存在可复用的成熟终端 -控件,唯一成熟且可合法分发的上游是引擎层。开发者要记住:**终端行为(滚动、 -选择、搜索、随窗缩放、reflow)只能调用 `alacritty_terminal` 的既有 API, -禁止在 `terminal.rs` 里手写第二套终端语义。** +控件,唯一成熟且可合法分发的上游是引擎层。开发者要记住两件事:**终端行为 +(滚动、选择、搜索、随窗缩放、reflow)只能调用 `alacritty_terminal` 的既有 +API,禁止在 `terminal.rs` 里手写第二套终端语义**;**PTY 子进程的终止与回收 +复用 `run/process.rs` 的“先温和后强制、有界等待”契约,不另写一套信号逻辑。** ## 问题 Linux 版工作台使用 GPUI Kit 构建,底部面板需要一个嵌入式终端。 Windows 版同类能力由前端 xterm.js 承担,xterm.js 是完整的成熟终端控件 -(自带网格渲染、滚动、选择、搜索)。GPUI 没有 DOM,无法复用 xterm.js。 +(自带网格渲染、滚动、选择、复制、搜索)。GPUI 没有 DOM,无法复用 xterm.js。 -早期实现虽然在依赖里引了 `alacritty_terminal`,但只把它当解析器使用: +第一版实现虽然在依赖里引了 `alacritty_terminal`,但只把它当解析器使用: 渲染固定 120×30、不随窗缩放,且完全没接上游已经提供的回滚滚动、 文本选择、复制、搜索 API。结果是终端“能跑但不好用”,并被迫在文件头 声明“多标签、搜索、复制粘贴、随窗缩放暂不支持”。 +接上滚动与选择之后仍然不能当 IDE 终端用:关闭会话只是丢句柄(拿不到退出码, +子孙进程可能残留)、PTY 环境照抄宿主终端变量、搜索从光标位置起算导致计数与 +上一个/下一个都不可信、自带一份硬编码 ANSI 调色板与主题脱节、 +`terminal.new`/`terminal.close` 只是重建网格和折叠面板。 + ## 决策 ### 复用边界 @@ -38,6 +44,52 @@ Windows 版同类能力由前端 xterm.js 承担,xterm.js 是完整的成熟 鼠标坐标换算、PTY 行列数、网格 `cols/rows` 三者必须使用同一套 `CellMetrics`,否则选择与点击会出现半格偏移。 +### 会话层:终止与回收只有一套契约 + +`TerminalSession` 拥有主端句柄、writer、子进程和 reader 线程。关闭顺序与 +`run/process.rs` 的受管进程一致:先断 writer(`portable-pty` 会写入换行 + +EOT,交互 shell 自行退出),再关主端(slave 立刻收到 hangup),然后 +`terminate_process_group` 先发 TERM、超时升级 KILL,最后兜底 +`ChildKiller::kill` 并 join reader 线程。每一步都是有界等待,UI 线程不参与 +等待:会话句柄的 `Drop` 交给一次性后台线程执行。 + +生命周期用纯状态机 `SessionState`(`Closed`/`Running`/`Stopping`/`Exited`) +表达,退出码与信号由 reader 线程 `wait()` 之后回传,视图把结果写进网格并 +显示在窗格状态区。不要为了“关得更快”只 drop 主端句柄,也不要在 UI 线程上 +阻塞等待子进程。 + +### 尺寸:prepaint 驱动,不靠固定初值 + +网格容器用 `on_children_prepainted` 拿到当帧实测边界后立刻 `Term::resize` +并同步 PTY 行列数,面板折叠/展开与窗口缩放都在当帧生效,不存在“先按上一帧 +尺寸渲染、下一帧才纠正”。首帧还没有实测值时用引导尺寸开会话,同一帧的 +prepaint 就会纠正。自动创建会话只尝试一次:失败或用户显式关闭之后不会每帧 +重开,否则会变成满 CPU 的重连循环。 + +### PTY 环境 + +会话环境 = 宿主环境减去宿主终端遗留变量(`COLUMNS`/`LINES`/`WT_SESSION`/ +`ITERM_SESSION_ID` 等),再加上 `TERM=xterm-256color` 与 +`COLORTERM=truecolor`。`TMUX`/`STY` 故意保留,嵌套会话需要它们。 + +### 搜索 + +搜索栏复用工作台唯一的 `search_input::SearchInput`(不重写输入框),输入变化 +后用上游 `RegexSearch` 从网格最旧一行顺序收集命中,上限 `SEARCH_MATCH_CAP` +(超限时计数带 `+`),再按下标做上一个/下一个环绕。注意上游 `search_next` +在“起点之后没有命中”时会回退返回第一个命中,所以必须自己判断是否已经环绕。 +命中高亮复用上游 `Selection` 并配合 `Term::scroll_to_point`。搜索栏做成浮层, +打开/关闭不会触发终端 resize 与全屏 TUI 重排。 + +### 渲染 + +前景/底色取 `theme.rs` 的 `terminal_*` token:8 个基础色直接用主题,亮色/ +暗色变体与 `Dim*`/`Cursor` 由基础色派生,256 色的立方体与灰阶仍按 xterm 公式 +计算。**不要再写第二份硬编码 ANSI 调色板**;命名色也不要再用 `named as u8` +取下标,那会把 `DimRed`(259) 折回 `Yellow`。单元格按上游 `Flags` 处理 +`INVERSE`/`HIDDEN`/`DIM`/粗斜体/下划线/删除线,宽字符的占位格只占宽度不绘制 +字符;光标形状按上游 `CursorShape` 画成反色块、下划线、竖线或空心块。 + ### 正确做法 - 需要滚动时调用 `term.scroll_display(Scroll::Delta / Bottom)`,不要 @@ -49,6 +101,9 @@ Windows 版同类能力由前端 xterm.js 承担,xterm.js 是完整的成熟 reflow 由上游完成。 - 鼠标命中用 `viewport_to_grid_point(局部坐标, CellMetrics, TermDims, display_offset)` 换算,回滚偏移必须叠加,保证上滚后仍命中用户看到的同一行。 +- 应用要求鼠标事件时(`TermMode::MOUSE_REPORT_CLICK`/`MOUSE_MOTION`),把事件 + 编码成 SGR 1006(回退 X10)发给应用,不要抢走做本地选择;是否上报由上游 + 模式决定,拖动上报只在按住鼠标时发。 ### 不要这样做 @@ -59,6 +114,7 @@ Windows 版同类能力由前端 xterm.js 承担,xterm.js 是完整的成熟 会让两者不一致。 - 不要为了“加个功能”把终端语义下沉到 `bottom_panel.rs` 或 `view.rs`; 宿主只负责切换标签、转发命令和生命周期。 +- 不要为终端另写一套搜索输入框,也不要另存一份 ANSI 调色板。 ## 考虑过的备选方案 @@ -82,30 +138,52 @@ Windows 版同类能力由前端 xterm.js 承担,xterm.js 是完整的成熟 120×30、无回滚、无选择、无搜索、无缩放,属于明确的能力缺口,不是可接受 的长期状态。 +### 备选方案四:终端自建进程组终止逻辑 + +PTY 子进程自带会话与进程组,可以直接在 `terminal.rs` 里发信号。但 +`run/process.rs` 已经把“先温和后强制、有界等待”做成 +`terminate_process_group`,另写一份会出现两套等待时长与两套降级策略, +因此选择复用。 + ## 后果 - 收益:滚动、选择、复制粘贴、搜索、随窗缩放、reflow 全部由上游保证, 本仓库代码量显著下降,缺陷面收敛到“投影与事件接线”。 - 收益:单元格度量、命中换算、PTY 尺寸三处共用同一来源,跨窗口大小与 字体缩放行为一致。 +- 收益:关闭会话拿得到退出码与信号,窗口折叠/展开与项目切换都不会留下 + 孤儿 shell;终端配色跟随主题。 - 代价:渲染仍需自绘(上游无 GPUI 渲染器),固定单元格排版要求等宽 字体,非等宽字体下对齐会退化。 -- 代价:多标签、横向分屏、超链接点击、图形协议(Sixel/Kitty)仍未接入, - 需后续按需扩展;这些属于产品编排,不属于引擎边界。 +- 代价:搜索高亮复用上游 `Selection`,因此搜索高亮与用户拖选的选区共用 + 一份状态,关闭搜索会清掉选区。 +- 未覆盖:多标签、横向分屏、超链接点击、图形协议(Sixel/Kitty)仍未接入。 + 会话层 `TerminalSession` 不依赖 GPUI,后续加标签只需在 `TerminalView` + 上并列多个会话;超链接与图形协议属于引擎/渲染能力缺失,需要单独决策。 ## 验证 - `cargo test --manifest-path linux/Cargo.toml -p lithe-linux --lib` - 覆盖:`viewport_to_grid_point` 的边界/内边距/回滚偏移换算、256 色与 - 加粗高亮解算,以及引擎联通性(解析输出入网格、回滚 `display_offset`、 - 选择导出字符串、`resize` 改变列数)。 + 覆盖纯逻辑与引擎联通性:`viewport_to_grid_point` 的边界/内边距/回滚偏移 + 换算、容器尺寸到网格行列数(含最小值与上限钳制)、会话生命周期状态机、 + 按键映射(控制字符、复制粘贴搜索、翻页滚动、方向键模式、Alt 序列、 + 放行组合)、鼠标上报编码(SGR/X10、拖动与抬起、偏移叠加)、搜索命中 + 序号环绕与计数文案、跨回滚缓冲的搜索、PTY 环境清理、主题调色板与 + 命名色/加粗/反色解算,以及引擎侧的解析、回滚 `display_offset`、选择导出、 + `resize` 与宽字符占位格。 - `cargo build --manifest-path linux/Cargo.toml -p lithe-linux` 通过。 - `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh` 通过。 ## 适用范围 -- `linux/src/workbench/terminal.rs`:Linux 终端的引擎调用、渲染投影与事件接线。 -- `linux/src/i18n.rs`:终端搜索栏等新增文案键。 +- `linux/src/workbench/terminal.rs`:Linux 终端的会话生命周期、引擎调用、 + 渲染投影与事件接线。 +- `linux/src/workbench/run/process.rs`:`terminate_process_group` 同时服务 + Run/Maven 受管进程与集成终端。 +- `linux/src/workbench/bottom_panel.rs`:终端窗格状态区、头部动作 + (重启/查找/回到底部/清空/关闭)与工作目录转发。 +- `linux/src/theme.rs`:`terminal_*` token 是终端配色的唯一来源。 +- `linux/src/i18n.rs`:终端会话、搜索与退出提示文案键。 - 不适用于 Windows 产品;Windows 终端由 `windows/tauri/src/features/terminal/`(xterm.js)与 `windows/tauri/crates/terminal/`(`portable-pty`)承担。 diff --git a/linux/src/i18n.rs b/linux/src/i18n.rs index 3c35f8164..0f1b5f697 100644 --- a/linux/src/i18n.rs +++ b/linux/src/i18n.rs @@ -188,6 +188,30 @@ pub fn menu_text(cx: &App, key: &str) -> &'static str { ), "terminal.cleared" => ("终端已清空。", "Terminal cleared."), "terminal.unavailable" => ("PTY 不可用", "PTY unavailable"), + "terminal.closed" => ( + "没有活动的终端会话。", + "No terminal session is running.", + ), + "terminal.sessionExited" => ("会话已退出", "Session exited"), + "terminal.exitedCode" => ( + "[lithe] 终端进程已退出(退出码 {detail})。", + "[lithe] The terminal process exited with code {detail}.", + ), + "terminal.signalled" => ( + "[lithe] 终端进程被信号 {detail} 终止。", + "[lithe] The terminal process was terminated by signal {detail}.", + ), + "terminal.exitUnknown" => ( + "[lithe] 终端会话已关闭。", + "[lithe] The terminal session was closed.", + ), + "terminal.searchPlaceholder" => ("在终端输出中查找…", "Find in terminal output..."), + "terminal.find" => ("在终端中查找", "Find in Terminal"), + "terminal.searchPrevious" => ("上一个匹配", "Previous match"), + "terminal.searchNext" => ("下一个匹配", "Next match"), + "terminal.searchClose" => ("关闭搜索", "Close search"), + "terminal.restartSession" => ("重启终端会话", "Restart terminal session"), + "terminal.scrollToBottom" => ("回到底部", "Scroll to bottom"), "workbench.settings" => ("设置", "Settings"), "workbench.sourceControl" => ("源代码管理", "Source Control"), "fileExplorer.searchFiles" => ("搜索文件", "Search files"), diff --git a/linux/src/workbench/bottom_panel.rs b/linux/src/workbench/bottom_panel.rs index ec1e8394c..340c5d235 100644 --- a/linux/src/workbench/bottom_panel.rs +++ b/linux/src/workbench/bottom_panel.rs @@ -611,6 +611,9 @@ impl BottomPanelView { } /// 更新工作目录并重探 Run 工程(替代直接写 `working_dir` 字段)。 + /// + /// 终端会话同步跟随:项目切换后旧 shell 的 cwd 与环境已过期,由 + /// `TerminalView::set_working_dir` 安全重启会话。 pub fn set_working_dir(&mut self, dir: String, cx: &mut Context) { let _ = self.processes.request_stop("run", None); self.maven_seq += 1; @@ -618,10 +621,74 @@ impl BottomPanelView { self.maven_running = false; self.pending_run_id = None; self.run_on_ready = false; - self.working_dir = dir; + self.working_dir = dir.clone(); + let _ = self.terminal.update(cx, |term, cx| { + term.set_working_dir(dir, cx); + }); self.reload_run_project(cx); } + /// 新建终端会话:切到终端页并按当前工作目录重启会话。 + pub fn restart_terminal(&mut self, cx: &mut Context) { + self.active_tab = BottomTab::Terminal; + self.is_collapsed = false; + let _ = self.terminal.update(cx, |term, cx| term.restart(cx)); + cx.notify(); + } + + /// 关闭终端会话:真正终止 PTY 子进程并回到空态,再折叠面板。 + /// 只折叠面板会让 shell 继续在后台跑,属于资源泄漏。 + pub fn close_terminal_session(&mut self, cx: &mut Context) { + let _ = self.terminal.update(cx, |term, cx| term.close_session(cx)); + self.active_tab = BottomTab::Terminal; + self.is_collapsed = true; + cx.notify(); + } + + /// 终端窗格状态区:会话在跑时点亮状态与 shell 名,已退出时展示退出码/信号。 + fn terminal_status(&self, cx: &mut Context) -> Option { + let (has_session, running, shell, exit) = { + let terminal = self.terminal.read(cx); + ( + terminal.has_session(), + terminal.is_running(), + terminal.shell().to_string(), + terminal + .session_exit() + .map(|exit| exit.detail()) + .filter(|detail| !detail.is_empty()), + ) + }; + if !has_session { + return None; + } + let text = if running { + let label = crate::i18n::menu_text(cx, "run.running"); + if shell.is_empty() { + label.to_string() + } else { + format!("{label} · {shell}") + } + } else { + let label = crate::i18n::menu_text(cx, "terminal.sessionExited"); + match exit { + Some(detail) => format!("{label} · {detail}"), + None => label.to_string(), + } + }; + Some( + div() + .text_xs() + .text_color(if running { + ThemeColors::accent_green() + } else { + ThemeColors::text_muted() + }) + .child(text) + .into_any_element(), + ) + } + /// 设置 Run 前需要保存的编辑器实体。 pub fn set_run_editor(&mut self, editor: Option>, cx: &mut Context) { self.run_editor = editor; @@ -2001,28 +2068,80 @@ impl Render for BottomPanelView { // 各窗格自带按钮头(对齐 Tauri:底部无标签切换条,切换只走左侧活动栏)。 let (header, body) = match self.active_tab { - BottomTab::Terminal => ( - self.render_pane_header( - IconName::Terminal, - crate::i18n::menu_text(cx, "workbench.terminal").to_string(), - None, - vec![Self::header_button( - "terminal-clear".to_string(), - IconName::Trash, - crate::i18n::menu_text(cx, "ui.clear").to_string(), - false, + BottomTab::Terminal => { + let status = self.terminal_status(cx); + let (at_bottom, has_session) = { + let terminal = self.terminal.read(cx); + (terminal.is_at_bottom(), terminal.has_session()) + }; + ( + self.render_pane_header( + IconName::Terminal, + crate::i18n::menu_text(cx, "workbench.terminal").to_string(), + status, + vec![ + Self::header_button( + "terminal-restart".to_string(), + IconName::RefreshCw, + crate::i18n::menu_text(cx, "terminal.restartSession").to_string(), + false, + cx, + |this, _window, cx| { + let _ = this.terminal.update(cx, |term, cx| term.restart(cx)); + }, + ), + Self::header_button( + "terminal-search".to_string(), + IconName::Search, + crate::i18n::menu_text(cx, "terminal.find").to_string(), + // 搜索栏是会话网格上的浮层,没有会话时无处可显示。 + !has_session, + cx, + |this, window, cx| { + let _ = this + .terminal + .update(cx, |term, cx| term.open_search(window, cx)); + }, + ), + Self::header_button( + "terminal-scroll-bottom".to_string(), + IconName::ArrowDownToLine, + crate::i18n::menu_text(cx, "terminal.scrollToBottom").to_string(), + at_bottom, + cx, + |this, _window, cx| { + let _ = this + .terminal + .update(cx, |term, _cx| term.scroll_to_bottom()); + }, + ), + Self::header_button( + "terminal-clear".to_string(), + IconName::Trash, + crate::i18n::menu_text(cx, "ui.clear").to_string(), + false, + cx, + |this, _window, cx| { + let _ = this.terminal.update(cx, |term, cx| term.clear(cx)); + }, + ), + Self::header_button( + "terminal-close".to_string(), + IconName::X, + crate::i18n::menu_text(cx, "menu.closeTerminal").to_string(), + false, + cx, + |this, _window, cx| this.close_terminal_session(cx), + ), + ], cx, - |this, _window, cx| { - let _ = this.terminal.update(cx, |t, cx| t.clear(cx)); - }, - )], - cx, - ), - div() - .size_full() - .child(self.terminal.clone()) - .into_any_element(), - ), + ), + div() + .size_full() + .child(self.terminal.clone()) + .into_any_element(), + ) + } BottomTab::Run => (self.render_run_header(cx), self.render_run_panel(cx)), BottomTab::Maven => (self.render_maven_header(cx), self.render_maven_panel(cx)), BottomTab::Diagnostics => ( diff --git a/linux/src/workbench/run/process.rs b/linux/src/workbench/run/process.rs index 74f327427..2855572ff 100644 --- a/linux/src/workbench/run/process.rs +++ b/linux/src/workbench/run/process.rs @@ -16,7 +16,7 @@ use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{mpsc, Arc, Condvar, Mutex}; use std::thread::{self, JoinHandle}; -use std::time::Duration; +use std::time::{Duration, Instant}; use serde_json::Value; @@ -555,6 +555,48 @@ fn configure_process_group(command: &mut Command) { } } +/// 终止一个外部拥有的进程组并有界等待它消失,语义与 [`ProcessHandle::stop`] +/// 完全一致:先 `Terminate`,超过 `grace` 仍未退出再 `Kill`。 +/// +/// 集成终端的 PTY 子进程由 `portable-pty` 拥有,其 reader 线程负责最终 +/// `wait()` 回收,因此这里只负责“信号 + 有界等待”,不负责 join。返回 +/// `true` 表示进程组已在期限内消失(`pgid` 不可用时视为已完成)。 +pub fn terminate_process_group(pgid: Option, grace: Duration) -> bool { + let Some(pgid) = pgid.filter(|value| *value > 1) else { + return true; + }; + send_signal(Some(pgid), ProcessSignal::Terminate); + if wait_for_process_group_exit(pgid, grace) { + return true; + } + send_signal(Some(pgid), ProcessSignal::Kill); + wait_for_process_group_exit(pgid, grace) +} + +/// 轮询进程组是否仍存在;Unix 用 `kill(-pgid, 0)` 探测,Windows 的 +/// `taskkill` 调用本身同步等待完成,直接视为已退出。 +fn wait_for_process_group_exit(pgid: i32, timeout: Duration) -> bool { + #[cfg(unix)] + { + let deadline = Instant::now() + timeout; + loop { + // 0 号信号只做存在性探测:整组都退出后返回 ESRCH。 + if unsafe { libc::kill(-pgid, 0) } != 0 { + return true; + } + if Instant::now() >= deadline { + return false; + } + thread::park_timeout(PROCESS_POLL_INTERVAL); + } + } + #[cfg(windows)] + { + let _ = (pgid, timeout); + true + } +} + /// 向由 `pgid` 标识的进程组(Unix)或进程树(Windows)发送终止信号。 /// /// `pgid` 只在子进程存活期间有效;None 或 <= 1(pid 0/1 属于内核或 launchd/ diff --git a/linux/src/workbench/terminal.rs b/linux/src/workbench/terminal.rs index a1fea1c04..cf480a861 100644 --- a/linux/src/workbench/terminal.rs +++ b/linux/src/workbench/terminal.rs @@ -1,16 +1,36 @@ -//! 原生 PTY 终端:`portable-pty` 跑 shell,`alacritty_terminal`(Alacritty -//! 网格引擎)做解析与状态,键盘字符模式直输(对齐 IDEA 内嵌终端的交互:历史、 -//! 补全、方向键、中断均由 PTY 行规程处理)。 +//! Linux 内嵌终端:`portable-pty` 拥有 PTY 与子进程生命周期, +//! `alacritty_terminal` 拥有全部终端语义(ANSI 解析、网格、回滚、选择、搜索、 +//! resize/reflow),本文件只做四件事: //! -//! 渲染层不自行实现终端语义,只做网格到 GPUI 元素的投影:可见区按固定字符单元格 -//! 排版,同样式字符合并为一段。选择、回滚、搜索、随窗缩放全部调用 -//! `alacritty_terminal` 的既有 API(`Term::scroll_display`、`Selection`、 -//! `RegexSearch`、`Term::resize` 与网格 reflow),不重复实现上游能力。 +//! 1. **会话层**([`TerminalSession`]):打开/关闭 PTY,回收子进程与 reader +//! 线程,上报退出码与信号;终止路径复用 `run::process` 的进程组契约。 +//! 2. **事件接线**:键盘与鼠标映射成 PTY 字节,或 IDE 行为(搜索、视口滚动、 +//! 复制粘贴、回到底部)。 +//! 3. **渲染投影**:把上游 `display_iter` 的可见网格按实测单元格宽度排版成 +//! GPUI 元素,语义全部来自上游 `Flags` / `TermMode` / `CursorShape`。 +//! 4. **宿主 API**:新建/关闭会话、切换工作目录、清屏、程序化发送命令。 +//! +//! 必须遵守的边界:终端语义只能来自 `alacritty_terminal`。不要在这里手写 +//! ANSI 解析、换行、光标移动或滚动缓冲;滚动只调 `Term::scroll_display`,复制 +//! 只调 `Term::selection_to_string`,搜索只用 `term::search::RegexSearch` + +//! `Term::search_next`,缩放只调 `Term::resize`(reflow 由上游完成)。 +//! +//! 适配声明:当前是单会话 UI——没有多标签、横向分屏、超链接点击与 Sixel/Kitty +//! 图形协议。会话层不依赖 GPUI,后续加标签只需在 [`TerminalView`] 上并列多个 +//! [`TerminalSession`],不需要改引擎接线。 //! -//! 适配声明:多标签、横向分屏、超链接点击、图形协议(Sixel/Kitty)暂不支持。//! //! Note: 引擎复用边界与禁止手写终端语义的原因见 //! `.agents/notes/implemented/architecture/2026-09-25-linux-gpui-terminal-engine-reuse.md`。 +use std::cell::Cell as StdCell; +use std::collections::BTreeMap; +use std::io::{Read, Write}; +use std::rc::Rc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc, Condvar, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + use alacritty_terminal::event::VoidListener; use alacritty_terminal::grid::{Dimensions, Scroll}; use alacritty_terminal::index::{Column, Direction, Line, Point as GridPoint, Side}; @@ -20,79 +40,108 @@ use alacritty_terminal::term::search::{Match, RegexSearch}; use alacritty_terminal::term::{Config, Term, TermMode}; use alacritty_terminal::vte::ansi::{Color as AlacColor, CursorShape, NamedColor, Processor, Rgb}; use gpui_kit::component::button::{Button, ButtonVariants as _}; -use gpui_kit::component::input::{Input, InputEvent, InputState}; -use gpui_kit::component::{h_flex, v_flex, Sizable as _}; +use gpui_kit::component::input::InputEvent; +use gpui_kit::component::{h_flex, v_flex, Disableable as _, Sizable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - div, px, App, AppContext as _, Bounds, ClipboardItem, Context, FocusHandle, FontWeight, - InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, - MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, Rgba, ScrollDelta, - ScrollWheelEvent, StatefulInteractiveElement as _, Styled as _, Window, + div, px, App, Bounds, ClipboardItem, Context, FocusHandle, FontWeight, InteractiveElement as _, + IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, + ParentElement as _, Pixels, Point, Render, Rgba, ScrollDelta, ScrollWheelEvent, Size, + StatefulInteractiveElement as _, Styled as _, Subscription, Window, }; -use portable_pty::{native_pty_system, CommandBuilder, PtySize}; -use std::cell::Cell; -use std::io::{Read, Write}; -use std::rc::Rc; -use std::sync::{mpsc, Arc, Mutex}; -use std::thread; +use portable_pty::{native_pty_system, Child, ChildKiller, CommandBuilder, MasterPty, PtySize}; use crate::settings; -use crate::theme::ThemeColors; +use crate::theme::{self, ThemeColors}; +use crate::workbench::run::process::terminate_process_group; +use crate::workbench::search_input::SearchInput; + +// --------------------------------------------------------------------------- +// 常量 +// --------------------------------------------------------------------------- -/// 初始网格尺寸(首帧布局完成前使用,随后按容器实测尺寸 resize)。 -const INITIAL_COLS: usize = 80; -const INITIAL_ROWS: usize = 24; +/// 引导网格尺寸:只用于 `Term::new` 与“首次开会话”这一刻。首个 prepaint +/// 拿到实测容器尺寸后立刻 `Term::resize` + PTY resize,不会停留在该值。 +const BOOTSTRAP_COLS: usize = 80; +const BOOTSTRAP_ROWS: usize = 24; -/// 网格最小尺寸,避免退化到 0 列触发上游断言。 +/// 网格最小尺寸,避免退化到 0 列触发上游断言(上游要求至少 2 列放全角字符)。 const MIN_COLS: usize = 2; const MIN_ROWS: usize = 1; -/// 平台默认 shell:Unix 用登录 shell(`$SHELL`),Windows 用 `COMSPEC`。 -/// -/// 两个变量都缺失时给出该平台最可能存在的回退名,避免空 program 导致 PTY 启动失败。 -fn default_shell() -> String { - #[cfg(unix)] - { - std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()) - } - #[cfg(windows)] - { - std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string()) - } -} - -/// 列出在 `dir` 下应该尝试的 shell 可执行文件名。 -/// -/// Windows 上按 `PATHEXT` 补全(`pwsh` -> `pwsh.exe`);名字已带扩展名或非 -/// Windows 平台时只尝试原名。 -fn shell_candidates(dir: &std::path::Path, name: &str) -> Vec { - #[cfg(windows)] - { - let mut candidates = vec![dir.join(name)]; - if std::path::Path::new(name).extension().is_none() { - let pathext = - std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()); - for extension in pathext.split(';').filter(|value| !value.is_empty()) { - candidates.push(dir.join(format!("{name}{extension}"))); - } - } - candidates - } - #[cfg(not(windows))] - { - vec![dir.join(name)] - } -} +/// PTY 行列数上限:超过这个尺寸的“窗口”多半是布局异常,钳住避免无意义 resize。 +const MAX_COLS: usize = 1000; +const MAX_ROWS: usize = 1000; /// 行高相对字号的倍率(等宽终端常用 1.4)。 const LINE_HEIGHT_RATIO: f32 = 1.4; -/// 网格内容内边距(左上角偏移,鼠标命中换算需扣除)。 +/// 网格内容内边距(左侧与上侧偏移,鼠标命中换算需扣除)。 const GRID_PADDING: f32 = 4.0; +/// 关闭会话时先温和终止的等待时长;超时升级为强杀。与 `run::process` 的停止 +/// 契约一致:宁可多等,也不给用户留下孤儿 shell。 +const SESSION_STOP_GRACE: Duration = Duration::from_secs(2); + +/// 强杀后的兜底等待时长;仍不退出即放弃等待(子进程已收到 KILL,由内核回收)。 +const SESSION_KILL_GRACE: Duration = Duration::from_millis(500); + +/// reader 线程单次读取的字节数。 +const READER_CHUNK: usize = 16 * 1024; + +/// 光标闪烁间隔(设置 `terminal_cursor_blink` 打开时使用)。 +const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(530); + +/// 搜索命中的计数上限:回滚缓冲可能有十万行,正则全量扫描必须有界。 +/// 超出后计数带 `+`,导航仍然正确(只截断总数展示)。 +const SEARCH_MATCH_CAP: usize = 512; + +/// 亮色变体向前景色混合的比例(ANSI 8-15 = 基础色 + 前景色)。 +const BRIGHT_MIX: f32 = 0.55; + +/// 暗色变体向背景色混合的比例(`DimXxx` = 基础色 + 背景色)。 +const DIM_MIX: f32 = 0.45; + +/// `Flags::DIM` 的前景向背景混合比例。 +const CELL_DIM_MIX: f32 = 0.35; + +/// 终端 PTY 的 `TERM`:声明 256 色,与 `COLORTERM=truecolor` 配套。 +const PTY_TERM: &str = "xterm-256color"; +const PTY_COLORTERM: &str = "truecolor"; + +/// 宿主终端遗留的环境变量:会让 PTY 里的程序误判“谁是宿主终端”,或读到 +/// 上一份终端的缓存几何。启动会话前一律清掉。 +/// +/// 保留 `TMUX`/`STY` 是有意的:嵌在 tmux/screen 里时透传它,嵌套会话才能 +/// 正确识别外层。 +const HOST_TERMINAL_VARIABLES: &[&str] = &[ + "LINES", + "COLUMNS", + "TERM_PROGRAM", + "TERM_PROGRAM_VERSION", + "TERMINAL_EMULATOR", + "TERMINAL_EMULATOR_VERSION", + "WT_SESSION", + "KITTY_WINDOW_ID", + "KITTY_LISTEN_ON", + "ALACRITTY_WINDOW_ID", + "ALACRITTY_SOCKET", + "ITERM_SESSION_ID", + "VTE_VERSION", + "GNOME_TERMINAL_SCREEN", + "GNOME_TERMINAL_SERVICE", + "KONSOLE_VERSION", + "KONSOLE_DBUS_SESSION", + "TERMINAL_SCREEN", +]; + +// --------------------------------------------------------------------------- +// 网格与几何(纯逻辑) +// --------------------------------------------------------------------------- + /// 网格尺寸(`Term::new` 与 `resize` 共用)。 -#[derive(Debug, Clone, Copy)] -struct TermDims { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TermDims { cols: usize, rows: usize, } @@ -111,72 +160,41 @@ impl Dimensions for TermDims { } } -/// xterm 基础 16 色。 -const ANSI_PALETTE: [(u8, u8, u8); 16] = [ - (0, 0, 0), - (205, 0, 0), - (0, 205, 0), - (205, 205, 0), - (0, 0, 238), - (205, 0, 205), - (0, 205, 205), - (229, 229, 229), - (127, 127, 127), - (255, 0, 0), - (0, 255, 0), - (255, 255, 0), - (92, 92, 255), - (255, 0, 255), - (0, 255, 255), - (255, 255, 255), -]; - -/// 256 色:0-15 基础色,16-231 立方体,232-255 灰阶。 -fn palette_256(index: u8) -> (u8, u8, u8) { - match index { - 0..=15 => ANSI_PALETTE[index as usize], - 16..=231 => { - let n = index - 16; - let levels = [0u8, 95, 135, 175, 215, 255]; - ( - levels[(n / 36) as usize], - levels[((n % 36) / 6) as usize], - levels[(n % 6) as usize], - ) - } - _ => { - let v = 8 + (index - 232) * 10; - (v, v, v) - } - } +/// 单元格像素尺寸。渲染排版、鼠标命中换算与 PTY 行列数三处必须共用它, +/// 否则宽字符与样式分段会出现半格偏移。 +#[derive(Debug, Clone, Copy, PartialEq)] +struct CellMetrics { + width: f32, + height: f32, } -fn rgba_of(rgb: (u8, u8, u8)) -> Rgba { - Rgba { - r: rgb.0 as f32 / 255.0, - g: rgb.1 as f32 / 255.0, - b: rgb.2 as f32 / 255.0, - a: 1.0, +impl CellMetrics { + /// 按当前字号实测等宽字符宽度;行高按倍率换算。 + fn measure(window: &Window, font_size: Pixels) -> Self { + let font = gpui_kit::Font { + family: "monospace".into(), + ..Default::default() + }; + let font_id = window.text_system().resolve_font(&font); + let width = f32::from(window.text_system().em_layout_width(font_id, font_size)).max(1.0); + Self { + width, + height: f32::from(font_size) * LINE_HEIGHT_RATIO, + } } -} -/// 语义颜色解算:`Foreground`/`Background` 取主题,其余取调色板; -/// 加粗配 0-7 自动取高亮 variant(标准终端行为)。 -fn resolve_color(color: AlacColor, bold: bool, default: Rgba) -> Rgba { - match color { - AlacColor::Named(NamedColor::Foreground) => default, - AlacColor::Named(NamedColor::Background) => default, - AlacColor::Named(named) => { - let index = named as u8; - let index = if bold && index < 8 { index + 8 } else { index }; - if index < 16 { - rgba_of(ANSI_PALETTE[index as usize]) - } else { - default - } + /// 容器内容区尺寸 → 网格行列数。容器还没布局完成(宽或高 ≤ 0)时返回 + /// `None`,此时不得 resize 上游网格。 + fn grid_dims(&self, size: Size) -> Option { + let width = f32::from(size.width) - GRID_PADDING * 2.0; + let height = f32::from(size.height) - GRID_PADDING * 2.0; + if width <= 0.0 || height <= 0.0 { + return None; } - AlacColor::Indexed(i) => rgba_of(palette_256(i)), - AlacColor::Spec(Rgb { r, g, b }) => rgba_of((r, g, b)), + Some(TermDims { + cols: ((width / self.width).floor() as usize).clamp(MIN_COLS, MAX_COLS), + rows: ((height / self.height).floor() as usize).clamp(MIN_ROWS, MAX_ROWS), + }) } } @@ -208,216 +226,1327 @@ fn viewport_to_grid_point( Some(GridPoint::new(line, Column(col))) } -/// Linux 原生 PTY 会话 +// --------------------------------------------------------------------------- +// 生命周期状态机(纯逻辑) +// --------------------------------------------------------------------------- + +/// 会话结束方式:正常退出码,或被信号杀死。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TerminalExit { + /// 进程以退出码结束。 + Code(i32), + /// 进程被信号终止(信号名来自 `portable-pty` 的 `ExitStatus`)。 + Signal(String), + /// 会话已请求关闭,但没拿到退出状态。 + Unknown, +} + +impl TerminalExit { + /// 展示用明细:供 i18n 模板的 `{detail}` 替换。 + pub fn detail(&self) -> String { + match self { + Self::Code(code) => code.to_string(), + Self::Signal(signal) => signal.clone(), + Self::Unknown => String::new(), + } + } +} + +/// 终端会话生命周期。会话层与视图层共享同一套状态语义,视图只读它来决定 +/// 空态、状态条与是否允许发送输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SessionState { + /// 还没有 PTY 会话(面板刚建,或用户显式关闭了会话)。 + Closed, + /// 子进程在跑。 + Running, + /// 已发出停止信号,等待子进程退出或被强杀。 + Stopping, + /// 子进程已回收。 + Exited(TerminalExit), +} + +impl SessionState { + /// 是否还持有可用的 PTY(能写字节、能 resize)。 + pub fn is_attached(&self) -> bool { + matches!(self, Self::Running | Self::Stopping) + } +} + +/// 驱动 [`SessionState`] 迁移的事件。 +#[derive(Debug, Clone, PartialEq, Eq)] +enum SessionEventKind { + Spawned, + StopRequested, + Reaped(TerminalExit), +} + +impl SessionState { + /// 纯状态迁移。非法迁移保持原状态,调用方可以无条件应用结果。 + fn transition(self, event: SessionEventKind) -> Self { + match (event, self) { + (SessionEventKind::Spawned, Self::Closed | Self::Exited(_)) => Self::Running, + (SessionEventKind::StopRequested, Self::Running | Self::Stopping) => Self::Stopping, + (SessionEventKind::Reaped(exit), Self::Running | Self::Stopping | Self::Closed) => { + Self::Exited(exit) + } + (_, state) => state, + } + } +} + +/// 生命周期驱动器:把 [`SessionEventKind`] 顺序应用到 [`SessionState`] 上。 +#[derive(Debug, Default, Clone, PartialEq, Eq)] +struct SessionLifecycle { + state: Option, +} + +impl SessionLifecycle { + fn new() -> Self { + Self { state: None } + } + + /// 当前状态;从未创建过会话时为 `Closed`。 + fn state(&self) -> SessionState { + self.state.clone().unwrap_or(SessionState::Closed) + } + + fn apply(&mut self, event: SessionEventKind) -> SessionState { + let next = self.state().transition(event); + self.state = Some(next.clone()); + next + } +} + +// --------------------------------------------------------------------------- +// PTY 环境与 shell 解析(纯逻辑) +// --------------------------------------------------------------------------- + +/// 生成 PTY 子进程的环境:清掉宿主终端遗留变量,并写入终端能力声明。 +/// +/// 纯函数,便于断言“哪些变量被清理、哪些被覆盖”。 +fn pty_environment(base: &BTreeMap) -> BTreeMap { + let mut env: BTreeMap = base + .iter() + .filter(|(key, _)| !HOST_TERMINAL_VARIABLES.contains(&key.as_str())) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + env.insert("TERM".to_string(), PTY_TERM.to_string()); + env.insert("COLORTERM".to_string(), PTY_COLORTERM.to_string()); + env +} + +/// 解析生效 shell:设置 `terminalDefaultShellId` 非空即用(名称经 `PATH` +/// 解析为路径),为空回退平台默认 shell。 +fn resolve_shell(configured: &str) -> String { + let id = configured.trim(); + if id.is_empty() { + return default_shell(); + } + // 已含路径分隔符(`/` 或 `\`)时视为绝对/相对路径,直接使用。 + if id.contains('/') || id.contains('\\') { + return id.to_string(); + } + if let Some(paths) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&paths) { + for candidate in shell_candidates(&dir, id) { + if candidate.is_file() { + return candidate.to_string_lossy().to_string(); + } + } + } + } + // 某些发行版的 shell 不在 PATH 中,退回固定系统目录。 + if cfg!(unix) { + let fallback = std::path::PathBuf::from("/bin").join(id); + if fallback.is_file() { + return fallback.to_string_lossy().to_string(); + } + } + id.to_string() +} + +/// 平台默认 shell:Unix 用登录 shell(`$SHELL`),Windows 用 `COMSPEC`。 +/// +/// 两个变量都缺失时给出该平台最可能存在的回退名,避免空 program 导致 PTY +/// 启动失败。 +fn default_shell() -> String { + if cfg!(unix) { + std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()) + } else { + std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string()) + } +} + +/// 列出在 `dir` 下应该尝试的 shell 可执行文件名。 +/// +/// Windows 上按 `PATHEXT` 补全(`pwsh` -> `pwsh.exe`);名字已带扩展名或非 +/// Windows 平台时只尝试原名。 +fn shell_candidates(dir: &std::path::Path, name: &str) -> Vec { + let mut candidates = vec![dir.join(name)]; + if cfg!(windows) && std::path::Path::new(name).extension().is_none() { + let pathext = + std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()); + for extension in pathext.split(';').filter(|value| !value.is_empty()) { + candidates.push(dir.join(format!("{name}{extension}"))); + } + } + candidates +} + +// --------------------------------------------------------------------------- +// 键盘映射(纯逻辑) +// --------------------------------------------------------------------------- + +/// 终端按键修饰键。用自有结构而不是 GPUI 的 `Modifiers`,让映射函数可以脱离 +/// 窗口单测。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct KeyModifiers { + control: bool, + alt: bool, + shift: bool, + platform: bool, +} + +impl From<&gpui_kit::Modifiers> for KeyModifiers { + fn from(value: &gpui_kit::Modifiers) -> Self { + Self { + control: value.control, + alt: value.alt, + shift: value.shift, + platform: value.platform, + } + } +} + +/// 按键的最终归宿。 +#[derive(Debug, Clone, PartialEq, Eq)] +enum KeyAction { + /// 写入 PTY 的字节。 + Send(Vec), + /// 复制当前选区。 + Copy, + /// 从剪贴板粘贴。 + Paste, + /// 打开终端内搜索。 + OpenSearch, + /// 视口相对滚动(正数向上,即更早的输出)。 + Scroll(isize), + /// 回到输出末尾。 + ScrollToBottom, + /// 不消费,交给工作台全局快捷键。 + Passthrough, +} + +/// 键名 → 终端字节序列。方向键/Home/End 同时给出普通模式与应用光标模式两套, +/// 模式由上游 `TermMode::APP_CURSOR` 决定。 +fn key_bytes(key: &str, term_mode: TermMode) -> Option> { + // 显式标注成切片:带 `~` 的序列长度与方向键不同,数组字面量无法统一。 + let (normal, application): (&[u8], &[u8]) = match key { + "up" => (b"\x1b[A", b"\x1bOA"), + "down" => (b"\x1b[B", b"\x1bOB"), + "right" => (b"\x1b[C", b"\x1bOC"), + "left" => (b"\x1b[D", b"\x1bOD"), + "home" => (b"\x1b[H", b"\x1bOH"), + "end" => (b"\x1b[F", b"\x1bOF"), + "insert" => (b"\x1b[2~", b"\x1b[2~"), + "delete" => (b"\x1b[3~", b"\x1b[3~"), + "pageup" => (b"\x1b[5~", b"\x1b[5~"), + "pagedown" => (b"\x1b[6~", b"\x1b[6~"), + "f1" => (b"\x1bOP", b"\x1bOP"), + "f2" => (b"\x1bOQ", b"\x1bOQ"), + "f3" => (b"\x1bOR", b"\x1bOR"), + "f4" => (b"\x1bOS", b"\x1bOS"), + "f5" => (b"\x1b[15~", b"\x1b[15~"), + "f6" => (b"\x1b[17~", b"\x1b[17~"), + "f7" => (b"\x1b[18~", b"\x1b[18~"), + "f8" => (b"\x1b[19~", b"\x1b[19~"), + "f9" => (b"\x1b[20~", b"\x1b[20~"), + "f10" => (b"\x1b[21~", b"\x1b[21~"), + "f11" => (b"\x1b[23~", b"\x1b[23~"), + "f12" => (b"\x1b[24~", b"\x1b[24~"), + _ => return None, + }; + Some( + if term_mode.contains(TermMode::APP_CURSOR) { + application + } else { + normal + } + .to_vec(), + ) +} + +/// Ctrl + 字母/符号 → 控制字符(Ctrl+C 中断、Ctrl+D EOF 等)。 +fn control_byte(key: &str) -> Option> { + let lower = key.to_lowercase(); + let mut chars = lower.chars(); + match (chars.next(), chars.next()) { + (Some(c @ 'a'..='z'), None) => Some(vec![(c as u8) - b'a' + 1]), + (Some(' '), None) => Some(vec![0]), + (Some('['), None) => Some(vec![0x1b]), + (Some('\\'), None) => Some(vec![0x1c]), + (Some(']'), None) => Some(vec![0x1d]), + (Some('^'), None) => Some(vec![0x1e]), + (Some('_'), None) => Some(vec![0x1f]), + _ => None, + } +} + +/// 单个按键的映射结果。`visible_rows` 用于把翻页动作换算成行数。 +fn map_key( + key: &str, + key_char: Option<&str>, + mods: KeyModifiers, + term_mode: TermMode, + visible_rows: usize, +) -> KeyAction { + // ---- IDE 语义:复制/粘贴/搜索/视口滚动(放在控制字符之前,避免被吞)---- + // 对齐 Windows 终端的 `getTerminalKeyAction`:非 macOS 平台 + // Ctrl+Shift+C/V 为复制粘贴,Ctrl+V 也直接粘贴(否则 shell 只会显示 ^V)。 + if mods.control && !mods.alt && !mods.platform { + let lower = key.to_lowercase(); + if mods.shift { + match lower.as_str() { + "c" => return KeyAction::Copy, + "v" => return KeyAction::Paste, + "f" => return KeyAction::OpenSearch, + _ => {} + } + } else { + match lower.as_str() { + "v" => return KeyAction::Paste, + "f" => return KeyAction::OpenSearch, + "end" => return KeyAction::ScrollToBottom, + _ => {} + } + } + } + // Shift+翻页:滚视口,不把按键交给 PTY(xterm/IDEA 的共同行为)。 + if mods.shift && !mods.control && !mods.alt && !mods.platform { + let page = visible_rows.max(1) as isize; + match key { + "pageup" => return KeyAction::Scroll(page), + "pagedown" => return KeyAction::Scroll(-page), + _ => {} + } + } + + // ---- 直输与控制字符 ---- + if mods.control && !mods.alt && !mods.platform { + return match control_byte(key) { + Some(bytes) => KeyAction::Send(bytes), + // Ctrl+其它组合键归工作台(如 Ctrl+W 关标签)。 + None => KeyAction::Passthrough, + }; + } + if mods.alt && !mods.control && !mods.platform { + // Alt+字符 → ESC 前缀(readline Meta 键);Alt+方向键 → 词移动。 + return match key { + "left" => KeyAction::Send(b"\x1b[b".to_vec()), + "right" => KeyAction::Send(b"\x1b[f".to_vec()), + "backspace" => KeyAction::Send(b"\x1b\x7f".to_vec()), + _ => match key_char { + Some(text) if text.chars().count() == 1 => { + let mut out = vec![0x1b]; + out.extend_from_slice(text.as_bytes()); + KeyAction::Send(out) + } + _ => KeyAction::Passthrough, + }, + }; + } + // 平台键(Super/Win)与 AltGr(Ctrl+Alt)都不是终端输入,放行给上层。 + if mods.platform || (mods.control && mods.alt) { + return KeyAction::Passthrough; + } + + match key { + "enter" => KeyAction::Send(b"\r".to_vec()), + "backspace" => KeyAction::Send(vec![0x7f]), + "tab" => KeyAction::Send(b"\t".to_vec()), + "escape" => KeyAction::Send(vec![0x1b]), + _ => { + if let Some(bytes) = key_bytes(key, term_mode) { + return KeyAction::Send(bytes); + } + match key_char { + // 可打印字符(含中文)原样直输。 + Some(text) if !text.is_empty() => KeyAction::Send(text.as_bytes().to_vec()), + _ => KeyAction::Passthrough, + } + } + } +} + +// --------------------------------------------------------------------------- +// 鼠标上报(纯逻辑) +// --------------------------------------------------------------------------- + +/// 需要上报给终端应用的鼠标动作。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MouseReport { + /// 按下;`button` 为 0 左 / 1 中 / 2 右。 + Press(u8), + /// 抬起。 + Release(u8), + /// 按住拖动。 + Drag(u8), +} + +/// 上游 `TermMode` 是否要求把该动作上报给应用(否则走本地文本选择)。 +fn mouse_report_enabled(action: MouseReport, mode: TermMode) -> bool { + match action { + MouseReport::Drag(_) => mode.contains(TermMode::MOUSE_MOTION), + _ => mode.contains(TermMode::MOUSE_REPORT_CLICK), + } +} + +/// 滚轮手势取值:Shift+滚轮在 Linux 上是横向手势,终端按行处理时也把它 +/// 当成纵向滚动。 +fn wheel_axis(shift: bool, x: f64, y: f64) -> f64 { + if shift && x != 0.0 { + x + } else { + y + } +} + +/// 编码鼠标上报字节。 +/// +/// 优先 SGR 1006(`CSI < b ; col ; row M/m`),回退 X10(`CSI M b+32 …`)。 +/// 行列按终端协议从 1 开始,并叠加回滚偏移(终端报告的是视口坐标)。 +fn encode_mouse_report( + action: MouseReport, + point: GridPoint, + display_offset: usize, + sgr: bool, +) -> Vec { + let (button, final_byte) = match action { + MouseReport::Press(button) => (button, b'M'), + MouseReport::Release(button) => (button + 3, b'm'), + MouseReport::Drag(button) => (button + 32, b'M'), + }; + let column = point.column.0 + 1; + let row = (point.line.0 + display_offset as i32 + 1).max(1) as usize; + if sgr { + format!("\x1b[<{button};{column};{row}{}", final_byte as char).into_bytes() + } else { + let mut out = vec![0x1b, b'[', b'M', 32 + button]; + out.push((32 + column.min(223)) as u8); + out.push((32 + row.min(223)) as u8); + out + } +} + +// --------------------------------------------------------------------------- +// 搜索(纯逻辑 + 上游正则) +// --------------------------------------------------------------------------- + +/// 命中序号(1 起)按方向推进并环绕;无命中返回 0。 +fn next_match_index(current: usize, total: usize, forward: bool) -> usize { + if total == 0 { + return 0; + } + if current == 0 { + return 1; + } + let current = current.min(total); + if forward { + current % total + 1 + } else { + (current + total - 2) % total + 1 + } +} + +/// 搜索计数展示文案:无命中为 `0/0`;超出上限带 `+`。 +fn match_label(current: usize, total: usize, truncated: bool) -> String { + if total == 0 { + return "0/0".to_string(); + } + let suffix = if truncated { "+" } else { "" }; + format!("{}/{total}{suffix}", current.min(total)) +} + +/// 一次命中的下一搜索起点:命中结束后一列;行尾则换到下一行首列。 +fn next_search_origin(term: &Term, found: &Match) -> Option { + let end = *found.end(); + if end.column < term.last_column() { + return Some(GridPoint::new(end.line, Column(end.column.0 + 1))); + } + if end.line.0 + 1 < term.total_lines() as i32 { + return Some(GridPoint::new(Line(end.line.0 + 1), Column(0))); + } + None +} + +/// 从网格最旧一行开始收集全部命中,直到没有更多或达到 [`SEARCH_MATCH_CAP`]。 +/// +/// 顺序收集而不是“每次从光标重新搜”,这样“下一个/上一个”的计数、环绕和 +/// 高亮落点都与 xterm 的 SearchAddon 一致。 +/// +/// 上游 `search_next` 在“起点之后没有命中”时会回退返回第一个命中,因此必须 +/// 自己判断是否已经环绕:命中起点不再前进即视为收集结束。 +fn collect_matches(term: &Term, regex: &mut RegexSearch, cap: usize) -> (Vec, bool) { + let mut matches: Vec = Vec::new(); + let mut origin = GridPoint::new(term.topmost_line(), Column(0)); + while matches.len() < cap { + let Some(found) = term.search_next(regex, origin, Direction::Right, Side::Left, None) + else { + break; + }; + if matches + .last() + .is_some_and(|last| *found.start() <= *last.start()) + { + // 已经环绕回前面的命中,收集结束。 + break; + } + let next = next_search_origin(term, &found); + matches.push(found); + match next { + Some(next) => origin = next, + // 命中在缓冲末尾,没有下一处起点。 + None => break, + } + } + let truncated = matches.len() >= cap; + (matches, truncated) +} + +/// 终端内搜索状态:查询串、上游正则缓存、有界的命中列表与当前序号。 +struct SearchState { + query: String, + regex: Option, + matches: Vec, + /// 当前命中序号(1 起,0 表示无命中)。 + current: usize, + /// 命中数是否被 [`SEARCH_MATCH_CAP`] 截断。 + truncated: bool, +} + +impl SearchState { + fn new() -> Self { + Self { + query: String::new(), + regex: None, + matches: Vec::new(), + current: 0, + truncated: false, + } + } + + fn is_empty(&self) -> bool { + self.query.is_empty() + } + + fn total(&self) -> usize { + self.matches.len() + } + + /// 当前命中的高亮范围;无命中返回 `None`。 + fn current_match(&self) -> Option { + if self.current == 0 { + return None; + } + self.matches.get(self.current - 1).cloned() + } + + /// 计数文案,供搜索栏展示。 + fn label(&self) -> String { + if self.is_empty() { + return String::new(); + } + match_label(self.current, self.total(), self.truncated) + } + + /// 换查询串:重建上游正则并重新收集命中,序号复位到首个命中。 + fn set_query(&mut self, term: &Term, query: String) { + self.query = query; + self.matches.clear(); + self.current = 0; + self.truncated = false; + if self.query.is_empty() { + self.regex = None; + return; + } + match RegexSearch::new(&self.query) { + Ok(regex) => self.regex = Some(regex), + Err(error) => { + // 非法正则:当作无命中,并且不保留上一次的查询串。 + tracing::warn!("terminal search pattern rejected: {error}"); + self.query.clear(); + self.regex = None; + return; + } + } + self.jump_to_first(term); + } + + /// 跳到首个命中(无命中时保持 `current = 0`)。 + fn jump_to_first(&mut self, term: &Term) { + let Some(regex) = self.regex.as_mut() else { + return; + }; + let (matches, truncated) = collect_matches(term, regex, SEARCH_MATCH_CAP); + self.matches = matches; + self.truncated = truncated; + self.current = usize::from(!self.matches.is_empty()); + } + + /// 上一个/下一个命中(环绕)。返回应高亮的范围。 + fn step(&mut self, forward: bool) -> Option { + if self.matches.is_empty() { + self.current = 0; + return None; + } + self.current = next_match_index(self.current, self.matches.len(), forward); + self.current_match() + } + + fn clear(&mut self) { + self.query.clear(); + self.regex = None; + self.matches.clear(); + self.current = 0; + self.truncated = false; + } +} + +// --------------------------------------------------------------------------- +// 渲染调色板(纯逻辑) +// --------------------------------------------------------------------------- + +fn rgba_of(rgb: (u8, u8, u8)) -> Rgba { + Rgba { + r: rgb.0 as f32 / 255.0, + g: rgb.1 as f32 / 255.0, + b: rgb.2 as f32 / 255.0, + a: 1.0, + } +} + +/// 终端渲染调色板:8 个基础色来自主题 `terminal_*` token,亮/暗变体与 +/// 256 色由基础色派生。仓库里不再保留第二份硬编码 ANSI 调色板。 +struct TermPalette { + /// black/red/green/yellow/blue/magenta/cyan/white。 + base: [Rgba; 8], + foreground: Rgba, + background: Rgba, + cursor: Rgba, + selection: Rgba, + dimmed_foreground: Rgba, +} + +impl TermPalette { + fn from_theme() -> Self { + let palette = theme::palette(); + Self { + base: [ + palette.terminal_black, + palette.terminal_red, + palette.terminal_green, + palette.terminal_yellow, + palette.terminal_blue, + palette.terminal_magenta, + palette.terminal_cyan, + palette.terminal_white, + ], + foreground: palette.foreground, + background: palette.background, + cursor: palette.foreground, + selection: palette.selection, + dimmed_foreground: palette.muted_foreground, + } + } + + /// 亮色变体(ANSI 8-15):基础色向前景色混合。 + fn bright(&self, index: usize) -> Rgba { + theme::mix(self.base[index], self.foreground, BRIGHT_MIX) + } + + /// 暗色变体(`DimXxx`):基础色向背景色混合。 + fn dim(&self, index: usize) -> Rgba { + theme::mix(self.base[index], self.background, DIM_MIX) + } + + /// 0-15 走主题 token,16-231 走立方体,232-255 走灰阶。 + fn indexed(&self, index: u8) -> Rgba { + match index { + 0..=7 => self.base[index as usize], + 8..=15 => self.bright(index as usize - 8), + 16..=231 => { + let n = index - 16; + let levels = [0u8, 95, 135, 175, 215, 255]; + rgba_of(( + levels[(n / 36) as usize], + levels[((n % 36) / 6) as usize], + levels[(n % 6) as usize], + )) + } + _ => { + let value = 8 + (index - 232) * 10; + rgba_of((value, value, value)) + } + } + } + + /// 命名色 → 主题色。`Foreground`/`Background`/`Cursor` 与 `Dim*` 变体 + /// 都在这里显式落地;不要用 `named as u8` 取值,那会把 256+ 的变体折回 + /// 基础色(历史上 `DimRed` 会被当成 `Yellow`)。 + fn named(&self, named: NamedColor) -> Rgba { + match named { + NamedColor::Foreground | NamedColor::BrightForeground => self.foreground, + NamedColor::DimForeground => self.dimmed_foreground, + NamedColor::Background => self.background, + NamedColor::Cursor => self.cursor, + NamedColor::Black => self.base[0], + NamedColor::Red => self.base[1], + NamedColor::Green => self.base[2], + NamedColor::Yellow => self.base[3], + NamedColor::Blue => self.base[4], + NamedColor::Magenta => self.base[5], + NamedColor::Cyan => self.base[6], + NamedColor::White => self.base[7], + NamedColor::BrightBlack => self.bright(0), + NamedColor::BrightRed => self.bright(1), + NamedColor::BrightGreen => self.bright(2), + NamedColor::BrightYellow => self.bright(3), + NamedColor::BrightBlue => self.bright(4), + NamedColor::BrightMagenta => self.bright(5), + NamedColor::BrightCyan => self.bright(6), + NamedColor::BrightWhite => self.bright(7), + NamedColor::DimBlack => self.dim(0), + NamedColor::DimRed => self.dim(1), + NamedColor::DimGreen => self.dim(2), + NamedColor::DimYellow => self.dim(3), + NamedColor::DimBlue => self.dim(4), + NamedColor::DimMagenta => self.dim(5), + NamedColor::DimCyan => self.dim(6), + NamedColor::DimWhite => self.dim(7), + } + } + + /// 语义颜色解算:加粗时 0-7 基础色自动取高亮 variant(标准终端行为)。 + fn resolve(&self, color: AlacColor, bold: bool) -> Rgba { + match color { + AlacColor::Named(named) => match named_base_index(named) { + Some(index) if bold => self.bright(index), + _ => self.named(named), + }, + AlacColor::Indexed(index) => { + self.indexed(if bold && index < 8 { index + 8 } else { index }) + } + AlacColor::Spec(Rgb { r, g, b }) => rgba_of((r, g, b)), + } + } +} + +/// 命名色是否是 0-7 的基础色(加粗高亮只对这 8 个色生效)。 +fn named_base_index(named: NamedColor) -> Option { + match named { + NamedColor::Black => Some(0), + NamedColor::Red => Some(1), + NamedColor::Green => Some(2), + NamedColor::Yellow => Some(3), + NamedColor::Blue => Some(4), + NamedColor::Magenta => Some(5), + NamedColor::Cyan => Some(6), + NamedColor::White => Some(7), + _ => None, + } +} + +/// 单个单元格解析出的前景/底色(已处理 `INVERSE` / `HIDDEN` / `DIM`)。 +fn resolve_cell_colors( + foreground: AlacColor, + background: AlacColor, + flags: Flags, + palette: &TermPalette, +) -> (Rgba, Rgba) { + let mut fg = palette.resolve(foreground, flags.contains(Flags::BOLD)); + let bg = palette.resolve(background, false); + if flags.contains(Flags::DIM) { + fg = theme::mix(fg, bg, CELL_DIM_MIX); + } + if flags.contains(Flags::INVERSE) { + return (bg, fg); + } + if flags.contains(Flags::HIDDEN) { + return (bg, bg); + } + (fg, bg) +} + +/// 光标呈现方式。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CursorStyle { + /// 反色块。 + Block, + /// 下划线。 + Underline, + /// 竖线。 + Beam, + /// 空心块。 + Hollow, + /// 隐藏。 + Hidden, +} + +impl From for CursorStyle { + fn from(shape: CursorShape) -> Self { + match shape { + CursorShape::Block => Self::Block, + CursorShape::Underline => Self::Underline, + CursorShape::Beam => Self::Beam, + CursorShape::HollowBlock => Self::Hollow, + CursorShape::Hidden => Self::Hidden, + } + } +} + +// --------------------------------------------------------------------------- +// 会话层 +// --------------------------------------------------------------------------- + +/// 会话事件:PTY 输出字节,或子进程被回收的结果。 +#[derive(Debug)] +pub enum SessionEvent { + Output(Vec), + /// 子进程已回收(正常退出、被信号杀死,或读取失败后的兜底状态)。 + Closed(TerminalExit), +} + +/// 会话内部状态。会话句柄与升级/回收线程共享它,因此全部可跨线程。 +struct SessionInner { + writer: Mutex>>, + master: Mutex>>, + /// 只用于停机兜底的信号句柄:与 `child` 分离,避免和阻塞在 `wait` 的 + /// reader 线程抢同一把锁。 + killer: Mutex>>, + child: Mutex>>, + /// 前台进程组 id(`portable-pty` 让子进程 `setsid`,因此它就是组长)。 + pgid: Option, + /// 主端 reader 线程句柄:停止流程里由看门狗线程负责 join。 + reader: Mutex>>, + stop_requested: AtomicBool, + lifecycle: Mutex, + finished: Mutex, + finished_signal: Condvar, +} + +impl SessionInner { + fn mark_finished(&self) { + if let Ok(mut finished) = self.finished.lock() { + *finished = true; + } + self.finished_signal.notify_all(); + } + + /// 有界等待子进程被回收;返回是否在期限内完成。 + fn wait_finished(&self, timeout: Duration) -> bool { + let Ok(finished) = self.finished.lock() else { + return true; + }; + let (finished, result) = self + .finished_signal + .wait_timeout_while(finished, timeout, |finished| !*finished) + .unwrap(); + *finished || result.timed_out() + } + + /// 关闭 writer:Unix 下 portable-pty 会向 shell 写入换行 + EOT, + /// 交互 shell 因此能自行退出。 + fn release_writer(&self) { + if let Ok(mut guard) = self.writer.lock() { + guard.take(); + } + } + + /// 关闭主端:最后一个主端 fd 关闭后,slave 侧立刻收到 hangup, + /// 阻塞在 `read` 的 reader 线程也会返回。 + fn release_master(&self) { + if let Ok(mut guard) = self.master.lock() { + guard.take(); + } + } +} + +/// Linux 原生 PTY 会话:拥有主端句柄、writer、子进程与 reader 线程。 +/// +/// 生命周期契约与 `run::process` 的受管进程一致:先温和终止、超过 +/// [`SESSION_STOP_GRACE`] 升级为强杀,最后 join reader 线程。只 `drop` 主端 +/// 句柄是不够的——那样拿不到退出码,也可能在子孙进程上留下残留。 pub struct TerminalSession { - writer: Arc>>, - /// 主端句柄:必须活着,丢弃即关闭 PTY(shell 收 SIGHUP 退出)。 - #[allow(dead_code)] - master: Arc>>, + inner: Arc, + /// 展示用 shell 路径。 + shell: String, } impl TerminalSession { - /// 打开 PTY 会话并起输出线程:线程只透传原始字节块,主线程泵喂给 - /// 网格解析器(输出实时出现,对齐 xterm 的流式展示)。 - /// 返回会话、输出 channel 与展示用 shell 名。 + /// 打开 PTY 会话并起输出线程。返回会话与事件接收端。 + /// + /// 线程只透传原始字节块;子进程回收也在同一线程完成,因此“输出停止” + /// 与“进程已退出”必然同时被观察到,不会出现 PTY 已死但子进程未 wait + /// 的窗口。 pub fn new( - cols: u16, - rows: u16, + dims: TermDims, working_dir: &str, shell: &str, - ) -> anyhow::Result<(Self, mpsc::Receiver>, String)> { + ) -> anyhow::Result<(Self, mpsc::Receiver)> { let pty_system = native_pty_system(); - let pair = pty_system.openpty(PtySize { - rows, - cols, - pixel_width: 0, - pixel_height: 0, - })?; - - let shell = if shell.trim().is_empty() { - // Unix 从 `$SHELL` 取用户登录 shell;Windows 没有该变量,回退到 - // `COMSPEC`(cmd.exe),这与系统终端的默认行为一致。 - default_shell() - } else { - shell.to_string() - }; - let mut cmd = CommandBuilder::new(&shell); - cmd.cwd(working_dir); + let pair = pty_system.openpty(pty_size(dims))?; - let _child = pair.slave.spawn_command(cmd)?; + let mut cmd = CommandBuilder::new(shell); + if !working_dir.trim().is_empty() { + cmd.cwd(working_dir); + } + for (key, value) in pty_environment(&std::env::vars().collect()) { + cmd.env(key, value); + } + + let child = pair.slave.spawn_command(cmd)?; + let killer = child.clone_killer(); + let pgid = process_group_id(&pair.master, &child); let mut reader = pair.master.try_clone_reader()?; let writer = pair.master.take_writer()?; - let displayed_shell = shell.clone(); - - let (tx, rx) = mpsc::channel::>(); - thread::spawn(move || { - let mut buf = [0u8; 4096]; - loop { - match reader.read(&mut buf) { - Ok(0) | Err(_) => break, - Ok(n) => { - if tx.send(buf[..n].to_vec()).is_err() { - break; - } - } - } - } + + let (tx, rx) = mpsc::channel::(); + let inner = Arc::new(SessionInner { + writer: Mutex::new(Some(writer)), + master: Mutex::new(Some(pair.master)), + killer: Mutex::new(Some(killer)), + child: Mutex::new(Some(child)), + pgid, + reader: Mutex::new(None), + stop_requested: AtomicBool::new(false), + lifecycle: Mutex::new(SessionLifecycle::new()), + finished: Mutex::new(false), + finished_signal: Condvar::new(), }); + if let Ok(mut lifecycle) = inner.lifecycle.lock() { + lifecycle.apply(SessionEventKind::Spawned); + } + + let worker = { + let inner = inner.clone(); + thread::Builder::new() + .name("lithe-terminal-pty".to_string()) + .spawn(move || { + pump_output(&inner, &mut reader, &tx); + let exit = reap_child(&inner); + if let Ok(mut lifecycle) = inner.lifecycle.lock() { + lifecycle.apply(SessionEventKind::Reaped(exit.clone())); + } + inner.mark_finished(); + let _ = tx.send(SessionEvent::Closed(exit)); + }) + .map_err(|error| anyhow::anyhow!("Could not start PTY reader: {error}"))? + }; + if let Ok(mut slot) = inner.reader.lock() { + *slot = Some(worker); + } Ok(( Self { - writer: Arc::new(Mutex::new(writer)), - master: Arc::new(Mutex::new(pair.master)), + inner, + shell: shell.to_string(), }, rx, - displayed_shell, )) } - pub fn write_input(&self, input: &str) -> anyhow::Result<()> { - self.write_bytes(input.as_bytes()) + /// 当前生命周期状态。 + pub fn state(&self) -> SessionState { + self.inner + .lifecycle + .lock() + .map(|lifecycle| lifecycle.state()) + .unwrap_or(SessionState::Closed) + } + + /// 是否还能写字节/缩放。 + pub fn is_attached(&self) -> bool { + self.state().is_attached() } + /// 展示用 shell 路径。 + pub fn shell(&self) -> &str { + &self.shell + } + + /// 写入终端输入(键盘直输、粘贴、鼠标上报共用这一条路径)。 pub fn write_bytes(&self, bytes: &[u8]) -> anyhow::Result<()> { - let mut writer = self.writer.lock().unwrap(); + if bytes.is_empty() { + return Ok(()); + } + if !self.is_attached() { + return Err(anyhow::anyhow!("Terminal session is not running")); + } + let mut guard = self + .inner + .writer + .lock() + .map_err(|_| anyhow::anyhow!("Terminal writer is unavailable"))?; + let writer = guard + .as_mut() + .ok_or_else(|| anyhow::anyhow!("Terminal writer is closed"))?; writer.write_all(bytes)?; writer.flush()?; Ok(()) } /// PTY 缩放:容器尺寸变化时调用,使 shell / 全屏 TUI 拿到最新行列数。 - pub fn resize(&self, cols: u16, rows: u16) { - if let Ok(master) = self.master.lock() { - let _ = master.resize(PtySize { - rows, - cols, - pixel_width: 0, - pixel_height: 0, - }); + pub fn resize(&self, dims: TermDims) { + if !self.is_attached() { + return; + } + if let Ok(guard) = self.inner.master.lock() { + if let Some(master) = guard.as_ref() { + let _ = master.resize(pty_size(dims)); + } + } + } + + /// 请求关闭会话:立即返回,退出与回收由看门狗线程完成。 + pub fn request_stop(&self) { + if self.inner.stop_requested.swap(true, Ordering::AcqRel) { + return; + } + if let Ok(mut lifecycle) = self.inner.lifecycle.lock() { + lifecycle.apply(SessionEventKind::StopRequested); + } + // 先断 writer:交互 shell 读到 EOT 自行退出,比等信号更快。 + self.inner.release_writer(); + let pgid = self.inner.pgid; + let inner = self.inner.clone(); + thread::Builder::new() + .name("lithe-terminal-stop".to_string()) + .spawn(move || { + stop_session(&inner, pgid); + if let Ok(mut slot) = inner.reader.lock() { + if let Some(worker) = slot.take() { + let _ = worker.join(); + } + } + }) + .ok(); + } + + /// 关闭会话并有界等待回收(Drop 与显式关闭共用)。 + fn stop_and_reap(&self) { + self.request_stop(); + if self + .inner + .wait_finished(SESSION_STOP_GRACE + SESSION_KILL_GRACE) + { + // 看门狗线程也会尝试 join;`take()` 保证只有一个赢家。 + if let Ok(mut slot) = self.inner.reader.lock() { + if let Some(worker) = slot.take() { + let _ = worker.join(); + } + } } } } -/// 单元格尺寸(像素),渲染与鼠标命中都用它换算行列。 -#[derive(Debug, Clone, Copy)] -struct CellMetrics { - width: f32, - height: f32, +impl Drop for TerminalSession { + fn drop(&mut self) { + self.stop_and_reap(); + } } -/// 终端内搜索状态:查询串、上游正则缓存与命中计数。 -struct SearchState { - query: String, - regex: Option, - /// 当前命中序号(1 起,0 表示无命中)。 - current: usize, - /// 命中总数;上游按需搜索,不预先全量统计,未统计时为 0。 - total: usize, +/// 看门狗线程的停止流程:温和终止 → 强杀 → 兜底直杀子进程。 +/// +/// 三步都用有界等待,任何一步都不阻塞调用方线程。 +fn stop_session(inner: &Arc, pgid: Option) { + // 主端留着就无法把 hangup 送达 slave;停机阶段不再需要 resize。 + inner.release_master(); + if terminate_process_group(pgid, SESSION_STOP_GRACE) { + return; + } + if terminate_process_group(pgid, SESSION_KILL_GRACE) { + return; + } + // 进程组不可用或整组仍在退出:直接对子进程发信号。 + if let Ok(mut guard) = inner.killer.lock() { + if let Some(killer) = guard.as_mut() { + let _ = killer.kill(); + } + } + if inner.wait_finished(SESSION_KILL_GRACE) { + return; + } + // 进程已收到 SIGKILL 仍不退出:不再无限等待,标记完成让 UI 继续, + // 子进程由内核在父进程退出时一并回收。 + if let Ok(mut lifecycle) = inner.lifecycle.lock() { + lifecycle.apply(SessionEventKind::Reaped(TerminalExit::Unknown)); + } + inner.mark_finished(); } -impl SearchState { - fn new() -> Self { - Self { - query: String::new(), - regex: None, - current: 0, - total: 0, +fn pty_size(dims: TermDims) -> PtySize { + PtySize { + rows: dims.rows.clamp(MIN_ROWS, MAX_ROWS) as u16, + cols: dims.cols.clamp(MIN_COLS, MAX_COLS) as u16, + pixel_width: 0, + pixel_height: 0, + } +} + +/// 取子进程进程组 id:优先用主端报告的前台进程组(`portable-pty` 让子进程 +/// `setsid`,因此它就是组长),其次退回子进程 pid。 +fn process_group_id( + master: &Box, + child: &Box, +) -> Option { + #[cfg(unix)] + if let Some(pgid) = master.process_group_leader() { + if pgid > 1 { + return Some(pgid); } } + let _ = master; + child.process_id().and_then(|pid| i32::try_from(pid).ok()) +} - fn clear(&mut self) { - self.query.clear(); - self.regex = None; - self.current = 0; - self.total = 0; +/// reader 线程:透传输出字节,读端结束后回收子进程。 +fn pump_output( + inner: &Arc, + reader: &mut Box, + tx: &mpsc::Sender, +) { + let mut buffer = vec![0u8; READER_CHUNK]; + loop { + match reader.read(&mut buffer) { + Ok(0) => break, + Ok(count) => { + if tx + .send(SessionEvent::Output(buffer[..count].to_vec())) + .is_err() + { + // 视图已释放:没人再消费输出,停止读;子进程由会话 Drop 拉起的 + // 看门狗线程终止。 + break; + } + } + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } + inner.release_writer(); +} + +/// 回收子进程:先非阻塞探测,再阻塞等待;看门狗线程负责兜底强杀。 +fn reap_child(inner: &Arc) -> TerminalExit { + let Ok(mut guard) = inner.child.lock() else { + return TerminalExit::Unknown; + }; + let Some(child) = guard.as_mut() else { + return TerminalExit::Unknown; + }; + if let Ok(Some(status)) = child.try_wait() { + return exit_of(status); + } + match child.wait() { + Ok(status) => exit_of(status), + Err(_) => TerminalExit::Unknown, + } +} + +fn exit_of(status: portable_pty::ExitStatus) -> TerminalExit { + match status.signal() { + Some(signal) if !signal.is_empty() => TerminalExit::Signal(signal.to_string()), + _ => TerminalExit::Code(status.exit_code() as i32), } } -/// 终端视图组件:点击聚焦后键盘字符模式直输(IDEA 式交互)。 +// --------------------------------------------------------------------------- +// 视图层 +// --------------------------------------------------------------------------- + +/// 一个网格单元格的渲染数据(有序遍历 `display_iter` 时收集)。 +#[derive(Clone, Copy, PartialEq)] +struct TermCell { + /// 显示字符;`None` 表示宽字符占位格(只占宽度,不绘制)。 + c: Option, + fg: AlacColor, + bg: AlacColor, + flags: Flags, + selected: bool, +} + +/// 光标在一行中的落点。 +#[derive(Debug, Clone, Copy)] +struct CursorPlacement { + /// 该行是否就是光标行且光标可见(聚焦、未隐藏、视口在底部)。 + visible: bool, + /// 光标列(单元格下标)。 + column: usize, + style: CursorStyle, + /// 闪烁相位(`false` 表示处于熄灭阶段)。 + phase: bool, +} + +impl CursorPlacement { + /// 光标是否真的画在下标为 `column` 的单元格上。 + fn covers(&self, column: usize) -> bool { + self.visible && self.phase && column == self.column + } + + /// 行尾之后是否需要补一个光标块。 + fn trailing(&self, rendered: usize) -> bool { + self.visible && self.phase && self.column >= rendered + } +} + +/// 终端视图组件:单会话,键盘字符模式直输(IDEA 式交互)。 /// /// 网格引擎与交互能力全部来自上游 `alacritty_terminal`:本结构只负责调用 /// 它的 resize/scroll/selection/search API,并把结果投影成 GPUI 元素。 pub struct TerminalView { - pub session: Option, - pub working_dir: String, - term: Term, - processor: Processor, - focus_handle: FocusHandle, - /// 最近一次已知的网格尺寸(行列),用于判断是否需要 resize。 - size: TermDims, + /// 当前会话;`None` 表示尚未创建或已被显式关闭(渲染空态)。 + session: Option, + /// 会话工作目录(宿主项目切换时更新)。 + working_dir: String, + /// 会话代号:每次新建/关闭自增,输出泵据此丢弃过期会话的字节。 + session_seq: u64, + /// 是否仍允许在首个 prepaint 自动创建会话。自动创建只发生一次:失败或 + /// 用户显式关闭后都不再自动重试(否则每帧重开会话会打满 CPU)。 + auto_spawn_pending: bool, /// 单元格像素尺寸;每次 render 按当前字号实测写入。 cell: CellMetrics, + /// 最近一次已知的网格尺寸(行列)。 + size: TermDims, /// 网格内容区边界(窗口坐标);由 `on_children_prepainted` 写入。 - grid_bounds: Rc>>>, + grid_bounds: Rc>>>, + /// 上游引擎:唯一的终端语义来源。 + term: Term, + /// ANSI 解析器(`advance` 需要独占借用,故暂存取出再放回)。 + processor: Processor, + focus_handle: FocusHandle, /// 正在拖拽选择时的选区类型。 dragging: Option, /// 终端内搜索状态。 search: SearchState, - /// 搜索栏输入框实体(懒创建)。 - search_input: Option>, - /// 搜索输入框 Change 订阅。 - search_subscription: Option, - /// 请求在下一帧展开搜索栏(键盘事件没有 `Window`,输入框需在 render 创建)。 - search_requested: bool, + /// 搜索输入框(懒创建,复用工作台唯一输入实现)。 + search_input: Option, + /// 搜索输入框事件订阅(必须与 `search_input` 同生命周期)。 + search_subscription: Option, + /// 本次鼠标手势是否已向终端应用上报过按下(决定后续拖动/抬起是否上报)。 + mouse_reported: bool, + /// 光标闪烁相位(`terminal_cursor_blink` 打开时由后台任务翻转)。 + cursor_phase: bool, + /// 光标闪烁任务是否已在跑,避免重复启动定时任务。 + blink_task_running: bool, } impl TerminalView { pub fn new(working_dir: String, cx: &mut Context) -> Self { let scrollback = settings::get(cx).terminal_scrollback.max(1); - let mut view = Self { + let size = TermDims { + cols: BOOTSTRAP_COLS, + rows: BOOTSTRAP_ROWS, + }; + Self { session: None, working_dir, + session_seq: 0, + auto_spawn_pending: true, + cell: CellMetrics { + width: 8.0, + height: 14.0, + }, + size, + grid_bounds: Rc::new(StdCell::new(None)), term: Term::new( Config { scrolling_history: scrollback, ..Config::default() }, - &TermDims { - cols: INITIAL_COLS, - rows: INITIAL_ROWS, - }, + &size, VoidListener, ), processor: Processor::new(), focus_handle: cx.focus_handle(), - size: TermDims { - cols: INITIAL_COLS, - rows: INITIAL_ROWS, - }, - cell: CellMetrics { - width: 8.0, - height: 18.0, - }, - grid_bounds: Rc::new(Cell::new(None)), dragging: None, search: SearchState::new(), search_input: None, search_subscription: None, - search_requested: false, - }; - view.attach_session(cx); - view - } - - /// 打开 PTY 会话并起主线程输出泵(channel 收字节块 → 网格解析 → notify)。 - fn attach_session(&mut self, cx: &mut Context) { - let shell = Self::resolve_shell(cx); - match TerminalSession::new( - self.size.cols as u16, - self.size.rows as u16, - &self.working_dir, - &shell, - ) { - Ok((session, rx, displayed)) => { + mouse_reported: false, + cursor_phase: true, + blink_task_running: false, + } + } + + /// 当前会话是否可写。 + pub fn is_running(&self) -> bool { + self.session + .as_ref() + .is_some_and(TerminalSession::is_attached) + } + + /// 会话是否已存在(可能已退出,退出信息仍需展示)。 + pub fn has_session(&self) -> bool { + self.session.is_some() + } + + /// 已有会话时返回其状态。 + pub fn session_state(&self) -> SessionState { + self.session + .as_ref() + .map(TerminalSession::state) + .unwrap_or(SessionState::Closed) + } + + /// 会话已经退出时给出退出码/信号,供状态区直接展示;仍在跑返回 `None`。 + pub fn session_exit(&self) -> Option { + match self.session_state() { + SessionState::Exited(exit) => Some(exit), + _ => None, + } + } + + /// 会话 shell 路径(无会话时为空)。 + pub fn shell(&self) -> &str { + self.session + .as_ref() + .map(TerminalSession::shell) + .unwrap_or_default() + } + + /// 视口是否停在底部(决定“回到底部”按钮是否可用)。 + pub fn is_at_bottom(&self) -> bool { + self.term.grid().display_offset() == 0 + } + + /// 打开 PTY 会话并起主线程输出泵。已有会话、或自动创建闸门已关闭时不动。 + /// + /// 自动路径由首帧 render 触发:PTY 先按引导尺寸启动,同一帧的 prepaint 立刻 + /// 用实测容器尺寸 resize 网格与 PTY,因此引导尺寸只存在一帧。 + fn ensure_session(&mut self, cx: &mut Context) { + if self.session.is_some() || !self.auto_spawn_pending { + return; + } + self.auto_spawn_pending = false; + let shell = resolve_shell(&settings::get(cx).terminal_default_shell_id); + let size = self.size; + let working_dir = self.working_dir.clone(); + self.session_seq += 1; + let seq = self.session_seq; + match TerminalSession::new(size, &working_dir, &shell) { + Ok((session, rx)) => { self.session = Some(session); let banner = - crate::i18n::menu_text(cx, "terminal.session").replace("{shell}", &displayed); + crate::i18n::menu_text(cx, "terminal.session").replace("{shell}", &shell); // 首行提示直接画进网格(换行落行)。 self.advance(format!("{banner}\r\n").as_bytes()); - Self::spawn_pump(rx, cx); + Self::spawn_pump(rx, seq, cx); } - Err(_) => { - self.session = None; + Err(error) => { + tracing::warn!("failed to open terminal pty session: {error}"); + let text = crate::i18n::menu_text(cx, "terminal.unavailable"); + self.advance(format!("{text}: {error}\r\n").as_bytes()); } } cx.notify(); @@ -431,7 +1560,10 @@ impl TerminalView { } /// 主线程输出泵:后台收字节块,回主线程喂网格解析器。 - fn spawn_pump(rx: mpsc::Receiver>, cx: &mut Context) { + /// + /// 阻塞的 `recv` 放在后台线程,UI 线程只做 `advance`;会话代号变化后 + /// 旧泵立即退出,旧会话的字节不会污染新网格。 + fn spawn_pump(rx: mpsc::Receiver, seq: u64, cx: &mut Context) { let rx = Arc::new(Mutex::new(rx)); cx.spawn(async move |this, cx| loop { let slot = rx.clone(); @@ -442,79 +1574,93 @@ impl TerminalView { guard.recv().ok() }) .await; - match next { - Some(bytes) => { - if this - .update(cx, |view, cx| { - view.advance(&bytes); - cx.notify(); - }) - .is_err() - { - break; - } - } - None => break, + let Some(event) = next else { + break; + }; + let alive = this + .update(cx, |view, cx| view.handle_session_event(seq, event, cx)) + .unwrap_or(false); + if !alive { + break; } }) .detach(); } - /// 解析生效 shell:设置 `terminalDefaultShellId` 非空即用(名称经 - /// `PATH` 解析为路径),为空回退平台默认 shell。 - fn resolve_shell(cx: &App) -> String { - let id = settings::get(cx) - .terminal_default_shell_id - .trim() - .to_string(); - if id.is_empty() { - return String::new(); - } - // 已含路径分隔符(`/` 或 `\`)时视为绝对/相对路径,直接使用。 - if id.contains('/') || id.contains('\\') { - return id; + /// 处理一条会话事件;返回是否继续泵(会话已结束或被替换则停止)。 + fn handle_session_event( + &mut self, + seq: u64, + event: SessionEvent, + cx: &mut Context, + ) -> bool { + if seq != self.session_seq { + return false; } - if let Some(paths) = std::env::var_os("PATH") { - for dir in std::env::split_paths(&paths) { - for candidate in shell_candidates(&dir, &id) { - if candidate.is_file() { - return candidate.to_string_lossy().to_string(); - } - } + match event { + SessionEvent::Output(bytes) => { + self.advance(&bytes); + cx.notify(); + true } - } - // 某些发行版的 shell 不在 PATH 中,退回固定系统目录。 - #[cfg(unix)] - { - let fallback = format!("/bin/{id}"); - if std::path::Path::new(&fallback).is_file() { - return fallback; + SessionEvent::Closed(exit) => { + self.report_exit(exit, cx); + false } } - id } - /// 程序化发送命令(宿主调用,如打开目录):原文 + 换行。 - pub fn send_command(&mut self, cmd: &str, cx: &mut Context) { - if let Some(session) = &self.session { - let mut full = cmd.to_string(); - full.push('\n'); - let _ = session.write_input(&full); - } + /// 把子进程退出结果写进网格,让用户看到会话为什么停了。 + fn report_exit(&mut self, exit: TerminalExit, cx: &mut Context) { + let key = match &exit { + TerminalExit::Code(_) => "terminal.exitedCode", + TerminalExit::Signal(_) => "terminal.signalled", + TerminalExit::Unknown => "terminal.exitUnknown", + }; + let text = crate::i18n::menu_text(cx, key).replace("{detail}", &exit.detail()); + self.advance(format!("{text}\r\n").as_bytes()); + cx.notify(); + } + + /// 新建会话:先关闭旧会话(回收子进程),再按当前尺寸与工作目录重开。 + pub fn restart(&mut self, cx: &mut Context) { + self.teardown_session(); + self.reset_grid(cx); + self.open_session(cx); + } + + /// 显式新建会话(忽略自动创建闸门,供空态按钮与程序化命令使用)。 + fn open_session(&mut self, cx: &mut Context) { + self.auto_spawn_pending = true; + self.ensure_session(cx); + } + + /// 关闭会话:回收子进程并回到空态,且不再自动重建(面板是否折叠由宿主决定)。 + pub fn close_session(&mut self, cx: &mut Context) { + self.teardown_session(); + self.reset_grid(cx); + self.auto_spawn_pending = false; cx.notify(); } - /// 清屏(等价于 shell `clear`:擦除可见区,保留回滚)。 - pub fn clear(&mut self, cx: &mut Context) { - if self.session.is_some() { - self.advance(b"\x1b[2J\x1b[H"); + /// 丢弃当前会话句柄(进程组终止与 reader 回收交给后台线程,见 + /// [`TerminalSession::stop_and_reap`] 的有界等待)。 + fn teardown_session(&mut self) { + self.session_seq += 1; + if let Some(session) = self.session.take() { + // 句柄的 `Drop` 会有界等待子进程回收,不能占用 UI 线程;交给一次 + // 性后台线程立即 drop,效果与原地 drop 相同。 + thread::Builder::new() + .name("lithe-terminal-reap".to_string()) + .spawn(move || drop(session)) + .ok(); } - cx.notify(); + self.dragging = None; + self.mouse_reported = false; } - /// 用现有工作目录重建 PTY 会话与网格(失败则置空并通知)。 - pub fn respawn(&mut self, cx: &mut Context) { - self.session = None; + /// 重建上游网格并清空搜索态(回滚缓冲、选择、命中列表都归零)。 + fn reset_grid(&mut self, cx: &mut Context) { let scrollback = settings::get(cx).terminal_scrollback.max(1); self.term = Term::new( Config { @@ -527,58 +1673,109 @@ impl TerminalView { self.processor = Processor::new(); self.search.clear(); self.dragging = None; - self.attach_session(cx); + self.mouse_reported = false; + cx.notify(); } - /// 依据容器实测尺寸同步网格与 PTY(上游 reflow 由 `Term::resize` 完成)。 - fn sync_size(&mut self, bounds: Bounds, cx: &mut Context) { - let width = f32::from(bounds.size.width) - GRID_PADDING * 2.0; - let height = f32::from(bounds.size.height) - GRID_PADDING * 2.0; - if width <= 0.0 || height <= 0.0 { + /// 切换工作目录。项目切换后旧 shell 的 cwd 与环境已过期,按 IDE 语义 + /// 重启会话(`cd` 命令只适用于“打开某个子目录”,不适用于换项目)。 + pub fn set_working_dir(&mut self, dir: String, cx: &mut Context) { + let dir = dir.trim().to_string(); + if dir.is_empty() || dir == self.working_dir { return; } + self.working_dir = dir; + if self.session.is_some() { + self.restart(cx); + } else { + cx.notify(); + } + } + + /// 清屏(等价于 shell `clear`:擦除可见区,保留回滚)。 + pub fn clear(&mut self, cx: &mut Context) { + if self.has_session() { + self.advance(b"\x1b[2J\x1b[H"); + } + cx.notify(); + } - let cols = ((width / self.cell.width).floor() as usize).max(MIN_COLS); - let rows = ((height / self.cell.height).floor() as usize).max(MIN_ROWS); - if cols == self.size.cols && rows == self.size.rows { + /// 程序化发送命令(宿主调用,如打开目录):原文 + 换行。 + pub fn send_command(&mut self, cmd: &str, cx: &mut Context) { + // 会话被显式关闭过时命令无处可去,这里按用户意图重新开会话。 + self.open_session(cx); + if !self.is_running() { return; } + let mut full = cmd.to_string(); + full.push('\n'); + if let Some(session) = &self.session { + let _ = session.write_bytes(full.as_bytes()); + } + self.scroll_to_bottom(); + cx.notify(); + } - self.size = TermDims { cols, rows }; - self.term.resize(self.size); + /// 视口滚到底部(回到底部动作、键盘输入后自动跟随)。 + pub fn scroll_to_bottom(&mut self) { + self.term.scroll_display(Scroll::Bottom); + } + + /// 依据容器实测尺寸同步网格与 PTY(上游 reflow 由 `Term::resize` 完成)。 + /// 返回尺寸是否发生变化。 + fn sync_size(&mut self, bounds: Bounds, cx: &mut Context) -> bool { + let Some(dims) = self.cell.grid_dims(bounds.size) else { + return false; + }; + if dims == self.size { + return false; + } + self.size = dims; + self.term.resize(dims); if let Some(session) = &self.session { - session.resize(cols as u16, rows as u16); + session.resize(dims); } cx.notify(); + true } /// 窗口坐标 → 网格点(含回滚偏移);越界返回 `None`。 - fn grid_point_at(&self, position: gpui_kit::Point) -> Option { + fn grid_point_at(&self, position: Point) -> Option { let bounds = self.grid_bounds.get()?; - let display_offset = self.term.grid().display_offset(); viewport_to_grid_point( f32::from(position.x) - f32::from(bounds.origin.x), f32::from(position.y) - f32::from(bounds.origin.y), self.cell, self.size, - display_offset, + self.term.grid().display_offset(), ) } - /// 开始一次选择(单击 simple / 双击 semantic / 三击 lines)。 - fn begin_selection(&mut self, point: GridPoint, ty: SelectionType) { - self.term.selection = Some(Selection::new(ty, point, Side::Left)); - } - - /// 更新拖拽中的选择终点。 - fn update_selection(&mut self, point: GridPoint) { - if let Some(selection) = self.term.selection.as_mut() { - selection.update(point, Side::Right); + /// 把输入字节写入 PTY,并把视口带回底部(IDEA/xterm 行为:一旦开始 + /// 交互就该看到最新输出)。 + fn send_to_pty(&mut self, bytes: &[u8]) { + let bracketed = self.term.mode().contains(TermMode::BRACKETED_PASTE); + let payload = if bracketed && bytes.len() > 1 { + // 上游开启 bracketed paste 后必须成对包裹,否则程序会把粘贴 + // 当成逐字输入执行。 + let mut payload = vec![0x1b, b'[', b'2', b'0', b'0', b'~']; + payload.extend_from_slice(bytes); + payload.extend_from_slice(b"\x1b[201~"); + payload + } else { + bytes.to_vec() + }; + let written = self + .session + .as_ref() + .is_some_and(|session| session.write_bytes(&payload).is_ok()); + if written { + self.scroll_to_bottom(); } } /// 复制当前选择到系统剪贴板;无选择返回 false。 - fn copy_selection(&mut self, cx: &mut App) -> bool { + fn copy_selection(&self, cx: &mut App) -> bool { let Some(text) = self.term.selection_to_string() else { return false; }; @@ -594,254 +1791,236 @@ impl TerminalView { let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else { return; }; - if let Some(session) = &self.session { - let _ = session.write_bytes(text.as_bytes()); - } - } - - /// 搜索串变化:重建上游正则并跳到首个命中。 - fn set_search_query(&mut self, query: String, cx: &mut Context) { - self.search.query = query; - self.search.current = 0; - self.search.total = 0; - self.search.regex = if self.search.query.is_empty() { - None - } else { - RegexSearch::new(&self.search.query).ok() - }; - self.search_step(true, cx); - } - - /// 跳到下一个/上一个命中。`forward` 决定方向。 - fn search_step(&mut self, forward: bool, cx: &mut Context) { - let Some(regex) = self.search.regex.as_mut() else { - cx.notify(); - return; - }; - if self.search.query.is_empty() { - cx.notify(); + if text.is_empty() { return; } + // 剪贴板里的 CRLF 会让部分交互程序吞掉后续行,统一成 LF。 + let normalized = text.replace("\r\n", "\n"); + self.send_to_pty(normalized.as_bytes()); + } - let direction = if forward { - Direction::Right - } else { - Direction::Left - }; - let origin = self.term.grid().cursor.point; - let found: Option = - self.term - .search_next(regex, origin, direction, Side::Left, None); - - if let Some(found) = found { - self.search.current = if forward { - self.search.current.saturating_add(1).max(1) - } else { - self.search.current.saturating_sub(1).max(1) - }; - // 把命中滚入视口并选中,复用上游 selection 作为高亮来源。 - let start = *found.start(); - let end = *found.end(); - self.term.scroll_to_point(start); - let mut selection = Selection::new(SelectionType::Simple, start, Side::Left); - selection.update(end, Side::Right); - self.term.selection = Some(selection); - } else { - self.search.current = 0; + /// 打开终端内搜索:复用工作台唯一输入实现并聚焦。 + pub fn open_search(&mut self, window: &mut Window, cx: &mut Context) { + if self.search_input.is_none() { + let search = SearchInput::new( + crate::i18n::menu_text(cx, "terminal.searchPlaceholder"), + window, + cx, + ); + // 订阅必须与输入框同生命周期,否则事件立即失效。 + self.search_subscription = Some(search.subscribe(cx, |this, event, cx| match event { + InputEvent::Change => { + let value = this + .search_input + .as_ref() + .map(|input| input.value(cx)) + .unwrap_or_default(); + this.apply_search_query(value, cx); + } + InputEvent::PressEnter { shift, .. } => this.step_search(!shift, cx), + // 失焦不抢焦点:等用户点击网格或按关闭按钮,避免打字中途被夺走。 + InputEvent::Focus | InputEvent::Blur => {} + })); + self.search_input = Some(search); } + let search = self + .search_input + .as_ref() + .expect("search input just created"); + // 打开即复位为空查询并聚焦一次;不要每帧抢焦点。 + search.set_value("", window, cx); + self.search.clear(); + self.term.selection = None; + search.focus(window, cx); cx.notify(); } - /// 关闭搜索栏并清除搜索态选择。 - fn close_search(&mut self, cx: &mut Context) { + /// 关闭搜索栏并清除搜索态高亮,焦点回到网格。 + pub fn close_search(&mut self, window: &mut Window, cx: &mut Context) { self.search.clear(); self.term.selection = None; - self.search_requested = false; - // 丢弃输入框实体即释放搜索栏;下次打开重建空值输入框。 self.search_subscription = None; self.search_input = None; + window.focus(&self.focus_handle, cx); cx.notify(); } /// 搜索栏是否可见。 fn search_visible(&self) -> bool { - self.search_requested || self.search_input.is_some() + self.search_input.is_some() } - /// 懒创建搜索输入框并订阅 Change 事件,值同步到搜索状态。 - fn ensure_search_input( - slot: &mut Option>, - subscription: &mut Option, - window: &mut Window, - cx: &mut Context, - ) -> gpui_kit::Entity { - if let Some(entity) = slot { - return entity.clone(); - } - let entity = cx.new(|cx| InputState::new(window, cx)); - let value_source = entity.clone(); - *subscription = Some(cx.subscribe( - &entity, - move |this: &mut Self, _, event: &InputEvent, cx| { - if matches!(event, InputEvent::Change) { - let value = value_source.read(cx).value().to_string(); - this.set_search_query(value, cx); - } - }, - )); - *slot = Some(entity.clone()); - entity + /// 查询串变化:重建上游正则、重扫命中并高亮首个。 + fn apply_search_query(&mut self, query: String, cx: &mut Context) { + if query == self.search.query { + return; + } + self.search.set_query(&self.term, query); + self.highlight_current_match(); + cx.notify(); } - /// 应用光标键模式:程序用 `\x1bO` 系,否则 `\x1b[` 系。 - fn cursor_key(&self, normal: &[u8], app: &[u8]) -> Vec { - if self.term.mode().contains(TermMode::APP_CURSOR) { - app.to_vec() - } else { - normal.to_vec() + /// 上一个/下一个命中:序号环绕 + 上游滚动 + 用 selection 高亮。 + fn step_search(&mut self, forward: bool, cx: &mut Context) { + if self.search.is_empty() { + return; + } + if self.search.total() == 0 { + // 上一次查询判定无命中,而输出可能已经变化,重扫一次。 + self.search.jump_to_first(&self.term); } + self.search.step(forward); + self.highlight_current_match(); + cx.notify(); } - /// 键盘直输:可打印字符原样写 PTY,控制组合与功能键转义序列, - /// 未知键放行(IDEA 快捷键不断)。返回是否消费。 - fn handle_key(&mut self, event: &KeyDownEvent, cx: &mut App) -> bool { - let key = event.keystroke.key.as_str(); - let mods = &event.keystroke.modifiers; + fn highlight_current_match(&mut self) { + let Some(found) = self.search.current_match() else { + self.term.selection = None; + return; + }; + self.term.scroll_to_point(*found.start()); + let mut selection = Selection::new(SelectionType::Simple, *found.start(), Side::Left); + selection.update(*found.end(), Side::Right); + self.term.selection = Some(selection); + } - // 终端内复制/粘贴/搜索:Ctrl+Shift+C / Ctrl+Shift+V / Ctrl+Shift+F。 - // 放在最前,避免被下面的 Ctrl 控制字符分支吞掉。 - if mods.control && mods.shift && !mods.alt && !mods.platform { - match key.to_lowercase().as_str() { - "c" => { - self.copy_selection(cx); - return true; - } - "v" => { - self.paste_clipboard(cx); - return true; - } - "f" => { - // 输入框需要 `Window`,此处只登记请求,render 阶段创建。 - // 刷新由调用方 `on_key_down` 统一触发。 - self.search_requested = true; - return true; - } - _ => {} + /// 应用按键动作;返回是否已消费(未消费则不阻止全局快捷键)。 + fn apply_key_action( + &mut self, + action: KeyAction, + window: &mut Window, + cx: &mut Context, + ) -> bool { + match action { + KeyAction::Send(bytes) => self.send_to_pty(&bytes), + KeyAction::Copy => { + self.copy_selection(cx); } + KeyAction::Paste => self.paste_clipboard(cx), + KeyAction::OpenSearch => self.open_search(window, cx), + KeyAction::Scroll(lines) => { + self.term.scroll_display(Scroll::Delta(lines as i32)); + } + KeyAction::ScrollToBottom => self.scroll_to_bottom(), + KeyAction::Passthrough => return false, } + cx.notify(); + true + } - let bytes: Vec = if mods.control && !mods.alt && !mods.platform { - // Ctrl+字母 → 控制字符(Ctrl+C 中断等)。 - let lower = key.to_lowercase(); - let mut chars = lower.chars(); - match (chars.next(), chars.next()) { - (Some(c @ 'a'..='z'), None) => vec![(c as u8) - b'a' + 1], - (Some(' '), None) => vec![0], - (Some('['), None) => vec![0x1b], - (Some('\\'), None) => vec![0x1c], - (Some(']'), None) => vec![0x1d], - (Some('^'), None) => vec![0x1e], - (Some('_'), None) => vec![0x1f], - _ => return false, - } - } else if mods.alt && !mods.control && !mods.platform { - // Alt+字符 → ESC 前缀(readline Meta 键)。 - match &event.keystroke.key_char { - Some(text) if text.chars().count() == 1 => { - let mut out = vec![0x1b]; - out.extend_from_slice(text.as_bytes()); - out - } - _ => return false, - } - } else if mods.control || mods.platform { + /// 开始一次选择(单击 simple / 双击 semantic / 三击 lines)。 + fn begin_selection(&mut self, point: GridPoint, ty: SelectionType) { + self.term.selection = Some(Selection::new(ty, point, Side::Left)); + self.dragging = Some(ty); + } + + /// 更新拖拽中的选择终点。 + fn update_selection(&mut self, point: GridPoint) { + if let Some(selection) = self.term.selection.as_mut() { + selection.update(point, Side::Right); + } + } + + /// 鼠标事件是否应交给终端应用而不是本地选择。 + fn report_mouse(&mut self, report: MouseReport, position: Point) -> bool { + if !mouse_report_enabled(report, *self.term.mode()) { return false; - } else { - match key { - "enter" => b"\r".to_vec(), - "backspace" => vec![0x7f], - "tab" => b"\t".to_vec(), - "escape" => vec![0x1b], - "up" => self.cursor_key(b"\x1b[A", b"\x1bOA"), - "down" => self.cursor_key(b"\x1b[B", b"\x1bOB"), - "right" => self.cursor_key(b"\x1b[C", b"\x1bOC"), - "left" => self.cursor_key(b"\x1b[D", b"\x1bOD"), - "home" => self.cursor_key(b"\x1b[H", b"\x1bOH"), - "end" => self.cursor_key(b"\x1b[F", b"\x1bOF"), - "delete" => b"\x1b[3~".to_vec(), - "pageup" => b"\x1b[5~".to_vec(), - "pagedown" => b"\x1b[6~".to_vec(), - "f1" => b"\x1bOP".to_vec(), - "f2" => b"\x1bOQ".to_vec(), - "f3" => b"\x1bOR".to_vec(), - "f4" => b"\x1bOS".to_vec(), - "f5" => b"\x1b[15~".to_vec(), - "f6" => b"\x1b[17~".to_vec(), - "f7" => b"\x1b[18~".to_vec(), - "f8" => b"\x1b[19~".to_vec(), - "f9" => b"\x1b[20~".to_vec(), - "f10" => b"\x1b[21~".to_vec(), - "f11" => b"\x1b[23~".to_vec(), - "f12" => b"\x1b[24~".to_vec(), - _ => match &event.keystroke.key_char { - // 可打印字符(含中文)原样直输。 - Some(text) => text.as_bytes().to_vec(), - None => return false, - }, - } - }; - if let Some(session) = &self.session { - let _ = session.write_bytes(&bytes); } + let Some(point) = self.grid_point_at(position) else { + return false; + }; + let sgr = self.term.mode().contains(TermMode::SGR_MOUSE); + let bytes = encode_mouse_report(report, point, self.term.grid().display_offset(), sgr); + self.send_to_pty(&bytes); true } + + /// 键盘输入入口。返回是否已消费(消费则阻止冒泡到全局快捷键)。 + fn handle_key( + &mut self, + event: &KeyDownEvent, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if self.search_visible() { + // 搜索栏打开时字符归输入框(正常路径下焦点在输入框,这里走不到)。 + // 保留这道闸门:一旦焦点意外留在网格上,也不能把搜索期按键直输 PTY。 + return false; + } + let action = map_key( + &event.keystroke.key, + event.keystroke.key_char.as_deref(), + KeyModifiers::from(&event.keystroke.modifiers), + *self.term.mode(), + self.size.rows, + ); + self.apply_key_action(action, window, cx) + } + + /// 光标闪烁任务:仅在设置打开且会话存在时翻转相位;条件不再满足时 + /// 自行退出,不会留下常驻定时任务。 + fn spawn_blink(cx: &mut Context) { + cx.spawn(async move |this, cx| loop { + cx.background_executor().timer(CURSOR_BLINK_INTERVAL).await; + let keep = this + .update(cx, |view, cx| { + if !view.blink_task_running || !view.has_session() { + view.blink_task_running = false; + return false; + } + view.cursor_phase = !view.cursor_phase; + cx.notify(); + true + }) + .unwrap_or(false); + if !keep { + break; + } + }) + .detach(); + } } impl Render for TerminalView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let focused = self.focus_handle.is_focused(window); - // 单元格基准宽度:等宽字体下用上游 `em_layout_width` 实测(随字号变化)。 - let font_size = px(crate::settings::get(cx).terminal_font_size); - let font = gpui_kit::Font { - family: "monospace".into(), - ..Default::default() - }; - let font_id = window.text_system().resolve_font(&font); - let cell_width = - f32::from(window.text_system().em_layout_width(font_id, font_size)).max(1.0); - let line_height = f32::from(font_size) * LINE_HEIGHT_RATIO; - self.cell = CellMetrics { - width: cell_width, - height: line_height, - }; + let font_size = px(settings::get(cx).terminal_font_size); + self.cell = CellMetrics::measure(window, font_size); + let blink_enabled = settings::get(cx).terminal_cursor_blink; + if blink_enabled && !self.blink_task_running { + self.blink_task_running = true; + Self::spawn_blink(cx); + } else if !blink_enabled { + self.blink_task_running = false; + } - // 用上一帧 prepaint 记录的容器边界同步网格与 PTY;尺寸变化时 - // `sync_size` 内部 resize 上游网格并通知 PTY。 - if let Some(bounds) = self.grid_bounds.get() { - self.sync_size(bounds, cx); + // 没有会话就开一次(首帧用引导尺寸,本帧 prepaint 立刻按实测尺寸 + // resize 网格与 PTY;之后一律用已实测的尺寸)。自动创建只尝试一次, + // 失败或用户显式关闭后不会每帧重试。 + if !self.has_session() { + self.ensure_session(cx); } + let palette = TermPalette::from_theme(); + let focused = self.focus_handle.is_focused(window); + let display_offset = self.term.grid().display_offset(); + // 可见区按行分组(`display_iter` 即当前视口,已含回滚偏移)。 let content = self.term.renderable_content(); let selection = content.selection; - let default_fg = ThemeColors::foreground(); - let default_bg = ThemeColors::background(); - let selection_color = ThemeColors::accent(); - + let cursor_line = content.cursor.point.line.0; + let cursor_col = content.cursor.point.column.0; + let cursor_style = CursorStyle::from(content.cursor.shape); + // 上滚时不画光标:它已经不在视口里,画出来会指向错误的行。 + let cursor_visible = focused && cursor_style != CursorStyle::Hidden && display_offset == 0; let mut rows: Vec<(i32, Vec)> = Vec::new(); for indexed in content.display_iter { let line = indexed.point.line.0; if rows.last().map(|(number, _)| *number) != Some(line) { rows.push((line, Vec::new())); } - let cell = &indexed.cell; - let inverse = cell.flags.contains(Flags::INVERSE); - let (mut fg, mut bg) = (cell.fg, cell.bg); - if inverse { - std::mem::swap(&mut fg, &mut bg); - } - let bold = cell.flags.contains(Flags::BOLD); + let cell = indexed.cell; let selected = selection .as_ref() .is_some_and(|range| range.contains(indexed.point)); @@ -849,20 +2028,42 @@ impl Render for TerminalView { .expect("rows non-empty after push") .1 .push(TermCell { - c: cell.c, - fg, - bg, - bold, + // 宽字符的占位格只占宽度,不绘制字符。 + c: if cell + .flags + .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) + { + None + } else { + Some(cell.c) + }, + fg: cell.fg, + bg: cell.bg, + flags: cell.flags, selected, }); } - let (cursor_line, cursor_col, cursor_visible) = ( - content.cursor.point.line.0, - content.cursor.point.column.0, - focused - && !matches!(content.cursor.shape, CursorShape::Hidden) - && self.term.grid().display_offset() == 0, - ); + let cursor_phase = self.cursor_phase; + + let cell = self.cell; + let rendered_rows: Vec = rows + .into_iter() + .map(|(line, cells)| { + render_term_row( + line, + &cells, + CursorPlacement { + visible: cursor_visible && line == cursor_line, + column: cursor_col, + style: cursor_style, + phase: cursor_phase, + }, + &palette, + cell.width, + cell.height, + ) + }) + .collect(); let grid = div() .flex_1() @@ -871,158 +2072,230 @@ impl Render for TerminalView { .overflow_hidden() .pt(px(GRID_PADDING)) .pl(px(GRID_PADDING)) + .pr(px(GRID_PADDING)) .text_size(font_size) .font_family("monospace") - .children(rows.into_iter().map(|(line, cells)| { - render_term_row( - line, - &cells, - cursor_visible && line == cursor_line, - cursor_col, - default_fg, - default_bg, - selection_color, - cell_width, - line_height, - ) - })); - - // 搜索输入框懒创建 + 一次性订阅 Change(键盘请求在 render 落地)。 - let search_input = self.search_visible().then(|| { - Self::ensure_search_input( - &mut self.search_input, - &mut self.search_subscription, - window, - cx, - ) - }); + .children(rendered_rows); - // prepaint 记录网格容器边界,供鼠标坐标换算与 resize。 - // `on_children_prepainted` 给出的是子元素边界,因此外层包一层只有一个 - // 子元素的容器,`bounds.first()` 就是网格容器自身的实测边界。 + // 网格容器:唯一子元素,因此 prepaint 给出的首个子边界就是网格容器 + // 的实测边界。在这里直接驱动 resize,避免“先按引导尺寸渲染、下一帧才 + // resize”的滞后(折叠/展开、窗口缩放都当帧生效)。 let bounds_slot = self.grid_bounds.clone(); + let weak = cx.weak_entity(); let grid_with_bounds = div() .flex_1() .w_full() .min_h_0() - .on_children_prepainted(move |bounds, _window, _cx| { - if let Some(first) = bounds.first() { - bounds_slot.set(Some(*first)); - } + .on_children_prepainted(move |bounds, _window, cx| { + let Some(first) = bounds.first().copied() else { + return; + }; + bounds_slot.set(Some(first)); + let _ = weak.update(cx, |view, cx| { + view.sync_size(first, cx); + }); }) .child(grid); + let body: gpui_kit::AnyElement = if self.has_session() { + let search_bar = if self.search_visible() { + Some(self.render_search_bar(cx)) + } else { + None + }; + div() + .flex_1() + .w_full() + .min_h_0() + .id("terminal-grid") + .track_focus(&self.focus_handle) + .cursor_text() + .on_click(cx.listener(|this, _event, window, cx| { + if this.search_visible() { + this.close_search(window, cx); + } + window.focus(&this.focus_handle, cx); + })) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + if this.handle_key(event, window, cx) { + cx.stop_propagation(); + } + })) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, event: &MouseDownEvent, window, cx| { + if this.search_visible() { + this.close_search(window, cx); + } + window.focus(&this.focus_handle, cx); + if this.report_mouse(MouseReport::Press(0), event.position) { + this.mouse_reported = true; + return; + } + let Some(point) = this.grid_point_at(event.position) else { + return; + }; + let ty = match event.click_count { + 0 | 1 => SelectionType::Simple, + 2 => SelectionType::Semantic, + _ => SelectionType::Lines, + }; + this.begin_selection(point, ty); + cx.notify(); + }), + ) + .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| { + // 拖动上报只在“已上报按下”且按键仍按住时发,否则全屏 TUI + // 会被鼠标移动刷屏。 + if this.mouse_reported + && event.pressed_button.is_some() + && this.report_mouse(MouseReport::Drag(0), event.position) + { + return; + } + if this.dragging.is_none() { + return; + } + if let Some(point) = this.grid_point_at(event.position) { + this.update_selection(point); + cx.notify(); + } + })) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, event: &MouseUpEvent, _window, cx| { + let reported = this.mouse_reported; + this.mouse_reported = false; + this.dragging = None; + if reported { + this.report_mouse(MouseReport::Release(0), event.position); + } + cx.notify(); + }), + ) + .on_mouse_down( + MouseButton::Right, + cx.listener(|this, _event: &MouseDownEvent, window, cx| { + // 右键粘贴(IDEA 终端习惯),不落本地选择。 + cx.stop_propagation(); + this.paste_clipboard(cx); + window.focus(&this.focus_handle, cx); + }), + ) + .on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _window, cx| { + if this.scroll_wheel(event) { + cx.notify(); + } + })) + // 浮层搜索栏必须排在网格之后:GPUI 按绘制顺序逆序命中, + // 否则网格会盖住搜索条。 + .child(grid_with_bounds) + .when_some(search_bar, |el, bar| el.child(bar)) + .into_any_element() + } else { + self.render_closed_state(cx) + }; + v_flex() .size_full() .bg(ThemeColors::background()) - .id("terminal-grid") - .track_focus(&self.focus_handle) - .cursor_text() - .on_click(cx.listener(|this, _event, window, cx| { - window.focus(&this.focus_handle, cx); - })) - .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { - if this.handle_key(event, cx) { - cx.stop_propagation(); - cx.notify(); - } - })) - // 鼠标:按下定位并记录选区起点,拖动更新,抬起结束。 - .on_mouse_down( - MouseButton::Left, - cx.listener(|this, event: &MouseDownEvent, _window, cx| { - let Some(point) = this.grid_point_at(event.position) else { - return; - }; - let ty = match event.click_count { - 0 | 1 => SelectionType::Simple, - 2 => SelectionType::Semantic, - _ => SelectionType::Lines, - }; - this.begin_selection(point, ty); - this.dragging = Some(ty); - cx.notify(); - }), - ) - .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| { - if this.dragging.is_none() { - return; - } - if let Some(point) = this.grid_point_at(event.position) { - this.update_selection(point); - cx.notify(); - } - })) - .on_mouse_up( - MouseButton::Left, - cx.listener(|this, _event: &MouseUpEvent, _window, cx| { - this.dragging = None; - cx.notify(); - }), - ) - // 滚轮:直接驱动上游滚动缓冲。 - .on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _window, cx| { - let lines = match event.delta { - ScrollDelta::Lines(point) => f64::from(point.y), - ScrollDelta::Pixels(point) => f64::from(point.y) / this.cell.height as f64, - }; - let delta = lines.round() as i32; - if delta != 0 { - this.term.scroll_display(Scroll::Delta(delta)); - cx.notify(); - } - })) - .when_some(search_input, |el, input| { - el.child(self.render_search_bar(input, cx)) - }) - .child(grid_with_bounds) + .child(body) } } impl TerminalView { - /// 渲染搜索栏(终端内搜索,对齐 Tauri 终端搜索条)。 - fn render_search_bar( - &self, - input: gpui_kit::Entity, - cx: &mut Context, - ) -> impl IntoElement { - let counter = if self.search.query.is_empty() { - String::new() - } else { - format!( - "{}/{}", - self.search.current, - self.search.total.max(self.search.current) - ) + /// 滚轮 → 视口行数。Shift+滚轮在 Linux 上是横向手势,终端按行处理。 + fn scroll_wheel(&mut self, event: &ScrollWheelEvent) -> bool { + let shift = event.modifiers.shift; + let lines = match event.delta { + ScrollDelta::Lines(point) => wheel_axis(shift, f64::from(point.x), f64::from(point.y)), + ScrollDelta::Pixels(point) => { + let axis = wheel_axis(shift, f64::from(point.x), f64::from(point.y)); + axis / self.cell.height as f64 + } }; - h_flex() + let delta = lines.round() as isize; + if delta == 0 { + return false; + } + self.term.scroll_display(Scroll::Delta(delta as i32)); + true + } + + /// 无会话空态:给出明确的下一步(新建会话),不留空白面板。 + fn render_closed_state(&self, cx: &mut Context) -> gpui_kit::AnyElement { + v_flex() + .flex_1() .w_full() - .flex_shrink_0() + .min_h_0() .items_center() - .gap_2() - .px_2() - .py_1() - .bg(ThemeColors::bg_tab_bar()) - .border_b_1() - .border_color(ThemeColors::border()) + .justify_center() + .gap_3() .child( div() - .flex_1() - .child(Input::new(&input).small().cleanable(true)), + .text_sm() + .text_color(ThemeColors::text_muted()) + .child(crate::i18n::menu_text(cx, "terminal.closed")), + ) + .child( + Button::new("terminal-open") + .small() + .primary() + .icon(gpui_kit::assets::IconName::Plus) + .label(crate::i18n::menu_text(cx, "menu.newTerminal")) + .on_click(cx.listener(|this, _event, _window, cx| { + this.open_session(cx); + })), ) + .into_any_element() + } + + /// 渲染搜索栏(终端内搜索,对齐 Windows 终端搜索条:浮层 + 计数 + + /// 上/下一个 + 关闭)。做成浮层而不是挤占一行高度,打开/关闭搜索不会触发 + /// 终端 resize 与全屏 TUI 重排。输入框复用 [`SearchInput`],不在终端里 + /// 另写一套。 + fn render_search_bar(&self, cx: &mut Context) -> gpui_kit::AnyElement { + let search = self.search_input.as_ref().expect("search bar needs input"); + let label = self.search.label(); + let can_step = self.search.total() > 0; + h_flex() + .absolute() + .top_2() + .right_2() + .w(px(360.0)) + .items_center() + .gap_2() + .px_2() + .py_1() + .bg(ThemeColors::bg_tab_bar()) + .border_1() + .border_color(ThemeColors::border()) + .rounded_md() + .shadow_lg() + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + // Esc 关闭搜索栏:输入框自己没有这个动作,由搜索条兜底。 + if event.keystroke.key.as_str() == "escape" { + this.close_search(window, cx); + cx.stop_propagation(); + } + })) + .child(search.element()) .child( div() + .flex_shrink_0() .text_xs() .text_color(ThemeColors::text_muted()) - .child(counter), + .child(label), ) .child( Button::new("terminal-search-prev") .small() .ghost() .icon(gpui_kit::assets::IconName::ChevronUp) + .tooltip(crate::i18n::menu_text(cx, "terminal.searchPrevious")) + .disabled(!can_step) .on_click(cx.listener(|this, _event, _window, cx| { - this.search_step(false, cx); + this.step_search(false, cx); })), ) .child( @@ -1030,8 +2303,10 @@ impl TerminalView { .small() .ghost() .icon(gpui_kit::assets::IconName::ChevronDown) + .tooltip(crate::i18n::menu_text(cx, "terminal.searchNext")) + .disabled(!can_step) .on_click(cx.listener(|this, _event, _window, cx| { - this.search_step(true, cx); + this.step_search(true, cx); })), ) .child( @@ -1039,35 +2314,23 @@ impl TerminalView { .small() .ghost() .icon(gpui_kit::assets::IconName::X) - .on_click(cx.listener(|this, _event, _window, cx| { - this.close_search(cx); + .tooltip(crate::i18n::menu_text(cx, "terminal.searchClose")) + .on_click(cx.listener(|this, _event, window, cx| { + this.close_search(window, cx); })), ) + .into_any_element() } } -/// 一个网格单元格的渲染数据(有序遍历 `display_iter` 时收集)。 -#[derive(Clone, Copy)] -struct TermCell { - c: char, - fg: AlacColor, - bg: AlacColor, - bold: bool, - selected: bool, -} - -/// 渲染网格一行:同样式字符合并为一段;选中字符用选区底色;聚焦时在光标处 -/// 画反色块。每段用固定像素宽度(`字符数 × cell_width`)保证等宽对齐, -/// 鼠标命中换算依赖同一套单元格尺寸。 -#[allow(clippy::too_many_arguments)] +/// 渲染网格一行:同样式字符合并为一段;选中字符用选区底色;光标按上游 +/// `CursorShape` 呈现。每段用固定像素宽度(`字符数 × cell_width`)保证 +/// 等宽对齐,鼠标命中换算依赖同一套单元格尺寸。 fn render_term_row( line: i32, cells: &[TermCell], - cursor_here: bool, - cursor_col: usize, - default_fg: Rgba, - default_bg: Rgba, - selection_color: Rgba, + cursor: CursorPlacement, + palette: &TermPalette, cell_width: f32, line_height: f32, ) -> gpui_kit::AnyElement { @@ -1075,98 +2338,133 @@ fn render_term_row( .id(format!("term-row-{line}")) .w_full() .h(px(line_height)) + .flex_shrink_0() .items_center() + .whitespace_nowrap() .font_family("monospace"); if cells.is_empty() { - // 空行:只有光标落在首格时画块,否则保底一个空格占位。 - if cursor_here && cursor_col == 0 { - row = row.child( - div() - .flex_shrink_0() - .w(px(cell_width)) - .text_color(default_bg) - .bg(default_fg) - .child(" ".to_string()), - ); - } else { - row = row.child(div().child(" ")); + if cursor.trailing(0) { + row = row.child(cursor_block( + palette, + " ".to_string(), + cell_width, + cursor.style, + )); } return row.into_any_element(); } - // 逐格构建:同样式连续字符合并成段,光标位单独成块。 let mut index = 0usize; while index < cells.len() { let cell = cells[index]; - let is_cursor = cursor_here && index == cursor_col; - - if is_cursor { - // 光标块:反色(前景当底、背景当字),宽一格。 + if cursor.covers(index) { + let (fg, bg) = resolve_cell_colors(cell.fg, cell.bg, cell.flags, palette); row = row.child( div() .flex_shrink_0() .w(px(cell_width)) - .text_color(default_bg) - .bg(resolve_color(cell.fg, cell.bold, default_fg)) - .child(cell.c.to_string()), + .text_color(bg) + .bg(fg) + .child(cell.c.map(|c| c.to_string()).unwrap_or_default()), ); index += 1; continue; } - // 合并同 (fg,bg,bold,selected) 的后续字符。 + // 合并同 (fg,bg,flags,selected) 的后续字符。 let mut text = String::new(); - text.push(cell.c); - let mut len = 1usize; - while index + len < cells.len() { - let next = cells[index + len]; - if cursor_here && index + len == cursor_col { + let mut width = 0usize; + while index + width < cells.len() { + let next = cells[index + width]; + if cursor.covers(index + width) { break; } if next.fg != cell.fg || next.bg != cell.bg - || next.bold != cell.bold + || next.flags != cell.flags || next.selected != cell.selected { break; } - text.push(next.c); - len += 1; + // 宽字符占位格也要占一格宽度,但不产生字符。 + if let Some(c) = next.c { + text.push(c); + } + width += 1; } - - let fg = resolve_color(cell.fg, cell.bold, default_fg); - let bg = resolve_color(cell.bg, false, default_bg); - row = row.child( - div() - .flex_shrink_0() - .w(px(cell_width * len as f32)) - .text_color(fg) - .when(cell.selected, |el| el.bg(selection_color)) - .when( - !cell.selected && cell.bg != AlacColor::Named(NamedColor::Background), - |el| el.bg(bg), - ) - .when(cell.bold, |el| el.font_weight(FontWeight::BOLD)) - .child(text), - ); - index += len; + let (fg, bg) = resolve_cell_colors(cell.fg, cell.bg, cell.flags, palette); + let mut segment = div() + .flex_shrink_0() + .w(px(cell_width * width as f32)) + .text_color(fg) + .when(cell.selected, |el| el.bg(palette.selection)) + .when( + !cell.selected && cell.bg != AlacColor::Named(NamedColor::Background), + |el| el.bg(bg), + ); + if cell.flags.contains(Flags::BOLD) { + segment = segment.font_weight(FontWeight::BOLD); + } + if cell.flags.contains(Flags::ITALIC) { + segment = segment.italic(); + } + if cell.flags.intersects(Flags::ALL_UNDERLINES) { + segment = segment.underline(); + } + if cell.flags.contains(Flags::STRIKEOUT) { + segment = segment.line_through(); + } + row = row.child(segment.child(text)); + index += width; } - // 光标超出已给出单元格(行尾之后):补一个反色空格块。 - if cursor_here && cursor_col >= cells.len() { - row = row.child( - div() - .flex_shrink_0() - .w(px(cell_width)) - .text_color(default_bg) - .bg(default_fg) - .child(" ".to_string()), - ); + // 光标超出已给出单元格(行尾之后):补一个光标块。 + if cursor.trailing(cells.len()) { + row = row.child(cursor_block( + palette, + " ".to_string(), + cell_width, + cursor.style, + )); } row.into_any_element() } +/// 光标块:反色(前景当底、背景当字);下划线/竖线/空心三种形状按上游 +/// `CursorShape` 用边框表达。 +fn cursor_block( + palette: &TermPalette, + text: String, + cell_width: f32, + style: CursorStyle, +) -> gpui_kit::AnyElement { + let block = div() + .flex_shrink_0() + .w(px(cell_width)) + .h_full() + .text_color(palette.background) + .bg(palette.cursor) + .child(text); + match style { + CursorStyle::Block | CursorStyle::Hidden => block.into_any_element(), + CursorStyle::Underline => block + .border_b_1() + .border_color(palette.cursor) + .into_any_element(), + CursorStyle::Beam => block + .border_l_1() + .border_color(palette.cursor) + .into_any_element(), + CursorStyle::Hollow => block + .border_1() + .border_color(palette.cursor) + .text_color(palette.cursor) + .bg(palette.background) + .into_any_element(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -1182,6 +2480,36 @@ mod tests { } } + fn mods(control: bool, alt: bool, shift: bool, platform: bool) -> KeyModifiers { + KeyModifiers { + control, + alt, + shift, + platform, + } + } + + fn plain() -> KeyModifiers { + mods(false, false, false, false) + } + + /// 构造带输出的测试终端(复用与生产一致的解析路径)。 + fn term_with_output(bytes: &[u8]) -> Term { + let mut term = Term::new( + Config { + scrolling_history: 100, + ..Config::default() + }, + &dims(40, 10), + VoidListener, + ); + let mut processor: Processor = Processor::new(); + processor.advance(&mut term, bytes); + term + } + + // ---- 坐标换算 ---- + /// 网格左上角第一格:局部坐标需扣除内边距。 #[test] fn maps_top_left_corner_to_origin_cell() { @@ -1251,34 +2579,522 @@ mod tests { assert_eq!(point.column.0, 0); } - /// 256 色查表:0-15 取基础色,16 起为立方体,232 起为灰阶。 + /// 容器尺寸 → 网格行列数;容器未布局完成时不产出尺寸,极小容器不退化。 + #[test] + fn grid_dims_follow_measured_container() { + let metrics = cell(); + let size = Size { + width: px(GRID_PADDING * 2.0 + metrics.width * 40.0), + height: px(GRID_PADDING * 2.0 + metrics.height * 12.0), + }; + assert_eq!(metrics.grid_dims(size), Some(dims(40, 12))); + assert_eq!( + metrics.grid_dims(Size { + width: px(0.0), + height: px(0.0) + }), + None + ); + assert_eq!( + metrics.grid_dims(Size { + width: px(GRID_PADDING * 2.0 + 1.0), + height: px(GRID_PADDING * 2.0 + 1.0) + }), + Some(dims(MIN_COLS, MIN_ROWS)) + ); + // 超大容器必须被钳住,避免给 PTY 报一个荒谬的行列数。 + assert_eq!( + metrics.grid_dims(Size { + width: px(100_000.0), + height: px(100_000.0) + }), + Some(dims(MAX_COLS, MAX_ROWS)) + ); + } + + // ---- 生命周期状态机 ---- + + /// 正常路径:Closed → Running → Stopping → Exited(码)。 + #[test] + fn lifecycle_runs_from_spawn_to_exit_code() { + let mut lifecycle = SessionLifecycle::new(); + assert_eq!(lifecycle.state(), SessionState::Closed); + assert_eq!( + lifecycle.apply(SessionEventKind::Spawned), + SessionState::Running + ); + assert_eq!( + lifecycle.apply(SessionEventKind::StopRequested), + SessionState::Stopping + ); + assert_eq!( + lifecycle.apply(SessionEventKind::Reaped(TerminalExit::Code(0))), + SessionState::Exited(TerminalExit::Code(0)) + ); + } + + /// 停止请求是幂等的,重复请求不得把状态退回 Running。 + #[test] + fn stop_request_is_idempotent() { + let mut lifecycle = SessionLifecycle::new(); + lifecycle.apply(SessionEventKind::Spawned); + lifecycle.apply(SessionEventKind::StopRequested); + assert_eq!( + lifecycle.apply(SessionEventKind::StopRequested), + SessionState::Stopping + ); + } + + /// 前台程序自己退出时不经过 Stopping,Running 直接到 Exited(信号)。 + #[test] + fn spontaneous_exit_keeps_signal_detail() { + let mut lifecycle = SessionLifecycle::new(); + lifecycle.apply(SessionEventKind::Spawned); + let exit = TerminalExit::Signal("SIGKILL".to_string()); + assert_eq!( + lifecycle.apply(SessionEventKind::Reaped(exit.clone())), + SessionState::Exited(exit) + ); + } + + /// 关闭后可以重开:Exited → Running 允许,且不会复活已退出的旧会话。 + #[test] + fn session_can_be_reopened_after_exit() { + let mut lifecycle = SessionLifecycle::new(); + lifecycle.apply(SessionEventKind::Spawned); + lifecycle.apply(SessionEventKind::Reaped(TerminalExit::Code(1))); + assert_eq!( + lifecycle.apply(SessionEventKind::Spawned), + SessionState::Running + ); + // Running 状态下重复 Spawned 不得改变状态。 + assert_eq!( + lifecycle.apply(SessionEventKind::Spawned), + SessionState::Running + ); + } + + /// 已退出的会话不再可写,Running/Stopping 会话可写。 + #[test] + fn attached_matches_running_and_stopping() { + assert!(SessionState::Running.is_attached()); + assert!(SessionState::Stopping.is_attached()); + assert!(!SessionState::Closed.is_attached()); + assert!(!SessionState::Exited(TerminalExit::Code(0)).is_attached()); + } + + // ---- 输入映射 ---- + + /// 可打印字符原样直输;Ctrl+字母转控制字符。 + #[test] + fn maps_printable_and_control_characters() { + let default = TermMode::default(); + assert_eq!( + map_key("a", Some("a"), plain(), default, 24), + KeyAction::Send(b"a".to_vec()) + ); + assert_eq!( + map_key("c", Some("c"), mods(true, false, false, false), default, 24), + KeyAction::Send(vec![3]) + ); + assert_eq!( + map_key("d", Some("d"), mods(true, false, false, false), default, 24), + KeyAction::Send(vec![4]) + ); + } + + /// 非 macOS 平台约定:Ctrl+Shift+C/V 复制粘贴,Ctrl+V 直接粘贴。 + #[test] + fn maps_copy_paste_and_search_shortcuts() { + let default = TermMode::default(); + assert_eq!( + map_key("c", None, mods(true, false, true, false), default, 24), + KeyAction::Copy + ); + assert_eq!( + map_key("v", None, mods(true, false, true, false), default, 24), + KeyAction::Paste + ); + assert_eq!( + map_key("v", Some("v"), mods(true, false, false, false), default, 24), + KeyAction::Paste + ); + assert_eq!( + map_key("f", Some("f"), mods(true, false, false, false), default, 24), + KeyAction::OpenSearch + ); + } + + /// Shift+翻页滚视口,Ctrl+End 回到底部,两者都不写 PTY。 + #[test] + fn maps_viewport_scrolling_keys() { + let default = TermMode::default(); + assert_eq!( + map_key("pageup", None, mods(false, false, true, false), default, 30), + KeyAction::Scroll(30) + ); + assert_eq!( + map_key( + "pagedown", + None, + mods(false, false, true, false), + default, + 30 + ), + KeyAction::Scroll(-30) + ); + assert_eq!( + map_key("end", None, mods(true, false, false, false), default, 30), + KeyAction::ScrollToBottom + ); + } + + /// 方向键按上游 `APP_CURSOR` 模式选序列。 + #[test] + fn arrow_keys_follow_application_cursor_mode() { + assert_eq!( + map_key("up", None, plain(), TermMode::default(), 24), + KeyAction::Send(b"\x1b[A".to_vec()) + ); + assert_eq!( + map_key( + "up", + None, + plain(), + TermMode::APP_CURSOR | TermMode::default(), + 24 + ), + KeyAction::Send(b"\x1bOA".to_vec()) + ); + } + + /// Alt+字符加 ESC 前缀,Alt+方向键是 readline 词移动。 + #[test] + fn maps_alt_meta_sequences() { + let default = TermMode::default(); + assert_eq!( + map_key("b", Some("b"), mods(false, true, false, false), default, 24), + KeyAction::Send(b"\x1bb".to_vec()) + ); + assert_eq!( + map_key("left", None, mods(false, true, false, false), default, 24), + KeyAction::Send(b"\x1b[b".to_vec()) + ); + } + + /// 平台键与未映射组合键放行,工作台快捷键不能被终端吞掉。 + #[test] + fn unhandled_combinations_pass_through() { + let default = TermMode::default(); + // Ctrl+功能键没有终端语义,归工作台。 + assert_eq!( + map_key("home", None, mods(true, false, false, false), default, 24), + KeyAction::Passthrough + ); + assert_eq!( + map_key("t", Some("t"), mods(false, false, false, true), default, 24), + KeyAction::Passthrough + ); + // AltGr(Ctrl+Alt)用于输入字符,不能被当成控制字符。 + assert_eq!( + map_key("q", Some("q"), mods(true, true, false, false), default, 24), + KeyAction::Passthrough + ); + } + + /// Ctrl+字母仍是控制字符(readline 依赖它),不能因为“IDE 快捷键”就放行。 + #[test] + fn control_letters_still_reach_the_shell() { + let default = TermMode::default(); + assert_eq!( + map_key("w", Some("w"), mods(true, false, false, false), default, 24), + KeyAction::Send(vec![0x17]) + ); + } + + // ---- 鼠标上报 ---- + + /// 上游模式未开启鼠标上报时不编码(走本地选择)。 + #[test] + fn mouse_report_requires_upstream_mode() { + assert!(!mouse_report_enabled( + MouseReport::Press(0), + TermMode::default() + )); + assert!(mouse_report_enabled( + MouseReport::Press(0), + TermMode::MOUSE_REPORT_CLICK | TermMode::default() + )); + assert!(!mouse_report_enabled( + MouseReport::Drag(0), + TermMode::MOUSE_REPORT_CLICK | TermMode::default() + )); + assert!(mouse_report_enabled( + MouseReport::Drag(0), + TermMode::MOUSE_MOTION | TermMode::default() + )); + } + + /// SGR 编码:行列从 1 开始,回滚偏移叠加进上报行号。 + #[test] + fn encodes_sgr_mouse_report_with_offset() { + assert_eq!( + encode_mouse_report( + MouseReport::Press(0), + GridPoint::new(Line(-2), Column(3)), + 5, + true + ), + b"\x1b[<0;4;4M".to_vec() + ); + assert_eq!( + encode_mouse_report( + MouseReport::Release(0), + GridPoint::new(Line(0), Column(0)), + 0, + true + ), + b"\x1b[<3;1;1m".to_vec() + ); + assert_eq!( + encode_mouse_report( + MouseReport::Drag(2), + GridPoint::new(Line(0), Column(0)), + 0, + true + ), + b"\x1b[<34;1;1M".to_vec() + ); + } + + /// 非 SGR 模式回退 X10 编码(坐标加 32 偏移)。 + #[test] + fn encodes_x10_mouse_report_fallback() { + assert_eq!( + encode_mouse_report( + MouseReport::Press(1), + GridPoint::new(Line(0), Column(1)), + 0, + false + ), + vec![0x1b, b'[', b'M', 33, 34, 33] + ); + } + + // ---- 搜索状态 ---- + + /// 命中序号环绕:向前到末尾后回到首个,向后到首个后到末尾。 + #[test] + fn match_index_wraps_in_both_directions() { + assert_eq!(next_match_index(0, 3, true), 1); + assert_eq!(next_match_index(3, 3, true), 1); + assert_eq!(next_match_index(1, 3, false), 3); + assert_eq!(next_match_index(0, 0, true), 0); + } + + /// 计数文案:无命中 0/0,超出上限带 `+` 后缀。 + #[test] + fn match_label_marks_empty_and_truncated() { + assert_eq!(match_label(0, 0, false), "0/0"); + assert_eq!(match_label(2, 7, false), "2/7"); + assert_eq!(match_label(2, 7, true), "2/7+"); + } + + /// 查询串变化后重新收集命中,序号复位到首个命中,next 走到第二个。 + #[test] + fn search_collects_matches_and_selects_first() { + let term = term_with_output(b"alpha beta\r\nalpha gamma\r\n"); + let mut state = SearchState::new(); + state.set_query(&term, "alpha".to_string()); + assert_eq!(state.total(), 2); + assert_eq!(state.current, 1); + let first = state.current_match().expect("first match"); + assert_eq!(first.start().line.0, 0); + assert_eq!(first.start().column.0, 0); + state.step(true); + let second = state.current_match().expect("second match"); + assert_eq!(second.start().line.0, 1); + // 环绕回第一个。 + state.step(true); + assert_eq!(state.current, 1); + } + + /// 非法正则按无命中处理,且不保留上一次结果。 + #[test] + fn invalid_regex_clears_previous_matches() { + let term = term_with_output(b"alpha\r\n"); + let mut state = SearchState::new(); + state.set_query(&term, "alpha".to_string()); + assert_eq!(state.total(), 1); + state.set_query(&term, "([".to_string()); + assert_eq!(state.total(), 0); + assert!(state.is_empty()); + } + + /// 无命中时不产生高亮范围,计数显示 0/0。 + #[test] + fn search_without_match_has_no_highlight() { + let term = term_with_output(b"alpha\r\n"); + let mut state = SearchState::new(); + state.set_query(&term, "omega".to_string()); + assert_eq!(state.total(), 0); + assert_eq!(state.current, 0); + assert!(state.current_match().is_none()); + assert_eq!(state.label(), "0/0"); + } + + /// 引擎联通性:搜索跨回滚缓冲工作,命中集合覆盖历史行。 + #[test] + fn search_scans_scrollback_history() { + let mut term = Term::new( + Config { + scrolling_history: 100, + ..Config::default() + }, + &dims(20, 2), + VoidListener, + ); + let mut processor: Processor = Processor::new(); + processor.advance( + &mut term, + b"needle one\r\nneedle two\r\nneedle three\r\nneedle four", + ); + assert!(term.grid().history_size() > 0, "history must exist"); + let mut state = SearchState::new(); + state.set_query(&term, "needle".to_string()); + assert_eq!(state.total(), 4); + assert_eq!(state.label(), "1/4"); + } + + // ---- PTY 环境 ---- + + /// 宿主终端变量被清理,能力变量被覆盖,`TMUX` 之类嵌套标识保留。 + #[test] + fn pty_environment_clears_host_terminal_variables() { + let mut base = BTreeMap::new(); + base.insert("PATH".to_string(), "/usr/bin".to_string()); + base.insert("TERM".to_string(), "dumb".to_string()); + base.insert("COLUMNS".to_string(), "40".to_string()); + base.insert("WT_SESSION".to_string(), "abc".to_string()); + base.insert("TMUX".to_string(), "/tmp/tmux-0/default,1,0".to_string()); + let env = pty_environment(&base); + assert!(!env.contains_key("COLUMNS")); + assert!(!env.contains_key("WT_SESSION")); + assert_eq!(env.get("PATH").map(String::as_str), Some("/usr/bin")); + assert_eq!( + env.get("TMUX").map(String::as_str), + Some("/tmp/tmux-0/default,1,0") + ); + assert_eq!(env.get("TERM").map(String::as_str), Some(PTY_TERM)); + assert_eq!( + env.get("COLORTERM").map(String::as_str), + Some(PTY_COLORTERM) + ); + } + + // ---- 调色板 ---- + + /// 256 色:0-15 走主题 token,16 起为立方体,232 起为灰阶。 + #[test] + fn indexed_colors_cover_base_cube_and_grayscale() { + let palette = TermPalette::from_theme(); + let current = theme::palette(); + assert_eq!(palette.indexed(0), current.terminal_black); + assert_eq!(palette.indexed(7), current.terminal_white); + assert_eq!( + palette.indexed(8), + theme::mix(current.terminal_black, current.foreground, BRIGHT_MIX) + ); + assert_eq!(palette.indexed(16), rgba_of((0, 0, 0))); + assert_eq!(palette.indexed(231), rgba_of((255, 255, 255))); + assert_eq!(palette.indexed(232), rgba_of((8, 8, 8))); + assert_eq!(palette.indexed(255), rgba_of((238, 238, 238))); + } + + /// 命名色不走 `as u8` 折叠:`Foreground`/`Background` 走主题, + /// `DimRed` 走暗色变体而不是被折成 `Yellow`。 #[test] - fn palette_256_covers_base_cube_and_grayscale() { - assert_eq!(palette_256(0), ANSI_PALETTE[0]); - assert_eq!(palette_256(15), ANSI_PALETTE[15]); - assert_eq!(palette_256(16), (0, 0, 0)); - assert_eq!(palette_256(231), (255, 255, 255)); - assert_eq!(palette_256(232), (8, 8, 8)); - assert_eq!(palette_256(255), (238, 238, 238)); + fn named_colors_map_to_theme_tokens() { + let palette = TermPalette::from_theme(); + let current = theme::palette(); + assert_eq!(palette.named(NamedColor::Foreground), current.foreground); + assert_eq!(palette.named(NamedColor::Background), current.background); + assert_eq!(palette.named(NamedColor::Cursor), current.foreground); + assert_eq!( + palette.named(NamedColor::DimRed), + theme::mix(current.terminal_red, current.background, DIM_MIX) + ); + assert_ne!(palette.named(NamedColor::DimRed), current.terminal_yellow); } /// 加粗时 0-7 基础色提升到 8-15 高亮 variant(标准终端行为)。 #[test] fn bold_promotes_base_colors_to_bright_variants() { - let bold = resolve_color(AlacColor::Named(NamedColor::Red), true, Rgba::default()); - let normal = resolve_color(AlacColor::Named(NamedColor::Red), false, Rgba::default()); - assert_eq!(bold, rgba_of(ANSI_PALETTE[9])); - assert_eq!(normal, rgba_of(ANSI_PALETTE[1])); + let palette = TermPalette::from_theme(); + let bold = palette.resolve(AlacColor::Named(NamedColor::Red), true); + let normal = palette.resolve(AlacColor::Named(NamedColor::Red), false); + assert_eq!(bold, palette.bright(1)); + assert_eq!(normal, palette.base[1]); assert_ne!(bold, normal); } + /// `INVERSE` 交换前景底色;`HIDDEN` 让前景等于底色。 + #[test] + fn cell_colors_handle_inverse_and_hidden() { + let palette = TermPalette::from_theme(); + let fg = AlacColor::Indexed(1); + let bg = AlacColor::Indexed(4); + let (plain_fg, plain_bg) = resolve_cell_colors(fg, bg, Flags::empty(), &palette); + let (inverse_fg, inverse_bg) = resolve_cell_colors(fg, bg, Flags::INVERSE, &palette); + assert_eq!((inverse_fg, inverse_bg), (plain_bg, plain_fg)); + let (hidden_fg, hidden_bg) = resolve_cell_colors(fg, bg, Flags::HIDDEN, &palette); + assert_eq!(hidden_fg, hidden_bg); + } + + /// 光标形状按上游 `CursorShape` 映射。 + #[test] + fn cursor_style_follows_upstream_shape() { + assert_eq!(CursorStyle::from(CursorShape::Block), CursorStyle::Block); + assert_eq!( + CursorStyle::from(CursorShape::Underline), + CursorStyle::Underline + ); + assert_eq!(CursorStyle::from(CursorShape::Beam), CursorStyle::Beam); + assert_eq!( + CursorStyle::from(CursorShape::HollowBlock), + CursorStyle::Hollow + ); + assert_eq!(CursorStyle::from(CursorShape::Hidden), CursorStyle::Hidden); + } + + /// 光标落在哪一格、是否补尾格、熄灭相位是否隐藏,全部由上游光标列号决定。 + #[test] + fn cursor_placement_uses_cursor_column() { + let cursor = CursorPlacement { + visible: true, + column: 3, + style: CursorStyle::Block, + phase: true, + }; + assert!(cursor.covers(3)); + assert!(!cursor.covers(2)); + assert!(!cursor.trailing(4)); + assert!(cursor.trailing(2)); + let dark_phase = CursorPlacement { + phase: false, + ..cursor + }; + assert!(!dark_phase.covers(3)); + assert!(!dark_phase.trailing(2)); + } + + // ---- 引擎联通性 ---- + /// 引擎联通性:喂入带 ANSI 换行的字节后,可见区出现对应文本。 /// 这条守住 render 路径依赖的上游解析/网格行为。 #[test] fn engine_parses_output_into_grid() { - let mut term = Term::new(Config::default(), &dims(20, 4), VoidListener); - let mut processor: Processor = Processor::new(); - processor.advance(&mut term, b"hello\r\nworld"); + let term = term_with_output(b"hello\r\nworld"); let content = term.renderable_content(); let text: String = content.display_iter.map(|indexed| indexed.cell.c).collect(); assert!( @@ -1314,9 +3130,7 @@ mod tests { /// 引擎联通性:选择一块文本后可经上游导出字符串(复制链路)。 #[test] fn engine_selection_to_string_roundtrips() { - let mut term = Term::new(Config::default(), &dims(20, 4), VoidListener); - let mut processor: Processor = Processor::new(); - processor.advance(&mut term, b"abcdef"); + let mut term = term_with_output(b"abcdef"); let mut selection = Selection::new( SelectionType::Simple, GridPoint::new(Line(0), Column(0)), @@ -1331,7 +3145,6 @@ mod tests { /// 引擎联通性:resize 改变列数(随窗缩放依赖上游 reflow)。 #[test] fn engine_resize_updates_columns() { - use alacritty_terminal::grid::Dimensions as _; let mut term = Term::new(Config::default(), &dims(80, 24), VoidListener); assert_eq!(term.columns(), 80); term.resize(dims(100, 30)); @@ -1339,26 +3152,20 @@ mod tests { assert_eq!(term.screen_lines(), 30); } - /// 默认前景/背景取主题色;加粗不改变已高于 7 的索引颜色。 + /// 引擎联通性:宽字符在网格里占两格,第二格带占位标记,渲染必须跳过它。 #[test] - fn default_colors_follow_theme_and_bold_ignored_above_index_seven() { - let theme = Rgba { - r: 0.25, - g: 0.5, - b: 0.75, - a: 1.0, - }; - assert_eq!( - resolve_color(AlacColor::Named(NamedColor::Foreground), false, theme), - theme - ); - assert_eq!( - resolve_color(AlacColor::Named(NamedColor::Background), true, theme), - theme - ); - assert_eq!( - resolve_color(AlacColor::Indexed(9), true, theme), - rgba_of(ANSI_PALETTE[9]) - ); + fn engine_marks_wide_char_spacer_cells() { + let term = term_with_output("中".as_bytes()); + let content = term.renderable_content(); + let spacers = content + .display_iter + .filter(|indexed| { + indexed + .cell + .flags + .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) + }) + .count(); + assert_eq!(spacers, 1, "wide char must occupy a spacer cell"); } } diff --git a/linux/src/workbench/view.rs b/linux/src/workbench/view.rs index 39cc2e398..b909f65d7 100644 --- a/linux/src/workbench/view.rs +++ b/linux/src/workbench/view.rs @@ -33,13 +33,13 @@ use crate::workbench::branch_manager::{BranchManagerEvent, BranchManagerView}; use crate::workbench::command_palette::{CommandPaletteEvent, CommandPaletteModal}; use crate::workbench::editor::{EditorTabEvent, EditorView}; use crate::workbench::extensions_panel::{ExtensionsEvent, ExtensionsView}; +use crate::workbench::global_search_panel::{GlobalSearchEvent, GlobalSearchPanel}; use crate::workbench::go_to_line::{GoToLineEvent, GoToLineModal}; use crate::workbench::maven::{MavenEvent, MavenView}; use crate::workbench::notifications::{NotificationsEvent, NotificationsView}; use crate::workbench::panes::{PaneId, PaneNode, PaneTree, SplitDir}; use crate::workbench::project_dialog::{ProjectDialog, ProjectDialogEvent, ProjectDialogMode}; use crate::workbench::quick_open::{QuickOpenEvent, QuickOpenModal}; -use crate::workbench::global_search_panel::{GlobalSearchEvent, GlobalSearchPanel}; use crate::workbench::search_everywhere::{SearchEverywhereEvent, SearchEverywhereModal}; use crate::workbench::settings_dialog::{SettingsCategory, SettingsDialog, SettingsEvent}; use crate::workbench::sidebar::{FileEntry, SidebarEvent, SidebarTab, SidebarView}; @@ -2428,21 +2428,16 @@ impl WorkbenchView { self.append_log("[View] Font size reset to 14 px", cx); cx.notify(); } - // ---- Terminal:新建会话 / 关闭面板(只关不开) ---- + // ---- Terminal:新建会话(真实重启 PTY)/ 关闭会话(终止子进程后折叠) ---- "terminal.new" => { - let _ = self.bottom_panel.update(cx, |bp, cx| { - bp.set_tab(BottomTab::Terminal, cx); - let _ = bp.terminal.update(cx, |term, cx| { - term.respawn(cx); - }); - }); + let _ = self + .bottom_panel + .update(cx, |bp, cx| bp.restart_terminal(cx)); } "terminal.close" => { - if !self.bottom_panel.read(cx).is_collapsed { - let _ = self.bottom_panel.update(cx, |bp, cx| { - bp.toggle_collapsed(cx); - }); - } + let _ = self + .bottom_panel + .update(cx, |bp, cx| bp.close_terminal_session(cx)); } // ---- Window:全屏(gpui-pre `Window::toggle_fullscreen`) ---- "window.fullscreen" => { @@ -2673,10 +2668,8 @@ impl Render for WorkbenchView { // 快捷键统一走菜单派生的按键表;命中后交给 `handle_action`, // 与菜单项、命令面板共用同一套动作分支。 - let stroke = crate::keybindings::KeyStrokeId::from_event( - key, - &event.keystroke.modifiers, - ); + let stroke = + crate::keybindings::KeyStrokeId::from_event(key, &event.keystroke.modifiers); let Some(action_id) = this.keymap.lookup(&stroke) else { return; }; @@ -2732,8 +2725,8 @@ impl Render for WorkbenchView { })) // 双击 Shift:GPUI 的修饰键变化事件包含按下/抬起两次, // 由识别器按时间窗判定,命中后打开全局搜索。 - .on_modifiers_changed( - cx.listener(|this, event: &ModifiersChangedEvent, _window, cx| { + .on_modifiers_changed(cx.listener( + |this, event: &ModifiersChangedEvent, _window, cx| { let timestamp = this.double_shift_clock(); let modifiers = event.modifiers; let has_other = modifiers.control || modifiers.alt || modifiers.platform; @@ -2744,8 +2737,8 @@ impl Render for WorkbenchView { ) { this.handle_action(crate::keybindings::DOUBLE_SHIFT_ACTION, cx); } - }), - ) + }, + )) .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| { if this.is_resizing_sidebar { let current_x = f32::from(event.position.x); From 6dded9dbc0f86af1d5d556eb524f74e1539935d3 Mon Sep 17 00:00:00 2001 From: chenjunjie Date: Sat, 26 Sep 2026 01:40:38 +0800 Subject: [PATCH 079/109] =?UTF-8?q?feat(linux):=20=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=20gpui=5Fxterm=20=E7=BB=84=E4=BB=B6=E5=B9=B6?= =?UTF-8?q?=E5=86=85=E5=B5=8C=20JetBrains=20Mono=20=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E5=AD=97=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 终端:把自绘 alacritty 网格换成复用 GPUI 终端控件 - vendored Modolet/gpui_xterm@4bdcdbb 到 third_party/gpui_xterm,仅改依赖指向 (gpui→gpui-pre、gpui-component 0.6.6、alacritty_terminal 0.26)。上游依赖 Zed git 版 gpui,与 gpui-kit 的包身份不兼容,无法直接依赖 crates.io 版本 - linux/src/workbench/terminal.rs 从约 3100 行降到约 1000 行:渲染、键鼠、选择、 滚动、剪贴板、IME、右键菜单交给组件;本仓库只保留 PTY 会话编排、主题与设置接线、 搜索浮层、程序化发送命令、Ctrl+L 清屏、回到底部、退出状态 - 会话终止继续复用 run/process.rs 的“先温和后强制、有界等待”契约;写端改为 SharedWriter,供组件与 send_command 共享同一 PTY - 修复单元格宽度:vendored 的 measure_cell 只按半角 ASCII 步进测量(上游取 M/W/█/▀/▄ 最大值,块元素会回退到全角字体,把整列撑宽约 1.7 倍) - 组件不提供的终端内搜索由宿主补齐(SearchInput + 上游 RegexSearch);Ctrl+F 经 组件按键钩子吞掉后由宿主容器打开搜索栏 - 笔记:旧引擎复用决策归档,新增 2026-09-26-linux-gpui-terminal-component-reuse 字体:内嵌 JetBrains Mono,并让设置弹窗的「编辑器字体」真正生效 - 新增 linux/src/fonts.rs:内嵌 4 个 JetBrains Mono TTF(含 OFL 许可证),启动时 经 text_system().add_fonts 注册;FAMILY 为出厂默认字体族 - UI 全局字体固定为 JetBrains Mono(Theme.font_family);编辑器、代码/日志、终端 的等宽字体跟随设置 fontFamily(Theme.mono_font_family,gpui-component 的 Editor 在 input/editor.rs 读取它) - 12 处硬编码 .font_family("monospace") 改为 fonts::mono_family(cx);终端字体随 设置变化即时 update_config - 设置下拉提交后立即 apply_theme + refresh_windows,无需重启即可换字体 - 迁移旧配置:fontFamily 为空或是旧默认 "Geist Mono" 时归一为 JetBrains Mono, 新增单测 font_family_migrates_legacy_defaults - 笔记:新增 2026-09-26-linux-bundled-jetbrains-mono-default-font 验证 - bash scripts/build-linux.sh 通过,产物 target/debug/lithe-linux - cargo test -p lithe-linux --lib:98 passed / 0 failed - node scripts/verify-agent-notes.mjs 通过 --- ...-09-25-linux-gpui-terminal-engine-reuse.md | 4 + ...-26-linux-gpui-terminal-component-reuse.md | 172 ++ ...nux-bundled-jetbrains-mono-default-font.md | 134 + linux/Cargo.lock | 100 + linux/Cargo.toml | 1 + linux/assets/fonts/JetBrainsMono-Bold.ttf | Bin 0 -> 274096 bytes .../assets/fonts/JetBrainsMono-BoldItalic.ttf | Bin 0 -> 279172 bytes linux/assets/fonts/JetBrainsMono-Italic.ttf | Bin 0 -> 276084 bytes linux/assets/fonts/JetBrainsMono-Regular.ttf | Bin 0 -> 270224 bytes linux/assets/fonts/OFL.txt | 93 + linux/src/fonts.rs | 62 + linux/src/lib.rs | 1 + linux/src/main.rs | 5 + linux/src/settings.rs | 28 +- linux/src/workbench/bottom_panel.rs | 12 +- linux/src/workbench/maven.rs | 6 +- linux/src/workbench/project_dialog.rs | 6 +- linux/src/workbench/settings_dialog.rs | 17 +- linux/src/workbench/terminal.rs | 2530 ++++------------- linux/src/workbench/view.rs | 2 + third_party/gpui_xterm/Cargo.toml | 35 + third_party/gpui_xterm/LICENSE | 21 + third_party/gpui_xterm/README.md | 38 + third_party/gpui_xterm/src/clipboard.rs | 87 + third_party/gpui_xterm/src/colors.rs | 472 +++ third_party/gpui_xterm/src/event.rs | 195 ++ third_party/gpui_xterm/src/input.rs | 296 ++ third_party/gpui_xterm/src/lib.rs | 18 + third_party/gpui_xterm/src/mouse.rs | 371 +++ third_party/gpui_xterm/src/render.rs | 753 +++++ third_party/gpui_xterm/src/terminal.rs | 303 ++ third_party/gpui_xterm/src/view.rs | 842 ++++++ 32 files changed, 4577 insertions(+), 2027 deletions(-) rename .agents/notes/{implemented => archived}/architecture/2026-09-25-linux-gpui-terminal-engine-reuse.md (98%) create mode 100644 .agents/notes/implemented/architecture/2026-09-26-linux-gpui-terminal-component-reuse.md create mode 100644 .agents/notes/implemented/feature/2026-09-26-linux-bundled-jetbrains-mono-default-font.md create mode 100644 linux/assets/fonts/JetBrainsMono-Bold.ttf create mode 100644 linux/assets/fonts/JetBrainsMono-BoldItalic.ttf create mode 100644 linux/assets/fonts/JetBrainsMono-Italic.ttf create mode 100644 linux/assets/fonts/JetBrainsMono-Regular.ttf create mode 100644 linux/assets/fonts/OFL.txt create mode 100644 linux/src/fonts.rs create mode 100644 third_party/gpui_xterm/Cargo.toml create mode 100644 third_party/gpui_xterm/LICENSE create mode 100644 third_party/gpui_xterm/README.md create mode 100644 third_party/gpui_xterm/src/clipboard.rs create mode 100644 third_party/gpui_xterm/src/colors.rs create mode 100644 third_party/gpui_xterm/src/event.rs create mode 100644 third_party/gpui_xterm/src/input.rs create mode 100644 third_party/gpui_xterm/src/lib.rs create mode 100644 third_party/gpui_xterm/src/mouse.rs create mode 100644 third_party/gpui_xterm/src/render.rs create mode 100644 third_party/gpui_xterm/src/terminal.rs create mode 100644 third_party/gpui_xterm/src/view.rs diff --git a/.agents/notes/implemented/architecture/2026-09-25-linux-gpui-terminal-engine-reuse.md b/.agents/notes/archived/architecture/2026-09-25-linux-gpui-terminal-engine-reuse.md similarity index 98% rename from .agents/notes/implemented/architecture/2026-09-25-linux-gpui-terminal-engine-reuse.md rename to .agents/notes/archived/architecture/2026-09-25-linux-gpui-terminal-engine-reuse.md index d2476347b..15153ea1c 100644 --- a/.agents/notes/implemented/architecture/2026-09-25-linux-gpui-terminal-engine-reuse.md +++ b/.agents/notes/archived/architecture/2026-09-25-linux-gpui-terminal-engine-reuse.md @@ -2,6 +2,10 @@ 状态:已实现 +归档日期:2026-09-26 + +被 `.agents/notes/implemented/architecture/2026-09-26-linux-gpui-terminal-component-reuse.md` 取代。 + ## 先说结论 Linux 原生 GPUI 版终端不自己实现终端语义,只复用成熟上游 `alacritty_terminal` diff --git a/.agents/notes/implemented/architecture/2026-09-26-linux-gpui-terminal-component-reuse.md b/.agents/notes/implemented/architecture/2026-09-26-linux-gpui-terminal-component-reuse.md new file mode 100644 index 000000000..ec096fc2f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-26-linux-gpui-terminal-component-reuse.md @@ -0,0 +1,172 @@ +# Agent 笔记:Linux GPUI 终端改为复用 gpui_xterm 组件 + +状态:已实现 + +## 先说结论 + +Linux 原生 GPUI 版终端不再自己把 `alacritty_terminal` 的网格投影成 GPUI 元素, +改为整体复用成熟的 GPUI 终端控件 `gpui_xterm`(上游 Modolet/gpui_xterm), +以 vendored 副本放在 `third_party/gpui_xterm/`。开发者要记住三件事: +**终端渲染、键鼠输入、选择与剪贴板归组件负责,不要在本仓库重写**; +**`linux/src/workbench/terminal.rs` 只保留 PTY 会话编排、工程配置接线和组件 +没有提供的产品能力(搜索、程序化发送命令、清屏、退出状态)**; +**PTY 子进程的终止与回收继续复用 `run/process.rs` 的“先温和后强制、有界等待” +契约,不另写一套信号逻辑**。 + +## 问题 + +上一版实现了“只复用 `alacritty_terminal` 引擎、自己渲染”的方案:解析、网格、 +回滚、选择、搜索都用上游,但渲染投影、字符单元格排版、鼠标命中换算、键位映射、 +鼠标上报、光标形状全部要本仓库维护。该方案当时的前提是“GPUI 生态不存在可复用 +的成熟终端控件”(见已归档笔记 +`.agents/notes/archived/architecture/2026-09-25-linux-gpui-terminal-engine-reuse.md` +的备选方案一)。 + +这个前提后来不成立了:`gpui_xterm` 已经是一个可嵌入 GPUI 应用的完整终端控件, +自带基于 `alacritty_terminal` 的网格渲染、键盘输入、鼠标选择、滚动、剪贴板与 +事件回调。继续自绘意味着长期维护一套与上游能力重叠、缺陷面更宽的渲染与输入 +代码,且每次 `alacritty_terminal` 升级都要重新对齐投影逻辑。 + +## 决策 + +### 复用边界 + +- `gpui_xterm` 是 Linux 终端的唯一 UI 控件:渲染、键盘输入、鼠标选择、滚动、 + 剪贴板、IME、右键上下文菜单都由它提供。 +- `linux/src/workbench/terminal.rs` 只做三件事: + 1. 用 `portable-pty` 起 PTY 会话,把输出端与共享写端交给组件; + 2. 把工作目录、平台 shell、字体/回滚设置与主题色映射成 + `gpui_xterm::TerminalConfig`,把组件上报的行列数同步给 PTY; + 3. 在组件之上补它没有的产品能力:终端内搜索、程序化发送命令、清屏、 + 回到底部、退出状态展示。 +- 终端语义仍全部来自 `alacritty_terminal`(由组件内部使用)。需要读取引擎 + 信息(例如显示偏移、设置搜索高亮)时,只调用组件暴露的 `TerminalView::state()`, + 不在本仓库重新解析 ANSI 或重建网格。 + +### 依赖适配:必须 vendored + +上游 `gpui_xterm` 依赖 Zed git 仓库的 `gpui` 与 longbridge git 仓库的 +`gpui-component`;Lithe 通过 `gpui-kit` 使用 crates.io 的 `gpui-pre` / +`gpui-component` 包族。Cargo 把 git 依赖与 registry 依赖视为不同的包身份, +即使源码同源也无法类型互通,因此**不能**直接 `gpui_xterm = "0.1.1"`。 + +因此采用 `third_party/gpui_xterm/` 的 vendored 副本:保留上游源码,只改 +`Cargo.toml` 的依赖指向(`gpui = { package = "gpui-pre" }`、`gpui-component` +crates.io 版、`alacritty_terminal` 与产品一致的 0.26),并记录上游 revision +`4bdcdbbeab4211f7938f31919819b06d755a1733`。本地补丁只做必要扩展:暴露引擎 +状态(搜索/滚动/显示偏移)、让 `scrollback` 生效,以及把单元格宽度改为按半角 +ASCII 步进测量(上游取 `M/W/█/▀/▄` 最大值,块元素会回退到全角字体把整列撑宽)。 +完整清单见 `third_party/gpui_xterm/README.md`;升级上游时按该清单重新应用补丁。 + +### 会话层:终止与回收只有一套契约 + +`TerminalSession` 拥有主端句柄、共享写端与子进程等待线程,行为与旧实现一致: +先断 writer(`portable-pty` 会写入换行 + EOT,交互 shell 自行退出),再关主端 +(slave 立刻收到 hangup),然后 `terminate_process_group` 先发 TERM、超时升级 +KILL,最后兜底 `ChildKiller::kill` 并 join 等待线程。每一步都是有界等待,UI +线程不参与等待:会话句柄的 `Drop` 交给一次性后台线程执行。 + +生命周期用纯状态机 `SessionState`(`Closed`/`Running`/`Stopping`/`Exited`) +表达,退出码与信号由等待线程 `wait()` 之后回传。视图只把退出结果显示在窗格 +状态区,不再往网格里写提示行(组件拥有网格,宿主不应注入内容)。 + +### 写端共享,程序化能力留在宿主 + +组件构造时需要拿走一个 `Write` 句柄;宿主又需要 `send_command` 能力。解决方式是 +`SharedWriter`:会话与组件共享同一份底层 writer,双方写入同一 PTY。程序化清屏 +通过向 shell 发送 Ctrl+L(`0x0c`)完成,让 shell 自己重画,而不是去改组件网格。 + +### 搜索 + +组件不提供终端内搜索,这是宿主补的能力,语义与旧实现一致:搜索栏复用工作台 +唯一的 `search_input::SearchInput`,输入变化后用上游 `RegexSearch` 从网格最旧 +一行顺序收集命中,上限 `SEARCH_MATCH_CAP`(超限时计数带 `+`),再按下标做 +上一个/下一个环绕。注意上游 `search_next` 在“起点之后没有命中”时会回退返回 +第一个命中,所以必须自己判断是否已经环绕。命中高亮复用上游 `Selection` 并配合 +`Term::scroll_to_point`。搜索栏做成浮层,打开/关闭不会触发终端 resize 与全屏 +TUI 重排。Ctrl+F 由宿主的按键钩子(`with_key_handler`)从组件手里吞掉,再由宿主 +容器打开搜索栏,避免组件把 `^F` 写进 PTY 后工作台再收到同一个按键。 + +### 正确做法 + +- 需要滚动、选择、复制、随窗缩放时,用组件自身行为或 `TerminalView::state()` + 暴露的上游 API,不要在本仓库重写终端语义。 +- 需要搜索时用 `term::search::RegexSearch` + `Term::search_next`,不要自己写 + 匹配循环(与旧实现相同)。 +- 关闭会话、切换工作目录时走 `TerminalSession` 的既有契约;不要在 UI 线程上 + 阻塞等待子进程。 +- 组件上报行列数时(resize 回调)必须把 `cols/rows` 同步给 PTY,否则全屏 TUI + 会拿到错误窗口大小。 + +### 不要这样做 + +- 不要在 `terminal.rs` 里手写 ANSI 解析、网格、光标、键位映射或鼠标上报编码。 +- 不要为了“加个功能”把终端语义下沉到 `bottom_panel.rs` 或 `view.rs`;宿主只 + 负责切换标签、转发命令和生命周期。 +- 不要为终端另写一套搜索输入框,也不要另存一份 ANSI 调色板。 +- 不要跳过 vendored 副本直接依赖 crates.io 的 `gpui_xterm`,那会引入第二套 + GPUI 包身份并在编译期类型撕裂。 + +## 考虑过的备选方案 + +### 备选方案一:直接依赖 crates.io 的 `gpui_xterm 0.1.1` + +依赖最省事。但它依赖 crates.io 的 `gpui 0.2.2` 与 `gpui-component 0.5.1`, +而 Lithe 用的是 `gpui-pre 0.3.6` 与 `gpui-component 0.6.6`。这些包虽然都源自 +Zed 的 GPUI,却是不同的包身份,`Entity`/`Render` 等类型不能互换,编译期即失败, +因此不可行。 + +### 备选方案二:用 Cargo `[patch]` 把 git `gpui` 重定向到 `gpui-pre` + +`[patch]` 以包名匹配,无法把上游依赖的 `gpui` 包替换成包名不同的 `gpui-pre`, +也无法在不改上游清单的前提下统一 `gpui-component` 版本,因此不可行。 + +### 备选方案三:继续维护自绘实现(旧决策) + +维护成本高、缺陷面宽,且与上游能力重叠;每升级一次 `alacritty_terminal` 都要 +重新对齐渲染与输入。已被本决策取代。 + +### 备选方案四:把搜索/鼠标上报等能力也做成 vendored 补丁塞进组件 + +会让 vendored 副本偏离上游越来越远,升级成本上升。选择只保留“读取引擎状态” +一个最小接线补丁,其余能力留在宿主,边界清晰。 + +## 后果 + +- 收益:渲染、输入、选择、滚动、剪贴板、IME、右键菜单由成熟组件保证,本仓库 + 代码量大幅下降(`terminal.rs` 从约 3100 行降到约 700 行),缺陷面收敛到 + “会话编排与工程接线”。 +- 收益:终端配色、字号、回滚缓冲集中在一处映射,且随 `alacritty_terminal` + 升级只需同步 vendored 副本。 +- 代价:多了一份 vendored 上游代码,需要在升级时重新应用补丁。 +- 代价(已知能力差异):组件不提供终端内搜索、向应用上报鼠标事件 + (SGR/X10,影响 vim/htop 鼠标交互)、光标闪烁与“退出提示写进网格”。搜索由 + 宿主补齐;其余属于组件能力边界,若产品需要应在组件内实现或向上游补齐,不要 + 在本仓库重写渲染与输入。 +- 未覆盖:多标签、横向分屏、超链接点击、图形协议(Sixel/Kitty)仍未接入。 + +## 验证 + +- `cargo test --manifest-path linux/Cargo.toml -p lithe-linux --lib` 覆盖纯逻辑 + 与引擎联通性:会话生命周期状态机、PTY 环境清理、搜索命中序号环绕与计数文案、 + 跨回滚缓冲的搜索、主题色浮点转 8 位。 +- `cargo build --manifest-path linux/Cargo.toml -p lithe-linux` 通过。 +- `./scripts/verify-service-boundaries.sh` 通过(宿主边界未变化)。 +- `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh` 通过。 +- `./scripts/verify-agent-notes.sh` 通过。 + +## 适用范围 + +- `third_party/gpui_xterm/`:vendored 上游组件与其依赖适配、本地补丁清单。 +- `linux/src/workbench/terminal.rs`:Linux 终端的会话编排、工程接线、 + 搜索与宿主能力。 +- `linux/src/workbench/run/process.rs`:`terminate_process_group` 同时服务 + Run/Maven 受管进程与集成终端。 +- `linux/src/workbench/bottom_panel.rs`:终端窗格状态区、头部动作 + (重启/查找/回到底部/清空/关闭)与工作目录转发。 +- `linux/src/workbench/search_input.rs`:终端内搜索复用的唯一输入实现。 +- `linux/src/theme.rs`:`terminal_*` token 是终端配色的唯一来源。 +- `linux/src/settings.rs`:终端字号与回滚设置的来源。 +- 不适用于 Windows 产品;Windows 终端由 + `windows/tauri/src/features/terminal/`(xterm.js)与 + `windows/tauri/crates/terminal/`(`portable-pty`)承担。 diff --git a/.agents/notes/implemented/feature/2026-09-26-linux-bundled-jetbrains-mono-default-font.md b/.agents/notes/implemented/feature/2026-09-26-linux-bundled-jetbrains-mono-default-font.md new file mode 100644 index 000000000..02105bf9f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-26-linux-bundled-jetbrains-mono-default-font.md @@ -0,0 +1,134 @@ +# Agent 笔记:Linux 内嵌 JetBrains Mono 作为全局默认字体 + +状态:已实现 + +## 先说结论 + +Linux 工作台的全局默认字体改为 **JetBrains Mono**,并且字体文件**内嵌在二进制里** +(`linux/assets/fonts/` 下的 4 个 TTF 经 `include_bytes!` 打进 `linux/src/fonts.rs`), +不依赖用户系统是否安装。同时把设置弹窗的「编辑器字体」接成**真正生效**的等宽字体 +来源:**UI 全局字体固定为内嵌 `FAMILY`,编辑器、代码/日志显示与终端都跟随设置里的 +`fontFamily`(出厂默认也是 JetBrains Mono)**。开发者要记住四件事:**新增等宽文字 +用 `crate::fonts::mono_family(cx)` 或 `Theme.mono_font_family`,不要硬编码字体名或 +写 `"monospace"`**;**要改出厂默认字体族只改 `linux/src/fonts.rs` 的 `FAMILY`**; +**每次 `Theme::change` 之后都要调用 `fonts::apply_theme`**,否则主题切换会把字体 +字段重置回系统默认;**不要为了让字体生效去要求用户手动装字体或改系统 fontconfig**。 + +## 问题 + +工作台此前没有任何内嵌字体,UI 主题字体是 gpui-component 的默认值 +(Linux 上落到 `CosmicTextSystem` 的默认族),编辑器默认字体族设置写的是 +`"Geist Mono"`,而终端写死 `"monospace"`。结果是: + +- 三处字体名各写各的,字体行为随机器是否装了对应字体而漂移; +- 缺字体时 GPUI 会走回退栈,字形与预期不一致,用户看到的默认字体因机器而异; +- 终端默认字体族与 UI/编辑器不统一。 + +产品需要一个跨机器一致的默认字体。 + +## 决策 + +### 内嵌而不是依赖系统安装 + +`linux/assets/fonts/` 放置 `JetBrainsMono-{Regular,Bold,Italic,BoldItalic}.ttf` +与 `OFL.txt`,`linux/src/fonts.rs` 用 `include_bytes!` 内嵌,启动时经 +`cx.text_system().add_fonts(...)` 注册进 GPUI 文本系统(Linux 走 +`CosmicTextSystem`,`add_fonts` 会把字节载入 cosmic-text 的 fontdb)。这样: + +- 任意机器上字体都存在,不需要用户 `fc-cache` 或装系统字体; +- 四个字重/样式覆盖 UI、代码高亮(粗体)与终端斜体,避免 GPUI 合成字形。 + +字体以 SIL Open Font License 1.1 分发,许可证与字体同目录保留。 + +### 单一来源 + 每次主题切换后重设 + +- `fonts::FAMILY` 是内嵌字体族名,也是**出厂默认**:`settings.rs` 的 + `font_family` 默认值取它,所以设置弹窗初始就显示 JetBrains Mono。 +- `fonts::mono_family(cx)` 返回**当前生效的等宽字体族**:读取设置 `fontFamily`, + 为空时回退到 `FAMILY`。编辑器、代码/日志显示与终端都经它取字体。 +- `fonts::apply_theme(cx)` 把 UI 字体设为 `FAMILY`、等宽字体设为 `mono_family(cx)`: + - `Theme.font_family`(UI 全局)固定 `FAMILY`; + - `Theme.mono_font_family`(gpui-component 的 `Editor` 在 + `input/editor.rs` 就读取它)跟随设置。 +- **必须在每次 `Theme::change(...)` 之后调用**:`change` 会按当前主题重新 + `apply_config`,可能覆盖字体字段。 +- 字体的注册(`fonts::register`)只做一次,且要早于窗口创建,保证首帧文本布局 + 就能命中新字体。 + +### 设置项「编辑器字体」必须真正生效 + +- 下拉写入 `settings.font_family` 后,回调里立刻 `fonts::apply_theme(cx)` 并 + `cx.refresh_windows()`,让编辑器/代码/终端在使用中即时换字体,无需重启。 +- 旧配置里遗留的旧默认值 `"Geist Mono"`(只是历史默认、并非用户真实选择)在 + `settings::load` 时迁移为 `FAMILY`;空值同样回退 `FAMILY`。归一化函数是 + `migrated_font_family`,有单测覆盖。 +- 下拉选项里已去掉未随产品分发的 `"Geist Mono"`,避免用户选到一个不存在的字体。 + +### 正确做法 + +- 新增需要等宽文字(代码、日志、路径、终端)的地方,用 + `crate::fonts::mono_family(cx)`,不要新写 `"monospace"` 或字体名字面量。 +- 新增普通 UI 文字不需要设字体族,继承 `Theme.font_family` 即可。 +- 主题切换路径(`main.rs` 与 `workbench/view.rs`)在 `Theme::change` 后调用 + `crate::fonts::apply_theme(cx)`。 +- 改设置里的字体后要 `apply_theme` + `refresh_windows`,保证即时生效。 +- 换字体时替换 `linux/assets/fonts/` 的文件并更新 `FAMILY`,同步更新许可证。 + +### 不要这样做 + +- 不要在业务代码里散落 `"JetBrains Mono"` / `"monospace"` 字面量。 +- 不要把字体注册放在窗口创建之后,也不要在 `Render` 里重复注册。 +- 不要只改 `settings.font_family` 而不 `apply_theme`:那样设置存了但不生效。 +- 不要依赖系统 fontconfig 或让用户手动装字体来“修好”默认字体。 + +## 考虑过的备选方案 + +### 备选方案一:只把默认字体名改成 JetBrains Mono,要求用户自己安装 + +改动最小,但默认字体是否生效取决于机器,和“跨机器一致”的目标冲突;缺字体时 +静默回退,问题难排查。不采用。 + +### 备选方案二:把字体装到 `~/.local/share/fonts` + +只解决开发机,不进仓库、不随产品分发,CI/其他机器仍会回退。不采用。 + +### 备选方案三:运行时从网络下载字体 + +引入构建/运行期网络依赖与校验负担,且离线或代理不可用时字体会缺失。忽略。 + +## 后果 + +- 收益:UI、编辑器、代码显示与终端跨机器一致,不再受系统字体安装影响;缺字体 + 导致的回退与排查成本消失。 +- 收益:设置弹窗的「编辑器字体」从“存了不生效”变为真正生效,且能即时切换。 +- 收益:字体名来源收敛为「内嵌 `FAMILY` 常量 + 设置项」两处,语义清晰。 +- 代价:二进制体积增加约 1.1 MB(4 个 TTF 内嵌)。 +- 代价:仓库新增二进制字体资源,需要在升级字体时同步更新 `OFL.txt`。 +- 注意:终端单元格宽度由 `gpui_xterm` 的 `measure_cell` 按半角 ASCII 步进测量 + (见 Linux 终端组件笔记)。用户若把编辑器字体换成非等宽字体,终端网格会退化, + 这是用户自选字体的预期后果。 + +## 验证 + +- `bash scripts/build-linux.sh` 通过,产物 `target/debug/lithe-linux` 内可检索到 + `JetBrains Mono` 字符串与注册失败告警文案,证明字体已内嵌。 +- `cargo test --manifest-path linux/Cargo.toml -p lithe-linux --lib` 通过, + 含 `settings::tests::font_family_migrates_legacy_defaults`(空值/旧默认迁移、 + 用户自选保留)。 +- 运行应用后 UI、编辑器、代码显示与终端默认字体均为 JetBrains Mono;在设置弹窗把 + 「编辑器字体」改为别的等宽字体,编辑器与终端即时跟随,重启后仍保持。 +- `./scripts/verify-agent-notes.sh` 通过。 + +## 适用范围 + +- `linux/src/fonts.rs`:字体内嵌、注册、主题字体族设置与等宽字体族查询的唯一入口。 +- `linux/assets/fonts/`:内嵌字体文件与 OFL 许可证。 +- `linux/src/main.rs`:启动时注册字体并在首次主题设置后应用。 +- `linux/src/workbench/view.rs`:主题切换后重新应用字体族。 +- `linux/src/settings.rs`:编辑器字体默认值与旧默认值迁移(`migrated_font_family`)。 +- `linux/src/workbench/settings_dialog.rs`:字体下拉项、选中后即时应用。 +- `linux/src/workbench/terminal.rs`:终端 `TerminalConfig.font_family` 跟随等宽字体, + 字体变化时 `update_config` 即时生效。 +- `linux/src/workbench/bottom_panel.rs`、`linux/src/workbench/maven.rs`、 + `linux/src/workbench/project_dialog.rs`:代码/日志文本改用 `fonts::mono_family(cx)`。 +- 不适用于 macOS/Windows 产品;两端的字体分别由各自平台的呈现层管理。 diff --git a/linux/Cargo.lock b/linux/Cargo.lock index 18b819753..c1818222b 100644 --- a/linux/Cargo.lock +++ b/linux/Cargo.lock @@ -233,6 +233,27 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "arc-swap" version = "1.9.2" @@ -1001,6 +1022,15 @@ dependencies = [ "libloading", ] +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cocoa" version = "0.25.0" @@ -1829,6 +1859,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "etagere" version = "0.2.15" @@ -3060,6 +3096,19 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1bb25850ad31b88bbdef454afb93ec4cdb81f5e200ba5b3f1a0368111780d2c" +[[package]] +name = "gpui_xterm" +version = "0.1.1" +dependencies = [ + "alacritty_terminal", + "anyhow", + "arboard", + "flume", + "gpui-component", + "gpui-pre", + "parking_lot", +] + [[package]] name = "granit-parser" version = "1.3.0" @@ -3960,6 +4009,7 @@ dependencies = [ "alacritty_terminal", "anyhow", "gpui-kit", + "gpui_xterm", "libc", "lithe-core", "portable-pty", @@ -5096,6 +5146,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "parking" version = "2.2.1" @@ -5181,6 +5241,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.11.3" @@ -7858,6 +7929,17 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + [[package]] name = "triomphe" version = "0.1.16" @@ -9339,6 +9421,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix", + "thiserror 2.0.20", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/linux/Cargo.toml b/linux/Cargo.toml index b988a5404..b5a8f192d 100644 --- a/linux/Cargo.toml +++ b/linux/Cargo.toml @@ -19,6 +19,7 @@ gpui-kit = { version = "0.6", features = [ "tree-sitter-yaml", ] } lithe-core = { path = "../rust/lithe-core" } +gpui_xterm = { path = "../third_party/gpui_xterm" } portable-pty = "0.9" alacritty_terminal = "0.26" serde = { version = "1", features = ["derive"] } diff --git a/linux/assets/fonts/JetBrainsMono-Bold.ttf b/linux/assets/fonts/JetBrainsMono-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..cd1bee0704e05a899cd70f0640a801f1afe046b2 GIT binary patch literal 274096 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