diff --git a/.gitignore b/.gitignore index 1d39df4..8b535ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,30 +1,35 @@ -target - -# Local debug/release build bundles produced by packaging scripts -dist/ -trellis/ -# `package-release.ps1 -TargetDirectory` builds into its own cargo target dir -# so packaging never invalidates the incremental cache of the main one. -target-pack/ - -# Local/generated visual assets and scratch files. -docs/ui_mockups/ -tmp/ -# Window/UI probe captures (probe_*.png) and prototype pages (.tmp-*.html): -# scratch evidence for a single design round, never part of the product. -probe_*.png -.tmp-*.html - -# Optional local ConPTY runtime bundle; the app falls back when absent. -assets/windows/conhost/ - -# Internal agent/session artifacts (review drafts, scratch notes — never publish) -.claude/ -.tocodex/ -tocodex-docs/ -.codex/ -*.internal.md - +target + +# Local debug/release build bundles produced by packaging scripts +dist/ +trellis/ +# `package-release.ps1 -TargetDirectory` builds into its own cargo target dir +# so packaging never invalidates the incremental cache of the main one. +target-pack/ + +# Local/generated visual assets and scratch files. +docs/ui_mockups/ +tmp/ +# Window/UI probe captures (probe_*.png) and prototype pages (.tmp-*.html): +# scratch evidence for a single design round, never part of the product. +probe_*.png +.tmp-*.html +# 所有 .tmp-/-tmp_ 前缀的草稿(探针 cfg 目录、截图、stdout 抓取、临时 +# 笔记)与备用 cargo target 目录:一次性调试产物,永不入库。 +.tmp-* +.tmp_* +.target-*/ + +# Optional local ConPTY runtime bundle; the app falls back when absent. +assets/windows/conhost/ + +# Internal agent/session artifacts (review drafts, scratch notes — never publish) +.claude/ +.tocodex/ +tocodex-docs/ +.codex/ +*.internal.md + # `docs/` is private by default. Public documentation must be reviewed and # explicitly added to this file one path at a time; never allowlist another subtree. /docs/** @@ -38,63 +43,76 @@ tocodex-docs/ # Local interactive demos belong under the private docs tree. /*.html /TASKS.md - -# Secrets / credentials / environment — must never be committed -.env -.env.* -*.pem -*.key -*.pfx -*.p12 -*.keystore -id_rsa* -*.credentials -secrets.* -*_secret* -*.token - -# Editor / IDE local settings -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# OS-generated junk -.DS_Store -Thumbs.db -desktop.ini - -# Build/debug scratch: parked-exe copies (Windows locked-file workaround), -# logs, crash dumps, profiling output -*.locked -*.log -*.tmp -*.orig -*.rej -*.stackdump -*.pdb.old - -# Scratch notes and locally downloaded font archives -body.txt -JetBrainsMono-*.zip - -# Local scratch checkouts, prototypes, and one-off repro scripts -.build-*/ -target-aligned/ -target-fix/ -_nebula-front-stage/ -message-queue-prototype.html -package.json -package-lock.json -node_modules/ -scripts/drawer_repro.ps1 -scripts/ghost_repro/ -scripts/icon-build/ + +# Secrets / credentials / environment — must never be committed +.env +.env.* +*.pem +*.key +*.pfx +*.p12 +*.keystore +id_rsa* +*.credentials +secrets.* +*_secret* +*.token + +# Editor / IDE local settings +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS-generated junk +.DS_Store +Thumbs.db +desktop.ini + +# Build/debug scratch: parked-exe copies (Windows locked-file workaround), +# logs, crash dumps, profiling output +*.locked +*.log +*.tmp +*.orig +*.rej +*.stackdump +*.pdb.old + +# Scratch notes and locally downloaded font archives +body.txt +JetBrainsMono-*.zip + +# Local scratch checkouts, prototypes, and one-off repro scripts +.build-*/ +target-aligned/ +target-fix/ +_nebula-front-stage/ +message-queue-prototype.html +package.json +package-lock.json +node_modules/ +scripts/drawer_repro.ps1 +scripts/ghost_repro/ +scripts/icon-build/ scripts/render_shell_icon.html - -# 探针实例跑的是 debug 产物的副本(见 scripts/launch_probe_instance.ps1): -# 直接运行构建产物会让下一次 cargo build 以一个含义不明的 mingw 链接错误告终。 -.probe/ -.run/ - + +# 探针实例跑的是 debug 产物的副本(见 scripts/launch_probe_instance.ps1): +# 直接运行构建产物会让下一次 cargo build 以一个含义不明的 mingw 链接错误告终。 +.probe/ +.run/ + +# 调试取证与旁路产物:PTY 录制(NEBULA_PTY_RECORD)、工具链输出目录、 +# 参考用的外部项目工作拷贝。都不是 Nebula 源码,永不入库。 +*.recording +output/ +impeccable/ + +# Windows 下 shell 重定向事故的常见落地物:bash 里写 `2>nul` 会真的建出 +# 名为 nul 的文件,`>false` 同理;PowerShell 在 HOME 未展开时把模块缓存 +# 写进 cwd。见到就删,这里兜底防止误提交。 +/nul +/false +/Microsoft/ + diff --git a/Cargo.lock b/Cargo.lock index 490cd43..55b894a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2319,6 +2319,18 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "1.9.0" @@ -2453,6 +2465,12 @@ 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.12.2" @@ -3090,7 +3108,6 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.1" -source = "git+https://github.com/Kuddev/gpui-component?rev=a59f704c497d6e6e628ec2d401466d825d4a51b2#a59f704c497d6e6e628ec2d401466d825d4a51b2" dependencies = [ "aho-corasick", "anyhow", @@ -3120,7 +3137,35 @@ dependencies = [ "smol", "tracing", "tree-sitter", + "tree-sitter-bash", + "tree-sitter-c", + "tree-sitter-c-sharp", + "tree-sitter-cmake", + "tree-sitter-cpp", + "tree-sitter-css", + "tree-sitter-diff", + "tree-sitter-elixir", + "tree-sitter-embedded-template", + "tree-sitter-go", + "tree-sitter-graphql", + "tree-sitter-html", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-jsdoc", "tree-sitter-json", + "tree-sitter-make", + "tree-sitter-md", + "tree-sitter-proto", + "tree-sitter-python", + "tree-sitter-ruby", + "tree-sitter-rust", + "tree-sitter-scala", + "tree-sitter-sequel", + "tree-sitter-swift", + "tree-sitter-toml-ng", + "tree-sitter-typescript", + "tree-sitter-yaml", + "tree-sitter-zig", "unicode-segmentation", "uuid", "zed-sum-tree", @@ -3129,7 +3174,6 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/Kuddev/gpui-component?rev=a59f704c497d6e6e628ec2d401466d825d4a51b2#a59f704c497d6e6e628ec2d401466d825d4a51b2" dependencies = [ "anyhow", "gpui", @@ -3139,7 +3183,6 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/Kuddev/gpui-component?rev=a59f704c497d6e6e628ec2d401466d825d4a51b2#a59f704c497d6e6e628ec2d401466d825d4a51b2" dependencies = [ "proc-macro2", "quote", @@ -3229,14 +3272,35 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" +dependencies = [ + "hashbrown 0.17.1", +] [[package]] name = "heck" @@ -3678,7 +3742,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.15.5", "serde", "serde_core", ] @@ -4055,6 +4119,17 @@ dependencies = [ "redox_syscall 0.5.18", ] +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -4603,12 +4678,14 @@ dependencies = [ "pulldown-latex", "regex", "rfd", + "rusqlite", "russh", "russh-sftp", "serde", "serde_json", "serde_path_to_error", "serde_yaml", + "sha1", "sha2 0.11.0", "signal-hook", "tauri-winrt-notification", @@ -6595,6 +6672,31 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags 2.13.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "russh" version = "0.62.2" @@ -7567,6 +7669,18 @@ dependencies = [ "der", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "ssh-cipher" version = "0.3.0" @@ -8444,6 +8558,156 @@ 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-c" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b2eb57a55fed6b00812912e730b7a275cf4fe98bfd6a5d76263d4438371728" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c-sharp" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1aac67f1ad71de1d6d39708d34811081c26dfa495658de6c14c34200849357c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-cmake" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "164e0c4f4236ec5ceff14824a5528615cf462e100467e49826442ff57d327061" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-cpp" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-css" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ad6489794d41350d12a7fbe520e5199f688618f43aace5443980d1ddcf1b29e" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-diff" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfe1e5ca280a65dfe5ba4205c1bcc84edf486464fed315db53dee6da9a335889" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-elixir" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66dd064a762ed95bfc29857fa3cb7403bb1e5cb88112de0f6341b7e47284ba40" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-embedded-template" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790063ef14e5b67556abc0b3be0ed863fb41d65ee791cf8c0b20eb42a1fa46af" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-graphql" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efedc4cac157161cc23a0adc4553a2cedc908e1cd754b6cd033a919bb81ce5d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-html" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee" +dependencies = [ + "cc", + "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-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-jsdoc" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3862dfcb1038fc5e7812d7df14190afdeb7e1415288fd5f51f58395f8cb0faf" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-json" version = "0.24.8" @@ -8460,6 +8724,136 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" +[[package]] +name = "tree-sitter-make" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5998dc7cbcbdab19fae8aefef982bf2d6544513d8d2e69cc44aec4c63810104" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[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-proto" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e4360b434b5980fc397137ef29e1988619fef4159ac86fa7ac5777d459d3924" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-python" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-ruby" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95" +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-scala" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efde5e68b4736e9eac17bfa296c6f104a26bffab363b365eb898c40a63c15d2f" +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-swift" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe36052155b9dd69ca82b3b8f1b4ccfb2d867125ac1a4db1dd7331829242668c" +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 = "tree-sitter-zig" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab11fc124851b0db4dd5e55983bbd9631192e93238389dcd44521715e5d53e28" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "triomphe" version = "0.1.16" @@ -8828,6 +9222,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" diff --git a/Cargo.toml b/Cargo.toml index 4dd63f0..b63b3e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,30 @@ winit = { path = "third_party/winit-0.30.13" } # 3. 新增的 text/inline_flow.rs:shape_line 前把 \n\r 换成空格(gpui # shaper 遇换行 panic;替换保 byte 长度,run 偏移不失效) # 本地 fork 工作拷贝在 ../gpui-component-fork(已挂 tty7 remote 可 diff)。 +# +# 上游行为备忘(不是补丁,是调用侧要知道的事):`Scrollable` +# (scroll/scrollable.rs 的 overflow_*_scrollbar)不是就地加滚动条,而是把 +# 被包装元素的 StyleRefinement 整个 clone 到它新建的外层 div,再把原元素 +# 样式清空、只补 flex_1,然后塞进一层 `overflow_y_scroll` 的 scroll-area。 +# 后果:真正承载 children 的那层退回 gpui 默认的 Display::Block 且宽度不 +# 确定,children 上的 `w_full`(width:100%)无从解析、回落到 max-content, +# 于是每个子块按自己的内容宽度收缩,看起来就是"分组宽度参差、控件既不贴 +# 左也不贴右"(2026-08-17 设置页实测 A/B 确认)。**调用侧约定:滚动区内 +# 先放一层 `v_flex().w_full()`,内容写在它里面**——竖向 flex 的子项走交叉 +# 轴 stretch 拿宽度,绕开百分比解析。settings_pane.rs 的设置正文即按此写。 +# +# 补丁清单(nebula 分支,rebase 新基点时逐条核对): +# 1. popover 单层紧凑阴影(a59f704c,见上)。 +# 2. TextView 数学公式钩子(本地 nebula-math 分支,待 push 后恢复 rev pin): +# markdown 解析启用 math 构造;新增 text/math.rs 的 set_math_renderer +# 全局注册点;InlineNode/Node 增加数学节点并在渲染时调钩子,未注册时 +# 回退现状(inline=code 文本、block=CodeBlock)。TeX 引擎/栅格化/绘制 +# 全部住在 nebula_app::gpui_shell::math_view,fork 只有接线层。上游 +# 0.6 线出现 MarkdownPlugin/Custom block 后应迁过去并撤掉本补丁。 +# +# ⚠ 临时状态:gpui-component 走本地 path patch(nebula-math 分支工作树), +# 便于数学补丁的本地迭代;验收后 push 到 Kuddev/gpui-component 并恢复 +# `git + rev` 精确 pin(gpui 不受影响,仍是 rev pin)。 gpui = { git = "https://github.com/Kuddev/zed", rev = "69e2130295c2649963eb639fc70b4f2ee8ea1624" } -gpui-component = { git = "https://github.com/Kuddev/gpui-component", rev = "a59f704c497d6e6e628ec2d401466d825d4a51b2" } -gpui-component-assets = { git = "https://github.com/Kuddev/gpui-component", rev = "a59f704c497d6e6e628ec2d401466d825d4a51b2" } +gpui-component = { path = "../gpui-component-fork/crates/ui" } +gpui-component-assets = { path = "../gpui-component-fork/crates/assets" } diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index f3dde64..5d9a3f9 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -35,15 +35,18 @@ optional = true [dependencies.gpui-component] version = "=0.5.1" optional = true +# 代码查看 tab 的 tree-sitter 语法高亮语言包(不开只有 JSON)。 +features = ["tree-sitter-languages"] [dependencies.gpui-component-assets] version = "=0.5.1" optional = true # 运行时用户设置(nebula_settings.txt + 主题终端色表)的共享权威实现。 +# 非可选:ai_hook 的 `ai_hooks` 持久开关(setup-ai --remove)在所有壳形态 +# 下都要读写这份设置。 [dependencies.nebula-settings] path = "../nebula_settings" -optional = true # 分屏规则的共享权威实现(布局树/拖拽比例/方向导航);GPUI 壳消费。 [dependencies.nebula-split] @@ -118,6 +121,8 @@ getrandom = "0.3.3" pollster = { version = "0.4", optional = true } wgpu = { version = "25.0.2", optional = true } regex = "1.13.1" +rusqlite = { version = "0.40.2", features = ["bundled"] } +sha1 = "0.11.0" [build-dependencies] gl_generator = "0.14.0" @@ -233,7 +238,6 @@ gpui-shell = [ "dep:gpui", "dep:gpui-component", "dep:gpui-component-assets", - "dep:nebula-settings", "dep:nebula-split", "dep:futures", ] diff --git a/nebula_app/src/ai_hook.rs b/nebula_app/src/ai_hook.rs index a208d38..7a08ede 100644 --- a/nebula_app/src/ai_hook.rs +++ b/nebula_app/src/ai_hook.rs @@ -482,6 +482,13 @@ mod win { /// Boot entrypoint: install now, then keep installed (see module docs). pub fn spawn_config_guard() { + // `setup-ai --remove` 落下的持久开关:用户明确断开过就不再自动 + // 装回(#38 的自愈复发面 / #8 卸载后仍在 hook)。重新启用走 + // `nebula setup-ai`。 + if hooks_disabled() { + log::info!("ai_hook: ai_hooks=0 (setup-ai --remove); auto-install disabled"); + return; + } if let Err(err) = std::thread::Builder::new().name("nebula-ai-setup".into()).spawn(config_guard) { @@ -489,6 +496,24 @@ mod win { } } + /// `nebula_settings.txt` 里 `ai_hooks=0`(由 `setup-ai --remove` 写入)。 + fn hooks_disabled() -> bool { + nebula_settings::RawSettings::load().bool_on("ai_hooks") == Some(false) + } + + /// 一轮完整自愈。每轮都重读开关:`setup-ai --remove` 可能发生在本进程 + /// 存活期间,它触发的 config 变更事件会立刻打回这里——不重读就会在 + /// 400ms 内把刚移除的接线原样装回(#38 实测的自愈复发路径)。 + fn heal_all() { + if hooks_disabled() { + return; + } + ensure_claude_hooks(); + ensure_codex_notify(); + ensure_opencode_plugin(); + ensure_pi_extension(); + } + fn config_guard() { use notify::{RecursiveMode, Watcher}; @@ -507,10 +532,7 @@ mod win { std::thread::sleep(Duration::from_secs(300)); }; - ensure_claude_hooks(); - ensure_codex_notify(); - ensure_opencode_plugin(); - ensure_pi_extension(); + heal_all(); let (tx, rx) = std::sync::mpsc::channel(); let mut watcher = match notify::recommended_watcher(move |res| { @@ -551,10 +573,7 @@ mod win { // Debounce the writer's burst, then heal. Our own atomic // rename lands here once and heals to a no-op. while rx.recv_timeout(Duration::from_millis(400)).is_ok() {} - ensure_claude_hooks(); - ensure_codex_notify(); - ensure_opencode_plugin(); - ensure_pi_extension(); + heal_all(); }, Err(_) => return, // channel closed: shutting down } @@ -565,10 +584,7 @@ mod win { fn poll_guard() -> ! { loop { std::thread::sleep(Duration::from_secs(300)); - ensure_claude_hooks(); - ensure_codex_notify(); - ensure_opencode_plugin(); - ensure_pi_extension(); + heal_all(); } } @@ -595,6 +611,53 @@ mod win { Some(PathBuf::from(std::env::var_os("USERPROFILE")?).join(".codex")) } + /// notify 序列化后的字节预算。正常接线只有几百字节;超过它的唯一已知 + /// 途径是与其他 notify 包装器互相包装的指数膨胀(#38,最终 130 MB 撑爆 + /// config.toml 让 codex 起不来)。宁可这一轮不接线,也不把病态值落盘。 + const NOTIFY_BYTE_BUDGET: usize = 8 * 1024; + + /// 由现有 notify argv 算出应写入的新 argv;`None` = 不动文件。 + /// + /// 与 codex-computer-use 的共存规则(#38):它重新注册时若认不出 + /// notify\[0\] 是自己,会把整个旧数组 JSON 序列化进自己的 + /// `--previous-notify` 参数。此时 nebula-hook 不在最外层,但仍在链中 + /// ——事件会沿链回流。若这时再包一层,两个包装器互相包装、转义反斜杠 + /// 每轮翻倍。所以:helper 标记出现在**任何位置**(含 JSON 字符串内部) + /// 都算已接线,只有最外层是自己时才做路径自愈。 + fn desired_codex_notify(current: &[String], helper: &str) -> Option> { + let desired: Vec = match current.first() { + // Already ours: heal the helper path, keep any chain tail as-is. + Some(first) if first.contains(HELPER_MARK) => { + let mut argv = current.to_vec(); + argv[0] = helper.to_owned(); + argv + }, + // 已在链中但不在最外层:保持现状,绝不再包(见上)。 + Some(_) if current.iter().any(|arg| arg.contains(HELPER_MARK)) => return None, + // Occupied: wrap the existing notifier behind --chain. + Some(_) => { + let mut argv = vec![helper.to_owned(), "codex".to_owned(), "--chain".to_owned()]; + argv.extend(current.iter().cloned()); + argv + }, + None => vec![helper.to_owned(), "codex".to_owned()], + }; + if current == desired { + return None; + } + // 长度兜底:对任何形态的膨胀(不止 #38 这一种循环)一律拒写。 + // +4 ≈ 每个元素的引号、逗号与空格开销。 + let bytes: usize = desired.iter().map(|arg| arg.len() + 4).sum(); + if bytes > NOTIFY_BYTE_BUDGET { + log::warn!( + "ai_hook: codex notify would serialize to {bytes} bytes (> {NOTIFY_BYTE_BUDGET}); \ + refusing to write (wrapper loop guard, #38)" + ); + return None; + } + Some(desired) + } + /// Wire codex's `notify` to nebula-hook. Codex has a SINGLE notify slot /// which may already be taken (e.g. OpenAI's own computer-use notifier), /// so an occupied slot is wrapped, not evicted: nebula-hook forwards to @@ -618,24 +681,9 @@ mod win { .map(|a| a.iter().filter_map(|i| i.as_str().map(str::to_owned)).collect()) .unwrap_or_default(); - let desired: Vec = match current.first() { - // Already ours: heal the helper path, keep any chain tail as-is. - Some(first) if first.contains(HELPER_MARK) => { - let mut argv = current.clone(); - argv[0] = helper; - argv - }, - // Occupied: wrap the existing notifier behind --chain. - Some(_) => { - let mut argv = vec![helper, "codex".to_owned(), "--chain".to_owned()]; - argv.extend(current.iter().cloned()); - argv - }, - None => vec![helper, "codex".to_owned()], - }; - if current == desired { + let Some(desired) = desired_codex_notify(¤t, &helper) else { return false; - } + }; let mut array = toml_edit::Array::new(); for arg in &desired { @@ -980,6 +1028,17 @@ export default function (pi: ExtensionAPI) { failed = true; }, } + // 持久开关:不写它,下次 Nebula 启动(含开机自启)会把上面 + // 刚清掉的四处原样装回——移除必须比自愈活得久(#8、#38)。 + match nebula_settings::persist_keys(&[("ai_hooks", "0".to_owned())]) { + Ok(()) => println!( + "已写入 ai_hooks=0:Nebula 启动时不再自动接线(重新启用:nebula setup-ai)。" + ), + Err(err) => { + eprintln!("警告:无法写入 ai_hooks=0({err}),下次启动仍会自动装回。"); + failed = true; + }, + } // 卸载器必须尽最大努力清理所有集成,不能因一个损坏的用户配置 // 提前返回而让其他 Hook 永久指向即将被删除的程序目录。 return i32::from(failed); @@ -991,6 +1050,11 @@ export default function (pi: ExtensionAPI) { return 1; }, } + // 显式安装即重新授权:清掉 --remove 落下的持久开关,守护线程下次 + // 启动恢复自愈。 + if let Err(err) = nebula_settings::persist_keys(&[("ai_hooks", "1".to_owned())]) { + eprintln!("警告:无法写入 ai_hooks=1({err});若之前执行过 --remove,自动接线仍是关闭状态。"); + } if dir.exists() { if ensure_claude_hooks() { println!("claude: 已写入 {}(首次改动备份 *.nebula-bak)。", path.display()); @@ -1213,6 +1277,78 @@ export default function (pi: ExtensionAPI) { std::fs::rename(&tmp, path) } + #[cfg(test)] + mod codex_notify_tests { + use super::desired_codex_notify; + + const HELPER: &str = "C:/Program Files/Nebula/runtime/nebula-hook.exe"; + + fn argv(args: &[&str]) -> Vec { + args.iter().map(|s| (*s).to_owned()).collect() + } + + #[test] + fn an_empty_slot_is_claimed_outright() { + assert_eq!( + desired_codex_notify(&[], HELPER), + Some(argv(&[HELPER, "codex"])) + ); + } + + #[test] + fn a_foreign_notifier_is_wrapped_behind_chain() { + let current = argv(&["C:/cua/codex-computer-use.exe", "turn-ended"]); + assert_eq!( + desired_codex_notify(¤t, HELPER), + Some(argv(&[ + HELPER, + "codex", + "--chain", + "C:/cua/codex-computer-use.exe", + "turn-ended", + ])) + ); + } + + #[test] + fn our_stale_helper_path_heals_and_keeps_the_chain_tail() { + let current = argv(&["D:/old/nebula-hook.exe", "codex", "--chain", "C:/cua/cua.exe"]); + assert_eq!( + desired_codex_notify(¤t, HELPER), + Some(argv(&[HELPER, "codex", "--chain", "C:/cua/cua.exe"])) + ); + } + + #[test] + fn an_up_to_date_wiring_is_left_alone() { + let current = argv(&[HELPER, "codex"]); + assert_eq!(desired_codex_notify(¤t, HELPER), None); + } + + // #38 的核心形态:codex-computer-use 重新注册时把我们的 chain JSON + // 编码进 --previous-notify。我们不在最外层,但已在链中——再包一层 + // 就进入互相包装、反斜杠每轮翻倍的指数爆炸。 + #[test] + fn a_notifier_that_swallowed_us_into_previous_notify_is_not_wrapped_again() { + let current = argv(&[ + "C:/cua/codex-computer-use.exe", + "--previous-notify", + r#"["C:\\Program Files\\Nebula\\runtime\\nebula-hook.exe", "codex", "--chain", "C:\\cua\\cua.exe", "turn-ended"]"#, + "turn-ended", + ]); + assert_eq!(desired_codex_notify(¤t, HELPER), None); + } + + // 兜底:即使标记检测失手(比如未来某个包装器改了我们的文件名), + // 病态膨胀也会被字节预算拦住,config.toml 不会被写到 codex 起不来。 + #[test] + fn an_oversized_result_is_refused() { + let ballooned = "\\".repeat(64 * 1024); + let current = argv(&["C:/cua/codex-computer-use.exe", &ballooned]); + assert_eq!(desired_codex_notify(¤t, HELPER), None); + } + } + #[cfg(test)] mod runtime_asset_tests { use super::helper_path_from_exe; diff --git a/nebula_app/src/display/command_palette.rs b/nebula_app/src/display/command_palette.rs index 4aff259..98c2798 100644 --- a/nebula_app/src/display/command_palette.rs +++ b/nebula_app/src/display/command_palette.rs @@ -112,7 +112,7 @@ impl LauncherGroup { } impl LauncherFilter { - fn label(self, language: super::UiLanguage) -> &'static str { + pub(crate) fn label(self, language: super::UiLanguage) -> &'static str { match self { Self::All => language.pick("全部", "All"), Self::Ssh => "SSH", diff --git a/nebula_app/src/display/content.rs b/nebula_app/src/display/content.rs index 6d7a3c2..a8d0c89 100644 --- a/nebula_app/src/display/content.rs +++ b/nebula_app/src/display/content.rs @@ -205,7 +205,7 @@ fn cursor_follows_theme(color: InvertedCellColors) -> bool { color == NEBULA_DEFAULT_CURSOR } -fn themed_cursor_style( +pub(crate) fn themed_cursor_style( shape: CursorShape, theme_is_light: bool, theme_anchor: Rgb, @@ -553,7 +553,16 @@ fn is_terminal_graphic(character: char) -> bool { ) } -fn is_application_cursor_cell( +pub(crate) fn is_application_cursor_glyph( + character: char, + flags: Flags, + fixed_background: bool, +) -> bool { + matches!(character, '\u{2580}'..='\u{259f}') + || (character == ' ' && (flags.contains(Flags::INVERSE) || fixed_background)) +} + +pub(crate) fn is_application_cursor_cell( terminal_cursor_shape: CursorShape, terminal_cursor_point: Point, cell_point: Point, @@ -563,14 +572,20 @@ fn is_application_cursor_cell( ) -> bool { terminal_cursor_shape == CursorShape::Hidden && terminal_cursor_point == cell_point - && (matches!(character, '\u{2580}'..='\u{259f}') - || (character == ' ' && (flags.contains(Flags::INVERSE) || fixed_background))) + && is_application_cursor_glyph(character, flags, fixed_background) +} + +pub(crate) fn cell_background_is_fixed( + color: Color, + overrides: &nebula_terminal::term::color::Colors, +) -> bool { + is_fixed_color(color, overrides) } /// Alpha-compose a theme overlay over the cell's existing background. Keeping /// this in the render model preserves explicit ANSI cell backgrounds while /// still allowing the default transparent terminal surface/image to show. -fn composite_overlay(overlay: Rgb, overlay_alpha: f32, base: Rgb, base_alpha: f32) -> (Rgb, f32) { +pub(crate) fn composite_overlay(overlay: Rgb, overlay_alpha: f32, base: Rgb, base_alpha: f32) -> (Rgb, f32) { let oa = overlay_alpha.clamp(0.0, 1.0); let ba = base_alpha.clamp(0.0, 1.0); let out_a = oa + ba * (1.0 - oa); @@ -589,7 +604,7 @@ fn composite_overlay(overlay: Rgb, overlay_alpha: f32, base: Rgb, base_alpha: f3 ) } -fn mix_rgb(color: Rgb, neutral: Rgb, neutral_amount: f32) -> Rgb { +pub(crate) fn mix_rgb(color: Rgb, neutral: Rgb, neutral_amount: f32) -> Rgb { let t = neutral_amount.clamp(0.0, 1.0); let channel = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t).round() as u8; Rgb::new(channel(color.r, neutral.r), channel(color.g, neutral.g), channel(color.b, neutral.b)) @@ -653,8 +668,8 @@ mod tests { use nebula_terminal::vte::ansi::CursorShape; use super::{ - composite_overlay, cursor_follows_theme, is_application_cursor_cell, is_terminal_graphic, - mix_rgb, themed_cursor_style, + composite_overlay, cursor_follows_theme, is_application_cursor_cell, + is_application_cursor_glyph, is_terminal_graphic, mix_rgb, themed_cursor_style, }; use crate::config::color::NEBULA_DEFAULT_CURSOR; use crate::display::color::Rgb; @@ -742,6 +757,9 @@ mod tests { Flags::empty(), false, )); + assert!(is_application_cursor_glyph('█', Flags::empty(), false)); + assert!(is_application_cursor_glyph(' ', Flags::INVERSE, false)); + assert!(!is_application_cursor_glyph(' ', Flags::empty(), false)); let (light_cursor, _) = composite_overlay( Rgb::new(124, 90, 196), diff --git a/nebula_app/src/display/keymap.rs b/nebula_app/src/display/keymap.rs index 1dda9e4..36af5cd 100644 --- a/nebula_app/src/display/keymap.rs +++ b/nebula_app/src/display/keymap.rs @@ -457,6 +457,7 @@ pub(crate) fn capture_combo(key: &KeyEvent, mods: ModifiersState) -> CaptureOutc /// - Shift+符号(gpui 直接给出变换后的字符并清掉 shift 位)只能按字符 /// 存储,旧壳 winit 层不会命中这类行——与旧壳编辑器「无法表示 → 等待」 /// 相比是多存了一条死绑定,可接受。 +#[cfg(feature = "gpui-shell")] pub(crate) fn capture_gpui(keystroke: &::gpui::Keystroke) -> CaptureOutcome { let mods = gpui_modifiers(&keystroke.modifiers); let key = keystroke.key.trim().to_lowercase(); @@ -494,6 +495,7 @@ pub(crate) fn capture_gpui(keystroke: &::gpui::Keystroke) -> CaptureOutcome { } /// gpui `Modifiers` → winit `ModifiersState`(位语义一致,直接搬运)。 +#[cfg(feature = "gpui-shell")] pub(crate) fn gpui_modifiers(modifiers: &::gpui::Modifiers) -> ModifiersState { let mut mods = ModifiersState::empty(); if modifiers.control { @@ -512,6 +514,7 @@ pub(crate) fn gpui_modifiers(modifiers: &::gpui::Modifiers) -> ModifiersState { } /// gpui `Modifiers` 的修饰键前缀回显(捕获态实时显示 "Ctrl+…")。 +#[cfg(feature = "gpui-shell")] pub(crate) fn gpui_mods_prefix(modifiers: &::gpui::Modifiers) -> String { mods_prefix(gpui_modifiers(modifiers)) } diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index 935bfd9..ef827a1 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -86,7 +86,7 @@ mod state; pub(crate) mod suggest_engine; mod surface_opacity; mod terminal_color; -mod terminal_math; +pub(crate) mod terminal_math; mod toast; /// Processor uses the same persisted value before the first window exists so diff --git a/nebula_app/src/display/side_panel.rs b/nebula_app/src/display/side_panel.rs index b9b2074..f9543a8 100644 --- a/nebula_app/src/display/side_panel.rs +++ b/nebula_app/src/display/side_panel.rs @@ -69,6 +69,10 @@ pub struct GitInfo { pub unstaged: Vec<(char, String)>, /// Index changes ready to commit. pub staged: Vec<(char, String)>, + /// 合并冲突(porcelain 的 U*/AA/DD;SVN 的 `C`)。冲突路径**同时**保留 + /// 在 `staged`/`unstaged` 里:旧壳视图零改动照常显示,GPUI 壳按本列表 + /// 单独分组并从另两组过滤(VS Code 的 Merge Changes 合同)。 + pub conflicts: Vec<(char, String)>, } /// Result of hit-testing a pixel against the open drawer. @@ -502,7 +506,22 @@ impl SidePanel { let slot = std::sync::Arc::clone(&self.snapshot_slot); std::thread::spawn(move || { let rows = SidePanel::tree_rows(&root, &expanded); - let git = read_git(&git_root).or_else(|| read_svn(&git_root)); + // 设置可强制只认 Git / SVN(混合仓库场景);Auto 保持既有探测: + // a checkout nested inside a Git tree must remain visible as SVN. + // Prefer SVN only when its metadata is in the current path's + // ancestor chain; ordinary Git directories keep the cheaper Git + // first path and only probe SVN as a fallback. + let git = match nebula_settings::RuntimeSettings::load().vcs_display { + nebula_settings::VcsDisplayName::Git => read_git(&git_root), + nebula_settings::VcsDisplayName::Svn => read_svn(&git_root), + nebula_settings::VcsDisplayName::Auto => { + if svn_working_copy_hint(&git_root) { + read_svn(&git_root).or_else(|| read_git(&git_root)) + } else { + read_git(&git_root).or_else(|| read_svn(&git_root)) + } + }, + }; if let Ok(mut slot) = slot.lock() { *slot = Some(PanelSnapshot { root, rows, git }); } @@ -653,6 +672,33 @@ impl SidePanel { } } + /// `git add -- `:单文件暂存(VS Code 行内 + 的合同)。 + pub fn git_stage_path(&mut self, path: &str) { + if self.vcs() == Some(VcsKind::Git) && !self.op_running() { + self.spawn_git(vec!["add".into(), "--".into(), path.to_owned()]); + } + } + + /// `git restore --staged -- `:单文件取消暂存(VS Code 行内 −)。 + pub fn git_unstage_path(&mut self, path: &str) { + if self.vcs() == Some(VcsKind::Git) && !self.op_running() { + self.spawn_git(vec![ + "restore".into(), + "--staged".into(), + "--".into(), + path.to_owned(), + ]); + } + } + + /// `git restore -- `:丢弃工作区改动(调用方负责确认交互; + /// untracked 文件不适用——restore 不删新文件,按钮层不对 `?` 提供)。 + pub fn git_discard_path(&mut self, path: &str) { + if self.vcs() == Some(VcsKind::Git) && !self.op_running() { + self.spawn_git(vec!["restore".into(), "--".into(), path.to_owned()]); + } + } + /// Commit button: with staged changes, open the message input (Enter then /// commits via [`Self::git_commit_submit`]). pub fn git_begin_commit(&mut self) { @@ -1222,6 +1268,12 @@ fn read_git(root: &Path) -> Option { info.unstaged.push(('?', path)); continue; } + // Merge conflicts (VS Code's "Merge Changes" group). The path + // also stays in staged/unstaged below so the legacy view keeps + // rendering it untouched. + if x == 'U' || y == 'U' || (x == 'A' && y == 'A') || (x == 'D' && y == 'D') { + info.conflicts.push(('U', path.clone())); + } // One file can be in BOTH lists (partially staged). if x != ' ' { info.staged.push((x, path.clone())); @@ -1246,10 +1298,10 @@ fn read_git(root: &Path) -> Option { Some(info) } -/// SVN fallback(`read_git` 判空后才尝试):工作副本检测走祖先链找 `.svn` -/// ——零子进程的廉价预检,svn 未安装或普通目录都在这里就地退出,不为 -/// 每次快照付一次 CLI 启动开销。 -fn svn_working_copy(root: &Path) -> bool { +/// Cheap preference hint for nested checkouts. `read_svn` still runs the +/// authoritative `svn info` command, so this hint cannot make detection +/// incorrect when metadata is represented differently by an SVN client. +fn svn_working_copy_hint(root: &Path) -> bool { root.ancestors().any(|dir| dir.join(".svn").is_dir()) } @@ -1270,15 +1322,71 @@ fn parse_svn_status(status: &str) -> Vec<(char, String)> { .collect() } -/// Snapshot SVN state for `root`: revision + working-copy changes. `None` -/// when `root` isn't inside a working copy or the `svn` CLI is missing. -/// 认证失败等错误同样落 `None` + debug 日志——面板留白,操作路径 -/// (commit/update)的错误才经 `op_error` 呈现给用户。 +/// Snapshot SVN state for `root`. The CLI(`svn info`/`svn status`)is tried +/// first for maximum fidelity; machines without a command-line client +/// (TortoiseSVN installs GUI only) fall back to reading `.svn/wc.db` +/// directly(`svn_status` 模块,TSVNCache 同款路线)。`None` means the path +/// is not a working copy at all. fn read_svn(root: &Path) -> Option { - use std::process::Command; - if !svn_working_copy(root) { + read_svn_cli(root).map(fill_svn_conflicts).or_else(|| read_svn_wc_db(root)) +} + +/// CLI 输出没有单独的冲突列表:从 `unstaged` 里把 `C` 行登记到 +/// [`GitInfo::conflicts`](路径保留原位,合同见字段注释)。 +fn fill_svn_conflicts(mut info: GitInfo) -> GitInfo { + info.conflicts = info + .unstaged + .iter() + .filter(|(state, _)| *state == 'C') + .map(|(_, path)| ('C', path.clone())) + .collect(); + info +} + +/// 零外部依赖的 SVN 快照:`svn_status` 读 `.svn/wc.db` 推导状态字母表, +/// 修订号取 NODES 根行。路径统一转成相对 `root` 的正斜杠形式,与 CLI +/// `svn status` 的展示合同一致(只显示 `root` 之下的条目)。 +fn read_svn_wc_db(root: &Path) -> Option { + let crate::svn_status::SvnDirKind::WorkingCopy(wc_root) = crate::svn_status::classify_dir(root) + else { return None; + }; + let changes = crate::svn_status::working_copy_status(&wc_root).ok()?; + let revision = crate::svn_status::working_copy_revision(&wc_root); + let mut info = GitInfo { + vcs: VcsKind::Svn, + branch: revision.map(|value| format!("r{value}")).unwrap_or_else(|| "svn".to_owned()), + ..GitInfo::default() + }; + let prefix = root.strip_prefix(&wc_root).ok().map(|relative| { + let mut text = relative.to_string_lossy().replace('\\', "/"); + if !text.is_empty() && !text.ends_with('/') { + text.push('/'); + } + text + }); + for change in changes { + let shown = match prefix.as_deref() { + None | Some("") => change.rel_path.clone(), + Some(prefix) => match change.rel_path.strip_prefix(prefix) { + Some(inside) => inside.to_owned(), + // `root` 子目录之外的变更不在本视图的展示合同内。 + None => continue, + }, + }; + let state = change.state.letter().chars().next().unwrap_or('M'); + if change.state == crate::svn_status::SvnState::Conflicted { + info.conflicts.push(('C', shown.clone())); + } + // SVN 无暂存区:全部归 unstaged(GPUI 会把冲突条目过滤出去单独分组)。 + info.unstaged.push((if state == 'U' { '?' } else { state }, shown)); } + Some(info) +} + +/// CLI 路线(现代客户端的权威路径)。 +fn read_svn_cli(root: &Path) -> Option { + use std::process::Command; let run = |args: &[&str]| -> Option { let mut cmd = Command::new("svn"); // 交互式认证提示会把无头子进程挂死;快照必须是非交互的。 @@ -1298,7 +1406,11 @@ fn read_svn(root: &Path) -> Option { Some(String::from_utf8_lossy(&out.stdout).into_owned()) }; - let revision = run(&["info", "--show-item", "revision"])?; + // `--show-item` is available in modern SVN. Fall back to regular + // `svn info` so older clients still produce a revision number. + let revision = run(&["info", "--show-item", "revision"]) + .and_then(|value| (!value.trim().is_empty()).then_some(value)) + .or_else(|| run(&["info"]).and_then(|value| parse_svn_revision(&value)))?; let status = run(&["status"])?; Some(GitInfo { vcs: VcsKind::Svn, @@ -1308,6 +1420,13 @@ fn read_svn(root: &Path) -> Option { }) } +fn parse_svn_revision(info: &str) -> Option { + info.lines().find_map(|line| { + let value = line.strip_prefix("Revision:")?.trim(); + (!value.is_empty()).then_some(value.to_owned()) + }) +} + // ---- rendering (mirrors the `settings.rs` split: the parent `display::mod` // hands in a snapshot + renderer; this module owns the drawer's pixels) ---- @@ -2443,6 +2562,7 @@ mod tests { ahead: 0, unstaged: vec![('?', "one.txt".into()), ('M', "two.txt".into())], staged: vec![('A', "three.txt".into())], + conflicts: Vec::new(), }); assert!(!panel.git_row_is_file(0), "未暂存标题"); @@ -2565,6 +2685,13 @@ mod tests { ); } + #[test] + fn svn_info_revision_parser_accepts_standard_output() { + let info = "Path: .\r\nWorking Copy Root Path: D:\\checkout\r\nRevision: 42\r\nNode Kind: directory\r\n"; + assert_eq!(parse_svn_revision(info).as_deref(), Some("42")); + assert_eq!(parse_svn_revision("Path: .\nNode Kind: directory\n"), None); + } + #[test] fn svn_snapshot_disables_stage_and_push_semantics() { // SVN 没有暂存区、没有 push:快照层用 staged 恒空 + ahead 恒 0 编码, diff --git a/nebula_app/src/display/state.rs b/nebula_app/src/display/state.rs index 92cc2b3..1473fd7 100644 --- a/nebula_app/src/display/state.rs +++ b/nebula_app/src/display/state.rs @@ -233,6 +233,9 @@ pub struct NebulaPaneState { /// 本次前台 agent 命令是否收到过 hook。屏幕规则的 idle 不得覆盖精确的 /// hook done;可见 blocker/working 仍可纠正漏报或过时事件。 pub agent_hook_seen: bool, + /// 屏幕检测连续看到空闲提示符的拍数。Working 要连续两拍空闲才降级 + /// (单拍可能是重绘间隙);任何非 idle 检测都会清零。 + pub idle_screen_streak: u8, pub last_committed: String, pub awaiting_input: bool, pub finished_unseen: bool, diff --git a/nebula_app/src/display/terminal_math.rs b/nebula_app/src/display/terminal_math.rs index 5e3a2d1..4d39303 100644 --- a/nebula_app/src/display/terminal_math.rs +++ b/nebula_app/src/display/terminal_math.rs @@ -61,7 +61,7 @@ const HEIGHT_OVERRUN_TOLERANCE: f32 = 0.12; /// Per-pane state. Layout data is intentionally discarded when a pane state is /// cloned: cloned UI metadata can outlive a renderer/font scale, while layouts /// are cheap to rebuild and remain bounded by `MathLayoutCache` afterwards. -pub(super) struct TerminalMathState { +pub(crate) struct TerminalMathState { cache: MathLayoutCache, projection: LineProjection, ai_cli_seen: bool, @@ -110,15 +110,15 @@ impl fmt::Debug for TerminalMathState { } impl TerminalMathState { - pub(super) fn observe_program(&mut self, program: Option<&str>) { + pub(crate) fn observe_program(&mut self, program: Option<&str>) { self.ai_cli_seen |= program.is_some_and(is_ai_cli); } - pub(super) fn inline_dollar_enabled(&self) -> bool { + pub(crate) fn inline_dollar_enabled(&self) -> bool { self.ai_cli_seen } - pub(super) fn update_projection( + pub(crate) fn update_projection( &mut self, overlays: &[FormulaOverlay], prepared: &[Option], @@ -126,7 +126,7 @@ impl TerminalMathState { self.projection.rebuild(overlays, prepared); } - pub(super) fn project_cell(&self, point: Point, columns: usize) -> Option> { + pub(crate) fn project_cell(&self, point: Point, columns: usize) -> Option> { self.projection.project_cell(point, columns) } @@ -137,7 +137,7 @@ impl TerminalMathState { /// `viewport_origin` is the renderer's viewport top row: projection spans /// live in rendered-viewport coordinates, which can be cropped relative to /// the grid while a resize commit is pending. - pub(super) fn source_point( + pub(crate) fn source_point( &self, point: Point, side: Side, @@ -580,14 +580,14 @@ struct GridPosition { } #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) struct RowSpan { +pub(crate) struct RowSpan { row: i32, start: usize, end: usize, } #[derive(Clone, Debug)] -pub(super) struct FormulaOverlay { +pub(crate) struct FormulaOverlay { source: Arc, display: bool, formula_id: u64, @@ -615,6 +615,21 @@ pub(super) struct FormulaOverlay { } impl FormulaOverlay { + /// GPUI 壳的只读视图:该公式覆盖的 (行, 起列, 止列) 序列。行可为负 + /// (部分滚出视口的持久公式)。用于围栏背景过滤与源格跳过。 + pub(crate) fn covered_ranges(&self) -> impl Iterator + '_ { + self.spans.iter().map(|span| (span.row, span.start, span.end)) + } + + /// 归一化后的 TeX 源(GPUI 壳绘制/缓存键用)。 + pub(crate) fn source_arc(&self) -> Arc { + Arc::clone(&self.source) + } + + pub(crate) fn formula_id(&self) -> u64 { + self.formula_id + } + fn contains(&self, point: Point) -> bool { self.spans.iter().any(|span| { usize::try_from(span.row) == Ok(point.line) @@ -981,7 +996,7 @@ impl Ord for GridPosition { /// Scan the visible grid and attach renderer-resolved colors/fallback cells. #[allow(clippy::too_many_arguments)] -pub(super) fn scan_visible( +pub(crate) fn scan_visible( state: &mut TerminalMathState, terminal: &Term, size: &SizeInfo, @@ -1452,7 +1467,7 @@ fn make_overlay( /// Every geometric decision is taken here, so the fit and the clip can never /// disagree about how much room the formula was given. #[derive(Clone, Copy)] -pub(super) struct PreparedFormula { +pub(crate) struct PreparedFormula { fitted_pixel_size: f32, /// Typesetting style actually used. A block formula that does not fit its /// row falls back to text style before it gives up any size: readers judge @@ -1656,7 +1671,7 @@ fn ink_columns( /// Pre-compile every overlay before the cell pass so the renderer knows which /// source cells to skip. Returns one entry per overlay; `None` keeps the raw /// source visible (layout failure, or the fitted size would be unreadable). -pub(super) fn prepare_overlays( +pub(crate) fn prepare_overlays( state: &mut TerminalMathState, overlays: &[FormulaOverlay], size: &SizeInfo, @@ -1803,15 +1818,19 @@ pub(super) fn prepare_overlays( /// pass can skip those glyphs. Painting them and covering the area with an /// opaque patch afterwards is what produced the black/white formula slabs on /// transparent and background-image windows. -pub(super) struct CoverageMask { +#[derive(Default)] +pub(crate) struct CoverageMask { rows: BTreeMap>, } impl CoverageMask { - pub(super) fn build(overlays: &[FormulaOverlay], prepared: &[Option]) -> Self { + /// `gates` 与 overlays 一一对应;`None` 的公式保留原文(其源格不进 + /// 掩码)。旧壳传 `Option`,GPUI 壳传 + /// `Option`——覆盖判定与各自的回退路径保持一致。 + pub(crate) fn build(overlays: &[FormulaOverlay], gates: &[Option]) -> Self { let mut rows: BTreeMap> = BTreeMap::new(); - for (overlay, prepared) in overlays.iter().zip(prepared) { - if prepared.is_none() { + for (overlay, gate) in overlays.iter().zip(gates) { + if gate.is_none() { continue; } for span in &overlay.spans { @@ -1822,22 +1841,123 @@ impl CoverageMask { Self { rows } } - pub(super) fn covers(&self, point: Point) -> bool { + pub(crate) fn covers(&self, point: Point) -> bool { self.rows.get(&point.line).is_some_and(|spans| { spans.iter().any(|&(start, end)| (start..end).contains(&point.column.0)) }) } - pub(super) fn is_empty(&self) -> bool { + pub(crate) fn is_empty(&self) -> bool { self.rows.is_empty() } } +/// 一个 overlay 的后端无关绘制计划:几何决策(fit/居中/bleed/裁剪)的 +/// 单一出口,OpenGL(旧壳 [`draw_overlays`])与 GPUI 壳共同消费,保证两壳 +/// 的公式落点与裁剪像素级同源。坐标系与 [`SizeInfo`] 一致。 +#[derive(Clone, Copy, Debug)] +pub(crate) struct OverlayDrawPlan { + pub(crate) fitted_pixel_size: f32, + pub(crate) display_style: bool, + pub(crate) origin_x: f32, + pub(crate) baseline_y: f32, + pub(crate) clip_left: f32, + pub(crate) clip_top: f32, + pub(crate) clip_right: f32, + pub(crate) clip_bottom: f32, + pub(crate) foreground: Rgb, +} + +/// 计算一个已通过预检的 overlay 的绘制几何。`None` = 本帧回退原文 +/// (布局失败或裁剪退化),调用方自行决定回退方式(旧壳补画源格,GPUI +/// 壳保留源格不跳过)。 +pub(crate) fn plan_overlay_draw( + state: &mut TerminalMathState, + overlay: &FormulaOverlay, + prepared: &PreparedFormula, + size: &SizeInfo, + pixels_per_point: f32, +) -> Option { + let mut bounds = overlay.bounds(size)?; + let shift_columns = match overlay.spans.as_slice() { + [span] => usize::try_from(span.row) + .ok() + .map_or(0, |row| state.projection.shift_before(row, span.start)), + _ => 0, + }; + let shift_pixels = shift_columns as f32 * size.cell_width(); + bounds.left += shift_pixels; + bounds.right += shift_pixels; + let projected_box_right = prepared.box_right + shift_pixels; + + // Pre-flight succeeded, so failure here is unreachable in practice. + let metrics = state + .layout( + overlay.formula_id, + &overlay.source, + prepared.fitted_pixel_size, + pixels_per_point, + prepared.display_style, + ) + .ok()? + .metrics; + let total_height = metrics.height + metrics.depth; + let viewport_right = size.padding_x() + size.columns() as f32 * size.cell_width(); + let viewport_bottom = size.padding_y() + size.screen_lines() as f32 * size.cell_height(); + let box_right = projected_box_right; + let box_width = box_right - bounds.left; + let origin_x = if prepared.centered { + bounds.left + (box_width - metrics.width) / 2.0 + } else { + bounds.left + FORMULA_INSET + }; + // Centre inside the source rows while the ink fits them. What does not + // fit is split between the sides in proportion to what each side can + // actually lend: with a blank row above and prose below, the overflow + // goes up, and the formula keeps the full line gap between itself and + // the text underneath instead of splitting the crowding evenly. + let slack = bounds.height() - total_height; + let room = prepared.bleed_top + prepared.bleed_bottom; + let top = if slack >= 0.0 { + bounds.top + slack / 2.0 + } else if room > 0.0 { + bounds.top + slack * (prepared.bleed_top / room) + } else { + bounds.top + slack / 2.0 + }; + let baseline_y = (top + metrics.height) + .max(bounds.top - prepared.bleed_top + metrics.height) + .min(bounds.bottom + prepared.bleed_bottom - metrics.depth); + + // The clip follows the bleed budget, never the ink: whatever the fit + // could not absorb gets cropped instead of landing on neighbouring + // prose. (Following the ink is what painted formulas over adjacent + // rows.) + let clip_left = bounds.left.max(size.padding_x()); + let clip_top = (bounds.top - prepared.bleed_top).max(size.padding_y()); + let clip_right = box_right.min(viewport_right); + let clip_bottom = (bounds.bottom + prepared.bleed_bottom).min(viewport_bottom); + if clip_right <= clip_left || clip_bottom <= clip_top { + return None; + } + Some(OverlayDrawPlan { + fitted_pixel_size: prepared.fitted_pixel_size, + display_style: prepared.display_style, + origin_x, + baseline_y, + clip_left, + clip_top, + clip_right, + clip_bottom, + foreground: overlay.foreground, + }) +} + /// Draw prepared overlays after terminal rectangles. The source cells were /// already skipped during the grid pass, so the formula renders directly on /// the window background — no cover quad, transparency and wallpaper intact. #[allow(clippy::too_many_arguments)] -pub(super) fn draw_overlays( +pub(crate) fn draw_overlays( renderer: &mut Renderer, glyph_cache: &mut GlyphCache, state: &mut TerminalMathState, @@ -1850,78 +1970,34 @@ pub(super) fn draw_overlays( let Some(prepared) = prepared else { continue; }; - let Some(mut bounds) = overlay.bounds(size) else { + let Some(plan) = plan_overlay_draw(state, overlay, prepared, size, pixels_per_point) + else { + // Repaint the skipped source cells rather than leave a hole. + renderer.draw_cells(size, glyph_cache, overlay.fallback.iter().cloned()); continue; }; - let shift_columns = match overlay.spans.as_slice() { - [span] => usize::try_from(span.row) - .ok() - .map_or(0, |row| state.projection.shift_before(row, span.start)), - _ => 0, - }; - let shift_pixels = shift_columns as f32 * size.cell_width(); - bounds.left += shift_pixels; - bounds.right += shift_pixels; - let projected_box_right = prepared.box_right + shift_pixels; - let layout = match state.layout( overlay.formula_id, &overlay.source, - prepared.fitted_pixel_size, + plan.fitted_pixel_size, pixels_per_point, - prepared.display_style, + plan.display_style, ) { Ok(layout) => layout, Err(_) => { - // Pre-flight succeeded, so this is unreachable in practice; - // repaint the skipped source cells rather than leave a hole. renderer.draw_cells(size, glyph_cache, overlay.fallback.iter().cloned()); continue; }, }; - let total_height = layout.metrics.height + layout.metrics.depth; - let viewport_right = size.padding_x() + size.columns() as f32 * size.cell_width(); - let viewport_bottom = size.padding_y() + size.screen_lines() as f32 * size.cell_height(); - let box_right = projected_box_right; - let box_width = box_right - bounds.left; - let origin_x = if prepared.centered { - bounds.left + (box_width - layout.metrics.width) / 2.0 - } else { - bounds.left + FORMULA_INSET - }; - // Centre inside the source rows while the ink fits them. What does not - // fit is split between the sides in proportion to what each side can - // actually lend: with a blank row above and prose below, the overflow - // goes up, and the formula keeps the full line gap between itself and - // the text underneath instead of splitting the crowding evenly. - let slack = bounds.height() - total_height; - let room = prepared.bleed_top + prepared.bleed_bottom; - let top = if slack >= 0.0 { - bounds.top + slack / 2.0 - } else if room > 0.0 { - bounds.top + slack * (prepared.bleed_top / room) - } else { - bounds.top + slack / 2.0 - }; - let baseline_y = (top + layout.metrics.height) - .max(bounds.top - prepared.bleed_top + layout.metrics.height) - .min(bounds.bottom + prepared.bleed_bottom - layout.metrics.depth); - - // The clip follows the bleed budget, never the ink: whatever the fit - // could not absorb gets cropped instead of landing on neighbouring - // prose. (Following the ink is what painted formulas over adjacent - // rows.) let clip = MathClip { - left: bounds.left.max(size.padding_x()), - top: (bounds.top - prepared.bleed_top).max(size.padding_y()), - right: box_right.min(viewport_right), - bottom: (bounds.bottom + prepared.bleed_bottom).min(viewport_bottom), + left: plan.clip_left, + top: plan.clip_top, + right: plan.clip_right, + bottom: plan.clip_bottom, }; - if clip.right <= clip.left || clip.bottom <= clip.top { - renderer.draw_cells(size, glyph_cache, overlay.fallback.iter().cloned()); - continue; - } - if renderer.draw_math(size, layout, origin_x, baseline_y, overlay.foreground, clip).is_err() + if renderer + .draw_math(size, layout, plan.origin_x, plan.baseline_y, plan.foreground, clip) + .is_err() { renderer.draw_cells(size, glyph_cache, overlay.fallback.iter().cloned()); continue; @@ -1929,12 +2005,16 @@ pub(super) fn draw_overlays( let base_ascent = (size.cell_height() + glyph_cache.font_metrics().descent).max(1.0); for operation in &layout.text { - let scale = operation.pixel_size / prepared.fitted_pixel_size; - let x = origin_x + operation.x; - let y = baseline_y + operation.baseline_y - base_ascent * scale; + let scale = operation.pixel_size / plan.fitted_pixel_size; + let x = plan.origin_x + operation.x; + let y = plan.baseline_y + operation.baseline_y - base_ascent * scale; let width = size.cell_width() * scale; let height = size.cell_height() * scale; - if x < clip.left || x + width > clip.right || y < clip.top || y + height > clip.bottom { + if x < plan.clip_left + || x + width > plan.clip_right + || y < plan.clip_top + || y + height > plan.clip_bottom + { continue; } let mut text = [0u8; 4]; @@ -1943,7 +2023,7 @@ pub(super) fn draw_overlays( x, y, scale, - overlay.foreground, + plan.foreground, Flags::empty(), operation.character.encode_utf8(&mut text), glyph_cache, diff --git a/nebula_app/src/gpui_shell/code_tab.rs b/nebula_app/src/gpui_shell/code_tab.rs new file mode 100644 index 0000000..e5fcfd3 --- /dev/null +++ b/nebula_app/src/gpui_shell/code_tab.rs @@ -0,0 +1,170 @@ +//! 代码查看 Tab:tree-sitter 语法高亮 + 行级虚拟化的只读查看器。 +//! +//! 渲染走组件库 `InputState::code_editor`——它按可视行布局/绘制(约 5 万行 +//! 量级依旧流畅),正好满足"超大代码只渲染可视范围"的性能合同;Markdown +//! 文档继续走 `TextView`(根级块虚拟化),两条管线各管各的文件类型。 +//! +//! 只读策略:这是查看器不是编辑器——文件树双击的诉求是"看一眼源码", +//! 写盘语义(保存/冲突/编码回写)超出本 tab 的合同。 + +use std::path::{Path, PathBuf}; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString, + Styled as _, Window, div, px, +}; + +use crate::gpui_shell::prelude::*; + +/// 一次性读入的上限。行级虚拟化解决的是渲染成本,解析/塑形仍随内容量 +/// 增长;8MB 已覆盖常见源码文件,超限截断并在文首说明。 +const MAX_CODE_BYTES: usize = 8 * 1024 * 1024; + +/// 扩展名 → tree-sitter 语言 id(组件库 highlighter 的注册名)。 +/// 未命中的扩展仍可打开(纯文本、无高亮)。 +fn language_for_extension(extension: &str) -> Option<&'static str> { + Some(match extension.to_ascii_lowercase().as_str() { + "rs" => "rust", + "py" | "pyi" => "python", + "js" | "mjs" | "cjs" => "javascript", + "ts" | "mts" | "cts" => "typescript", + "tsx" => "tsx", + "jsx" => "jsx", + "c" | "h" => "c", + "cpp" | "cc" | "cxx" | "hpp" | "hh" => "cpp", + "cs" => "csharp", + "go" => "go", + "java" => "java", + "rb" => "ruby", + "php" => "php", + "swift" => "swift", + "kt" | "kts" => "kotlin", + "lua" => "lua", + "sh" | "bash" | "zsh" => "bash", + "ps1" | "psm1" | "psd1" => "powershell", + "toml" => "toml", + "yaml" | "yml" => "yaml", + "html" | "htm" => "html", + "css" => "css", + "scss" => "scss", + "sql" => "sql", + "zig" => "zig", + "cmake" => "cmake", + "dockerfile" => "dockerfile", + "ex" | "exs" => "elixir", + "erl" => "erlang", + "hs" => "haskell", + "ini" | "cfg" | "conf" => "ini", + "vim" => "vim", + "xml" => "xml", + _ => return None, + }) +} + +/// 文件树双击是否进代码查看 tab(与 markdown/图片路由并列;三者都不认的 +/// 交系统处理器)。 +pub fn viewable_file(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| language_for_extension(extension).is_some()) +} + +pub struct CodeTabView { + pub path: PathBuf, + pub title: String, + input: Entity, + notice: Option, + lines: usize, +} + +impl CodeTabView { + pub fn new(path: PathBuf, window: &mut Window, cx: &mut Context) -> Self { + let title = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + let language = path + .extension() + .and_then(|extension| extension.to_str()) + .and_then(language_for_extension) + .unwrap_or("text"); + let input = cx.new(|cx| { + InputState::new(window, cx) + .code_editor(language) + .line_number(true) + .indent_guides(true) + .soft_wrap(false) + }); + let mut this = + Self { path, title, input, notice: None, lines: 0 }; + this.reload(window, cx); + this + } + + /// 重新读盘(文件树再次双击同一路径时宿主调用,与 doc tab 同义)。 + pub fn reload(&mut self, window: &mut Window, cx: &mut Context) { + let (text, notice) = match std::fs::read(&self.path) { + Ok(bytes) => { + let truncated = bytes.len() > MAX_CODE_BYTES; + let slice = if truncated { &bytes[..MAX_CODE_BYTES] } else { &bytes[..] }; + let text = String::from_utf8_lossy(slice).into_owned(); + let notice = truncated + .then(|| format!("文件超过 {} MB,仅显示开头部分", MAX_CODE_BYTES / 1024 / 1024)); + (text, notice) + }, + Err(error) => { + (String::new(), Some(format!("无法读取 {}: {error}", self.path.display()))) + }, + }; + self.lines = text.lines().count(); + self.notice = notice; + self.input.update(cx, |input, cx| input.set_value(text, window, cx)); + cx.notify(); + } +} + +impl Render for CodeTabView { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let muted = theme.muted_foreground; + let path_label: SharedString = self.path.display().to_string().into(); + let meta: SharedString = format!("{} 行", self.lines).into(); + + v_flex() + .size_full() + .p_3() + .gap_2() + .child( + h_flex() + .h(px(28.0)) + .items_center() + .gap_2() + .child( + div() + .flex_1() + .min_w_0() + .text_xs() + .text_color(muted) + .overflow_hidden() + .whitespace_nowrap() + .child(path_label), + ) + .child(div().text_xs().text_color(muted).child(meta)) + .child( + Button::new("code-reload") + .icon(IconName::Redo2) + .ghost() + .xsmall() + .tooltip("重新读取文件") + .on_click(cx.listener(|this, _, window, cx| { + this.reload(window, cx); + })), + ), + ) + .when_some(self.notice.clone(), |root, notice| { + root.child(div().text_xs().text_color(theme.warning).child(notice)) + }) + .child(div().flex_1().min_h_0().child(Input::new(&self.input).h_full())) + } +} diff --git a/nebula_app/src/gpui_shell/config.rs b/nebula_app/src/gpui_shell/config.rs index cf9ac6b..597678b 100644 --- a/nebula_app/src/gpui_shell/config.rs +++ b/nebula_app/src/gpui_shell/config.rs @@ -52,6 +52,9 @@ pub struct Settings { pub completion_style: crate::display::CompletionStyle, /// 单元格宽度取整方式;同一窗口宽度下必须与旧壳得到相同列数。 pub cell_width_mode: nebula_settings::CellWidthModeName, + /// 全宽字形(CJK 等)的 bold run 用 Regular 字形栅格(粗体只提亮不加粗, + /// 旧壳 `glyph_cache.wide_bold_use_regular` 同义)。 + pub cjk_bold_regular: bool, /// 默认 shell 的稳定 id(`nebula_settings.txt` 的 `shell=`,如 /// "pwsh" / "cmd" / "wsl:Ubuntu")。None = 引擎默认。 pub shell_id: Option, @@ -138,6 +141,7 @@ impl Settings { _ => crate::display::CompletionStyle::Inline, }, cell_width_mode: runtime.cell_width_mode, + cjk_bold_regular: runtime.cjk_bold_regular, shell_id: runtime.shell.clone(), font_family: normal_family, source_path: path, diff --git a/nebula_app/src/gpui_shell/doc_tabs.rs b/nebula_app/src/gpui_shell/doc_tabs.rs index 7f25b75..af95aba 100644 --- a/nebula_app/src/gpui_shell/doc_tabs.rs +++ b/nebula_app/src/gpui_shell/doc_tabs.rs @@ -26,11 +26,12 @@ use crate::display::image_viewer::ImageView; use crate::gpui_shell::prelude::*; use gpui_component::text::TextView; -/// 双击路由与旧壳 `input/chrome.rs` 同合同:应用内能读的开 tab,其余交 -/// 系统处理器。 +/// 双击路由:应用内能读的开 tab(图片/文档/源码),其余交系统处理器。 +/// 源码查看是 GPUI 壳新增能力,旧壳合同(`input/chrome.rs`)之上的超集。 pub fn openable_in_app(path: &Path) -> bool { crate::display::image_viewer::viewable_file(path) || crate::display::markdown_view::viewable_file(path) + || crate::gpui_shell::code_tab::viewable_file(path) } /// 文档一次性读入的上限。组件 TextView 是全量解析(没有旧壳 DocView 的 diff --git a/nebula_app/src/gpui_shell/math_view.rs b/nebula_app/src/gpui_shell/math_view.rs new file mode 100644 index 0000000..df317d2 --- /dev/null +++ b/nebula_app/src/gpui_shell/math_view.rs @@ -0,0 +1,562 @@ +//! GPUI 壳的公式渲染:旧壳数学管线接入组件库 TextView。 +//! +//! 管线与旧壳三层同源:`compile_formula` 产出后端无关绘制指令 → +//! `MathGlyphRasterizer`(与旧壳 GL 图集同一支栅格化器)按物理像素把整条 +//! 公式合成一张 BGRA 位图 → `window.paint_image` 上屏;数学字体缺字的 +//! 字符(旧笔记里的中文等)用 gpui 文本系统按基线叠加补画,对应旧壳 +//! `draw_math` 之后的 `draw_doc_text` 回补。 +//! +//! 为什么走位图而不是 gpui 字形管线:数学排版要按字形 ID 绘制(拉伸变体、 +//! 装配件都不经 cmap),而 gpui 的 `GlyphId` 构造器是 crate 私有的;位图 +//! 路线反而让两条壳共享唯一一支栅格化器,公式像素级同源。 +//! +//! 失败合同与旧壳一致:编译失败/超预算/缩到 [`MIN_READABLE_MATH_PX`] 之下 +//! 的公式回退为源码文本(组件库侧代码样式),不撑破阅读列。 + +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; +use std::sync::Arc; + +use gpui::{ + App, AvailableSpace, Bounds, Corners, Element, ElementId, GlobalElementId, InspectorElementId, + IntoElement, LayoutId, Pixels, Rgba, ShapedLine, SharedString, Size, Style, Window, point, px, + size, +}; +use image::Frame; + +use crate::math::layout::MathLayout; +use crate::math::rasterizer::MathGlyphRasterizer; +use crate::math::{DEFAULT_LIMITS, MIN_READABLE_MATH_PX, compile_formula}; + +/// 探针编译字号:注册的渲染闭包用它判定"这条公式能否编译",失败即让 +/// 组件库走源码文本回退。解析/预算类失败与字号无关,任意正值等价。 +const PROBE_PX: f32 = 16.0; + +/// 布局缓存条数上限。键含字号与 DPI,同一公式 fit 前后各占一条;超限 +/// 整体清空,单条重建是亚毫秒级 compile,不值得上 LRU。 +const MAX_LAYOUTS: usize = 1024; + +/// 公式位图缓存的字节预算;超限整体清空,可见公式下一帧按需重建。 +const IMAGE_BUDGET_BYTES: usize = 32 * 1024 * 1024; + +/// 单条公式位图的物理边长/字节上限;超限回退源码文本,与旧壳"fit 不进 +/// 列宽即回退"同一精神,防止病态长公式吃掉显存。 +const MAX_IMAGE_EDGE_PX: u32 = 8192; +const MAX_IMAGE_BYTES: usize = 24 * 1024 * 1024; + +/// 位图四周的出血余量(物理 px):字形位图的 bearing 可以越出公式度量 +/// 包围盒 1–2px(`GLYPH_PADDING` + 抗锯齿),出血兜住后仍越界的按行裁剪。 +const CANVAS_PAD: u32 = 2; + +/// 与旧壳 `fit_math_run` 相同的收缩余量:布局对字号线性,留 2% 吸收 +/// 取整误差,保证最右侧抗锯齿像素不越出阅读列。 +const FIT_MARGIN: f32 = 0.98; + +/// 注册 TextView 的公式渲染器;`gpui_shell::init` 调用一次。 +pub fn register(cx: &mut App) { + cx.set_global(MathAssets::new()); + gpui_component::text::set_math_renderer(cx, |spec, _, cx| { + let assets = cx.global_mut::(); + if assets.rasterizer.is_none() { + return None; + } + // 探针编译:失败的公式仍是文档文本,交回组件库按代码样式排版。 + assets.layout(&spec.source, spec.display, PROBE_PX, 1.0)?; + Some( + MathView { source: spec.source.clone(), display: spec.display }.into_any_element(), + ) + }); +} + +// ---- 全局缓存 ---- + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct LayoutKey { + source: SharedString, + display: bool, + pixel_size_bits: u32, + pixels_per_point_bits: u32, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct ImageKey { + source: SharedString, + display: bool, + pixel_size_bits: u32, + raster_scale_bits: u32, + color: u32, +} + +/// 位图内的定位信息(物理 px):paint 时把位图基线吸附到元素基线。 +#[derive(Clone, Copy, Debug)] +struct ImageGeometry { + /// 位图顶边到公式基线的距离。 + baseline: u32, + /// 位图左边相对公式盒左缘的外扩(出血)。 + pad: u32, + width: u32, + height: u32, +} + +pub(crate) struct MathAssets { + /// 字体解析失败时为 None:探针直接失败,所有公式回退源码文本。 + rasterizer: Option, + layouts: HashMap>>, + images: HashMap, ImageGeometry)>>, + image_bytes: usize, +} + +impl gpui::Global for MathAssets {} + +impl MathAssets { + fn new() -> Self { + Self { + rasterizer: MathGlyphRasterizer::new().ok(), + layouts: HashMap::new(), + images: HashMap::new(), + image_bytes: 0, + } + } + + /// 编排(含负缓存:失败公式不逐帧重试)。 + pub(crate) fn layout( + &mut self, + source: &SharedString, + display: bool, + pixel_size: f32, + pixels_per_point: f32, + ) -> Option> { + let key = LayoutKey { + source: source.clone(), + display, + pixel_size_bits: pixel_size.to_bits(), + pixels_per_point_bits: pixels_per_point.to_bits(), + }; + if !self.layouts.contains_key(&key) { + if self.layouts.len() >= MAX_LAYOUTS { + self.layouts.clear(); + } + let compiled = + compile_formula(source.as_ref(), display, pixel_size, pixels_per_point, DEFAULT_LIMITS) + .ok() + .map(Arc::new); + self.layouts.insert(key.clone(), compiled); + } + self.layouts.get(&key).and_then(Clone::clone) + } + + /// 旧壳 `fit_math_run` 的等价物:超宽公式按线性比例缩字号,缩到 + /// [`MIN_READABLE_MATH_PX`] 之下就放弃(调用方回退源码文本)。 + fn fit( + &mut self, + source: &SharedString, + display: bool, + pixel_size: f32, + pixels_per_point: f32, + max_width: f32, + ) -> Option<(Arc, f32)> { + let base = self.layout(source, display, pixel_size, pixels_per_point)?; + if base.metrics.width <= max_width { + return Some((base, pixel_size)); + } + let fitted_size = pixel_size * (max_width / base.metrics.width) * FIT_MARGIN; + if fitted_size < MIN_READABLE_MATH_PX { + return None; + } + let fitted = self.layout(source, display, fitted_size, pixels_per_point)?; + (fitted.metrics.width <= max_width).then_some((fitted, fitted_size)) + } + + /// 公式位图(含负缓存:超限公式不逐帧重试合成)。 + pub(crate) fn image( + &mut self, + source: &SharedString, + display: bool, + pixel_size: f32, + pixels_per_point: f32, + raster_scale: f32, + color: Rgba, + ) -> Option<(Arc, ImageGeometry)> { + let color_bits = ((color.r * 255.0) as u32) << 16 + | ((color.g * 255.0) as u32) << 8 + | (color.b * 255.0) as u32; + let key = ImageKey { + source: source.clone(), + display, + pixel_size_bits: pixel_size.to_bits(), + raster_scale_bits: raster_scale.to_bits(), + color: color_bits, + }; + if let Some(cached) = self.images.get(&key) { + return cached.clone(); + } + + let layout = self.layout(source, display, pixel_size, pixels_per_point)?; + let composed = self + .rasterizer + .as_ref() + .and_then(|rasterizer| compose_image(rasterizer, &layout, raster_scale, color)); + if let Some((_, geometry)) = &composed { + let bytes = geometry.width as usize * geometry.height as usize * 4; + if self.image_bytes.saturating_add(bytes) > IMAGE_BUDGET_BYTES { + self.images.clear(); + self.image_bytes = 0; + } + self.image_bytes += bytes; + } + self.images.insert(key, composed.clone()); + composed + } +} + +/// 把整条公式(字形 + 分数线等矩形)按物理像素合成为一张直通 alpha 的 +/// BGRA 位图。字形位图原点吸附整数物理像素(旧壳同款),advance 保持 +/// 全精度。 +fn compose_image( + rasterizer: &MathGlyphRasterizer, + layout: &MathLayout, + raster_scale: f32, + color: Rgba, +) -> Option<(Arc, ImageGeometry)> { + let content_width = (layout.metrics.width * raster_scale).ceil().max(1.0) as u32; + let ascent = (layout.metrics.height * raster_scale).ceil().max(0.0) as u32; + let descent = (layout.metrics.depth * raster_scale).ceil().max(0.0) as u32; + let width = content_width.checked_add(CANVAS_PAD * 2)?; + let height = ascent.checked_add(descent)?.checked_add(CANVAS_PAD * 2)?.max(1); + if width > MAX_IMAGE_EDGE_PX || height > MAX_IMAGE_EDGE_PX { + return None; + } + let bytes = width as usize * height as usize * 4; + if bytes > MAX_IMAGE_BYTES { + return None; + } + let baseline = (ascent + CANVAS_PAD) as i64; + + // 先在 alpha 平面上合成(Porter-Duff over),最后一次性染色。 + let mut coverage = vec![0u8; width as usize * height as usize]; + for op in &layout.glyphs { + let glyph = rasterizer.rasterize(op.glyph_id, op.pixel_size * raster_scale).ok()?; + if glyph.width == 0 || glyph.height == 0 { + continue; + } + let origin_x = (op.x * raster_scale).round() as i64 + glyph.left as i64 + CANVAS_PAD as i64; + let origin_y = baseline + (op.baseline_y * raster_scale).round() as i64 - glyph.top as i64; + blit_over( + &mut coverage, + width, + height, + origin_x, + origin_y, + glyph.width as u32, + glyph.height as u32, + |x, y| glyph.rgba[(y * glyph.width as usize + x) * 4 + 3], + ); + } + for rule in &layout.rules { + let x0 = (rule.x * raster_scale).round() as i64 + CANVAS_PAD as i64; + let y0 = baseline + (rule.y * raster_scale).round() as i64; + let w = (rule.width * raster_scale).round().max(1.0) as u32; + let h = (rule.height * raster_scale).round().max(1.0) as u32; + blit_over(&mut coverage, width, height, x0, y0, w, h, |_, _| u8::MAX); + } + + // gpui 图像管线吃直通 alpha 的 BGRA(与 DirectWrite 字形同一约定)。 + let mut bgra = vec![0u8; bytes]; + let (r, g, b) = ( + (color.r * 255.0).round() as u8, + (color.g * 255.0).round() as u8, + (color.b * 255.0).round() as u8, + ); + for (pixel, alpha) in bgra.chunks_exact_mut(4).zip(&coverage) { + pixel[0] = b; + pixel[1] = g; + pixel[2] = r; + pixel[3] = *alpha; + } + let buffer = image::RgbaImage::from_raw(width, height, bgra)?; + let geometry = + ImageGeometry { baseline: baseline as u32, pad: CANVAS_PAD, width, height }; + Some((Arc::new(gpui::RenderImage::new([Frame::new(buffer)])), geometry)) +} + +/// alpha 平面上的 Porter-Duff over 合成;目标越界的像素按行列裁剪。 +#[allow(clippy::too_many_arguments)] +fn blit_over( + canvas: &mut [u8], + canvas_width: u32, + canvas_height: u32, + origin_x: i64, + origin_y: i64, + width: u32, + height: u32, + sample: impl Fn(usize, usize) -> u8, +) { + for row in 0..height as i64 { + let y = origin_y + row; + if y < 0 || y >= canvas_height as i64 { + continue; + } + for column in 0..width as i64 { + let x = origin_x + column; + if x < 0 || x >= canvas_width as i64 { + continue; + } + let source = sample(column as usize, row as usize) as u32; + if source == 0 { + continue; + } + let target = &mut canvas[y as usize * canvas_width as usize + x as usize]; + let existing = *target as u32; + *target = (existing + source * (255 - existing) / 255).min(255) as u8; + } + } +} + +// ---- 公式元素 ---- + +/// 一条公式的 GPUI 元素。行内公式由组件库放进换行 flex 行(底边对齐), +/// 自身用底部留白把公式基线补齐到文本基线;块级公式由组件库水平居中。 +struct MathView { + source: SharedString, + display: bool, +} + +/// measure 闭包与 paint 之间的当帧交接。taffy 可能以不同可用宽度多次 +/// 调用 measure,槽里保存最后一次结果;paint 用实际 bounds 核对,不符 +/// (罕见)就按 bounds 宽度重新 fit,宁可略小也不越界。 +#[derive(Default)] +enum FitSlot { + #[default] + Pending, + Math { + layout: Arc, + pixel_size: f32, + /// 元素底边到公式基线的距离(行内基线补偿;块级为 depth)。 + baseline_from_bottom: f32, + }, + /// fit 放弃后的退化形态:单行源码文本(探针已保证可编译,只有 + /// 极窄列会走到这里)。 + Text(ShapedLine), +} + +impl IntoElement for MathView { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for MathView { + type RequestLayoutState = Rc>; + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + _: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let slot: Rc> = Rc::default(); + let text_style = window.text_style(); + let rem_size = window.rem_size(); + let font_size = text_style.font_size.to_pixels(rem_size); + let line_height = f32::from(text_style.line_height_in_pixels(rem_size)); + let run = text_style.to_run(self.source.len()); + // 文本行盒里基线到底边的距离 = descent + 半行距;行内公式用它把 + // 自己的基线抬到与同行文本一致(旧壳 push_with_math 的基线合同)。 + let probe = window.text_system().shape_line("M".into(), font_size, &[text_style.to_run(1)], None); + let text_baseline_from_bottom = f32::from(probe.descent) + + (line_height - f32::from(probe.ascent) - f32::from(probe.descent)).max(0.0) / 2.0; + + let source = self.source.clone(); + let display = self.display; + let nominal_px = f32::from(font_size); + let pixels_per_point = window.scale_factor(); + let measure_slot = slot.clone(); + let style = Style { flex_shrink: 0.0, ..Style::default() }; + let layout_id = window.request_measured_layout(style, move |_, available, window, cx| { + let max_width = match available.width { + AvailableSpace::Definite(width) => f32::from(width).max(8.0), + AvailableSpace::MinContent | AvailableSpace::MaxContent => f32::INFINITY, + }; + let assets = cx.global_mut::(); + match assets.fit(&source, display, nominal_px, pixels_per_point, max_width) { + Some((layout, pixel_size)) => { + let bottom_pad = if display { + 0.0 + } else { + (text_baseline_from_bottom - layout.metrics.depth).max(0.0) + }; + let width = layout.metrics.width; + let height = layout.metrics.height + layout.metrics.depth + bottom_pad; + let baseline_from_bottom = layout.metrics.depth + bottom_pad; + *measure_slot.borrow_mut() = + FitSlot::Math { layout, pixel_size, baseline_from_bottom }; + size(px(width), px(height)) + }, + None => { + let line = window.text_system().shape_line( + source.clone(), + px(nominal_px), + std::slice::from_ref(&run), + None, + ); + let width = f32::from(line.width).min(max_width.max(8.0)); + *measure_slot.borrow_mut() = FitSlot::Text(line); + size(px(width), px(line_height)) + }, + } + }); + (layout_id, slot) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Window, + _: &mut App, + ) -> Self::PrepaintState { + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + bounds: Bounds, + slot: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let text_style = window.text_style(); + let line_height = text_style.line_height_in_pixels(window.rem_size()); + match &*slot.borrow() { + FitSlot::Pending => {}, + FitSlot::Text(line) => { + let _ = line.paint(bounds.origin, line_height, window, cx); + }, + FitSlot::Math { layout, pixel_size, baseline_from_bottom } => { + let bounds_width = f32::from(bounds.size.width); + // taffy 最终宽度与最后一次 measure 不一致(罕见):按实际 + // bounds 重新 fit,保证不越界绘制。 + if layout.metrics.width > bounds_width + 0.5 { + let nominal = f32::from(text_style.font_size.to_pixels(window.rem_size())); + let scale_factor = window.scale_factor(); + let assets = cx.global_mut::(); + let Some((layout, pixel_size)) = assets.fit( + &self.source, + self.display, + nominal, + scale_factor, + bounds_width, + ) else { + return; + }; + let baseline = bounds.bottom() - px(layout.metrics.depth); + self.paint_math(layout.as_ref(), pixel_size, baseline, bounds, window, cx); + return; + } + let baseline = bounds.bottom() - px(*baseline_from_bottom); + let layout = layout.clone(); + self.paint_math(layout.as_ref(), *pixel_size, baseline, bounds, window, cx); + }, + } + } +} + +impl MathView { + /// 位图贴到基线上(物理像素吸附),数学字体缺字的字符用 gpui 文本 + /// 按各自字号补画在同一条基线上。 + fn paint_math( + &self, + layout: &MathLayout, + pixel_size: f32, + baseline: Pixels, + bounds: Bounds, + window: &mut Window, + cx: &mut App, + ) { + let text_style = window.text_style(); + let color = Rgba::from(text_style.color); + paint_formula_image( + &self.source, + self.display, + pixel_size, + window.scale_factor(), + bounds.left(), + baseline, + color, + window, + cx, + ); + + for op in &layout.text { + let mut buffer = [0u8; 4]; + let text: SharedString = op.character.encode_utf8(&mut buffer).to_string().into(); + let run = text_style.to_run(text.len()); + let line = + window.text_system().shape_line(text, px(op.pixel_size), std::slice::from_ref(&run), None); + let origin = point( + bounds.left() + px(op.x), + baseline + px(op.baseline_y) - line.ascent, + ); + let _ = line.paint(origin, line.ascent + line.descent, window, cx); + } + } +} + +/// 把一条公式的位图贴到指定基线(物理像素吸附),文档元素与终端覆盖层 +/// 共用。位图缓存/合成走 [`MathAssets`];数学字体缺字的 text ops 由调用 +/// 方按各自的裁剪合同补画。 +#[allow(clippy::too_many_arguments)] +pub(crate) fn paint_formula_image( + source: &SharedString, + display: bool, + pixel_size: f32, + pixels_per_point: f32, + left: Pixels, + baseline: Pixels, + color: Rgba, + window: &mut Window, + cx: &mut App, +) { + let raster_scale = window.scale_factor(); + let assets = cx.global_mut::(); + let Some((render_image, geometry)) = + assets.image(source, display, pixel_size, pixels_per_point, raster_scale, color) + else { + return; + }; + // 位图物理像素与屏幕 1:1:原点在物理网格上取整后再折回逻辑坐标。 + let left_physical = (f32::from(left) * raster_scale).round(); + let baseline_physical = (f32::from(baseline) * raster_scale).round(); + let origin = point( + px((left_physical - geometry.pad as f32) / raster_scale), + px((baseline_physical - geometry.baseline as f32) / raster_scale), + ); + let image_size = size( + px(geometry.width as f32 / raster_scale), + px(geometry.height as f32 / raster_scale), + ); + let _ = window.paint_image( + Bounds::new(origin, image_size), + Corners::default(), + render_image, + 0, + false, + ); +} diff --git a/nebula_app/src/gpui_shell/mod.rs b/nebula_app/src/gpui_shell/mod.rs index d52fee3..4df2ff9 100644 --- a/nebula_app/src/gpui_shell/mod.rs +++ b/nebula_app/src/gpui_shell/mod.rs @@ -10,16 +10,20 @@ //! - `NEBULA_GPUI_SHELL=1`(需 gpui-shell 构建):spike 形态——GPUI 跑在 //! 专用线程,与 winit 旧壳同进程并存,仅用于双运行时验证,P3 完成后移除。 +pub mod code_tab; pub mod config; pub mod doc_tabs; +pub mod math_view; pub mod prelude; pub mod session_restore; pub mod settings_pane; pub mod ssh_hosts; +pub mod ssh_settings; pub mod terminal; pub mod theme; pub mod toast; pub mod wallpaper; +pub mod widgets; pub mod workspace; use gpui::{App, AppContext as _, Bounds, WindowBounds, WindowOptions, px, size}; @@ -55,6 +59,9 @@ fn init(cx: &mut App) { // 组件库必须只初始化一次,否则全局 action、菜单和主题状态会重复注册。 gpui_component::init(cx); + // TextView 的公式渲染钩子:旧壳数学管线(compile → 栅格化)接入组件库 + // 的 markdown 渲染;不注册时公式回退为源码文本。 + math_view::register(cx); theme::apply_chrome_theme(cx); terminal::init(cx); workspace::init(cx); @@ -124,6 +131,14 @@ fn open_main_window( cx.open_window(options, move |window, cx| { let workspace = cx.new(|cx| NebulaWorkspace::new(window, ai_events, cx)); + // 调试/验收后门:启动即打开指定文档(见 open_document_at_startup)。 + if let Ok(path) = std::env::var("NEBULA_GPUI_OPEN_DOC") + && !path.is_empty() + { + workspace.update(cx, |workspace, cx| { + workspace.open_document_at_startup(path.into(), window, cx); + }); + } cx.new(|cx| Root::new(workspace, window, cx)) }) .expect("failed to open Nebula GPUI window"); diff --git a/nebula_app/src/gpui_shell/prelude.rs b/nebula_app/src/gpui_shell/prelude.rs index 20e0313..7135324 100644 --- a/nebula_app/src/gpui_shell/prelude.rs +++ b/nebula_app/src/gpui_shell/prelude.rs @@ -30,3 +30,18 @@ pub use gpui_component::{ pub use gpui_component::IndexPath; pub use gpui_component::WindowExt as _; + +use gpui::{Window, px}; +use gpui_component::PixelsExt as _; + +/// 旧壳 `draw_confirm_modal`:`(win_w - box_w)/2`、`(win_h - box_h)/2`。 +/// 组件库 Dialog 水平已经居中,垂直默认贴在 `height/10`。不改 gpui-component, +/// 只把 `margin_top` 调到窗口中线。关闭/粘贴确认约 200px 高;入场动画结束 +/// 时还会把 top 再加 30px,所以这里先减去这段位移,落点才是正中。 +pub fn center_confirm_dialog(dialog: Dialog, window: &Window) -> Dialog { + let height = window.viewport_size().height.as_f32(); + const DIALOG_H: f32 = 200.0; + const SLIDE: f32 = 30.0; + let top = ((height - DIALOG_H) / 2.0 - SLIDE).max(16.0); + dialog.margin_top(px(top)) +} diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs index 7717d0b..afac844 100644 --- a/nebula_app/src/gpui_shell/settings_pane.rs +++ b/nebula_app/src/gpui_shell/settings_pane.rs @@ -1,4237 +1,3500 @@ -//! 设置页(GPUI 组件版,覆盖旧壳设置的全部纯键值项)。 -//! -//! 版面对齐旧壳:**左侧分区导航 + 右侧单分区内容**(分区名与顺序对照 -//! `NebulaSettingsSection`:外观/配置文件/供应商/SSH/网络/交互/按键映射/ -//! 高级/备份),默认落在外观。 -//! -//! 薄壳纪律:本文件不定义任何设置语义——键名、值域、出厂默认、主题色表、 -//! 持久化格式全部在共享 crate `nebula-settings`(从旧壳逐字迁移并有单测 -//! 锁定)。这里只负责用组件库把字段摆出来;改动经 `persist_keys` 原地 -//! 写回 `nebula_settings.txt`,与旧壳读写同一份文件、同一套语义。 -//! -//! 生效时机(对齐旧壳):主题/配色/背景色/字号/copy_on_select 即时热应用; -//! 字体族与默认光标形状对新标签页生效;窗口透明/模糊/托盘/快速终端热键 -//! 等依赖尚未迁移的窗口子系统,写盘后由旧壳消费(本页如实标注)。 -//! -//! 仍依赖旧壳窗口子系统的设置会在对应分区明确标注;SSH 主机、AI -//! Provider、按键映射和备份编辑器已经复用共享业务层与持久化合同。 - -use gpui::prelude::FluentBuilder as _; -use gpui::{ - App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, Hsla, - InteractiveElement as _, IntoElement, KeyDownEvent, ModifiersChangedEvent, MouseButton, - ParentElement as _, Render, RenderImage, Rgba as GpuiRgba, SharedString, - StatefulInteractiveElement as _, Styled as _, Subscription, Window, anchored, deferred, div, - img, px, -}; -use image::Frame; -use std::sync::Arc; -use gpui_component::input::InputEvent; -use gpui_component::select::{SelectEvent, SelectItem}; -use nebula_settings::{RuntimeSettings, ThemeName, format_hex_rgb, persist_keys}; - -use crate::gpui_shell::prelude::*; - -/// 主题下拉(展示名 = 持久化名,与旧壳一致)。 -const THEME_VALUES: [&str; 7] = - ["Nebula", "SilverLight", "SteelDark", "LimestoneLight", "CoalDark", "LinenLight", "MossDark"]; - -/// 左侧分区导航(名称与顺序对照旧壳 `NebulaSettingsSection`)。 -const SECTIONS: [&str; 9] = - ["外观", "配置文件", "供应商", "SSH", "网络", "交互", "按键映射", "高级", "备份"]; - -/// 左侧导航按原有业务顺序平铺。下标仍是 [`SECTIONS`] 的稳定 section -/// 身份;这里只控制显示顺序,不再插入“连接”“系统”等分类标题。 -const NAV_ORDER: [usize; 9] = [0, 1, 2, 5, 6, 3, 4, 7, 8]; - -/// 设置搜索的分区索引。当前设置页由九个高内聚 section 自己渲染,搜索层 -/// 只负责把用户常用字段词映射到 section,不把持久化键或业务语义搬进导航。 -const SECTION_SEARCH_TERMS: [&str; 9] = [ - "外观 主题 配色 背景 壁纸 透明度 模糊 字体 字号 字间距 光标 语言 密度 fetch powerline appearance theme color background wallpaper opacity blur font cursor language", - "配置文件 终端 shell 启动目录 补全 ghost 接受键 profile terminal startup directory completion", - "供应商 AI 助手 模型 API endpoint provider assistant model codex claude gemini", - "SSH 主机 连接 认证 密码 私钥 跳板 host connection auth password key", - "网络 代理 系统代理 自定义代理 bypass proxy network socks http", - "交互 标签 动效 新标签 复制 粘贴 选择 interaction tab animation copy paste select", - "按键映射 快捷键 热键 键位 keymap keyboard shortcut hotkey binding", - "高级 会话 保留 恢复 AI 接续 托盘 keep session restore resume tray", - "备份 加密 密码 导出 恢复 远程 同步 WebDAV S3 SFTP backup encryption export import remote sync", -]; - -fn section_matches(index: usize, query: &str) -> bool { - let query = query.trim().to_lowercase(); - if query.is_empty() { - return true; - } - let haystack = format!("{} {}", SECTIONS[index], SECTION_SEARCH_TERMS[index]).to_lowercase(); - query.split_whitespace().all(|term| haystack.contains(term)) -} - -// 这些几何值逐项来自旧壳 `display/settings.rs::settings_geometry`。GPUI -// 设置页沿用同一节奏,避免组件默认间距把标题、分组和表单压成一条均匀列表。 -const SETTINGS_NAV_WIDTH: f32 = 196.0; -const SETTINGS_HEADER_HEIGHT: f32 = 72.0; -const SETTINGS_GROUP_GAP: f32 = 32.0; -const SETTINGS_GROUP_TITLE_HEIGHT: f32 = 26.0; -const SETTINGS_GROUP_TITLE_GAP: f32 = 16.0; -const SETTINGS_ROW_HEIGHT: f32 = 44.0; - -const THEME_NAMES: [ThemeName; 7] = [ - ThemeName::Nebula, - ThemeName::SilverLight, - ThemeName::SteelDark, - ThemeName::LimestoneLight, - ThemeName::CoalDark, - ThemeName::LinenLight, - ThemeName::MossDark, -]; - -fn section_icon(index: usize) -> IconName { - match index { - 0 => IconName::Palette, - 1 => IconName::GalleryVerticalEnd, - 2 => IconName::Bot, - 3 => IconName::SquareTerminal, - 4 => IconName::Globe, - 5 => IconName::Inspector, - 6 => IconName::ALargeSmall, - 7 => IconName::Settings2, - _ => IconName::Inbox, - } -} - -fn chrome_theme(theme: ThemeName) -> crate::display::NebulaTheme { - use crate::display::NebulaTheme; - match theme { - ThemeName::Nebula => NebulaTheme::Nebula, - ThemeName::SilverLight => NebulaTheme::SilverLight, - ThemeName::SteelDark => NebulaTheme::SteelDark, - ThemeName::LimestoneLight => NebulaTheme::LimestoneLight, - ThemeName::CoalDark => NebulaTheme::CoalDark, - ThemeName::LinenLight => NebulaTheme::LinenLight, - ThemeName::MossDark => NebulaTheme::MossDark, - } -} - -fn rgb_hsla(r: u8, g: u8, b: u8) -> Hsla { - GpuiRgba { r: f32::from(r) / 255.0, g: f32::from(g) / 255.0, b: f32::from(b) / 255.0, a: 1.0 } - .into() -} - -/// 宿主(workspace)监听:设置已写盘 / 请求打开 SSH 会话。 -pub enum SettingsPaneEvent { - Changed, - /// 设置页"连接"按钮:宿主开 SSH tab(连接语义在业务层)。 - LaunchSsh(String), -} - -type SharedSelect = Entity>>; -type SharedShellSelect = Entity>>; - -#[derive(Clone)] -struct ShellSelectItem { - id: String, - name: SharedString, - closed_image: Option>, - row_image: Option>, -} - -/// 设置页 SSH 添加/编辑面板的非文本草稿。文字实体常驻在 `SettingsPane`, -/// 这样输入事件可以统一使测试结果失效;草稿只保存认证、密钥和编辑身份。 -#[derive(Clone)] -struct SshEditorState { - /// 每次打开递增。文件选择器等异步回调只可写回发起它的编辑会话。 - id: u64, - original_destination: Option, - auth: crate::ssh_profiles::SshAuthMode, - icon: Option, - private_keys: Vec, - save_password: bool, - show_password: bool, - revision: u64, - test_request_id: Option, - test_status: Option<(String, bool)>, -} - -impl SshEditorState { - fn new(id: u64, original_destination: Option) -> Self { - Self { - id, - original_destination, - // 与旧壳新增主机一致:第一次通常有密码,默认密码模式避免让 - // 新用户先因 Auto 没有可用私钥而得到一次无意义的认证失败。 - auth: crate::ssh_profiles::SshAuthMode::Password, - icon: None, - private_keys: Vec::new(), - save_password: true, - show_password: false, - revision: 0, - test_request_id: None, - test_status: None, - } - } - - fn testing(&self) -> bool { - self.test_request_id.is_some() - } -} - -impl ShellSelectItem { - fn new(id: String, name: String, scale_factor: f32) -> Self { - let bytes = crate::shell_detect::color_icon_png(&id); - // Select 的闭态和菜单行尺寸不同。分别生成与物理像素一一对应的纹理, - // 避免把 128px 原图交给 GPUI 在每帧缩小而产生模糊边缘。 - let closed_image = bytes.and_then(|bytes| Self::decode_image(bytes, 20.0, scale_factor)); - let row_image = bytes.and_then(|bytes| Self::decode_image(bytes, 24.0, scale_factor)); - Self { id, name: name.into(), closed_image, row_image } - } - - fn decode_image(bytes: &[u8], logical_size: f32, scale_factor: f32) -> Option> { - let source = image::load_from_memory(bytes).ok()?.into_rgba8(); - let (width, height) = source.dimensions(); - let target_size = (logical_size * scale_factor).round().max(1.0) as u32; - let (mut rgba, width, height) = crate::display::prepare_ai_logo_texture( - source.as_raw(), - width, - height, - target_size, - ); - // GPUI RenderImage 使用 BGRA;预缩放必须在 RGBA 上完成,换序放在最后, - // 否则 Lanczos 会把红蓝通道按错误语义混合。 - for pixel in rgba.chunks_exact_mut(4) { - pixel.swap(0, 2); - } - let rgba = image::RgbaImage::from_raw(width, height, rgba)?; - Some(Arc::new(RenderImage::new([Frame::new(rgba)]))) - } - - fn view(&self, size: f32, image: Option<&Arc>) -> gpui::AnyElement { - let icon: gpui::AnyElement = if let Some(image) = image { - gpui::StyledImage::object_fit( - img(image.clone()).size(px(size)).flex_shrink_0(), - gpui::ObjectFit::Contain, - ) - .into_any_element() - } else { - Icon::new(IconName::SquareTerminal).xsmall().into_any_element() - }; - h_flex() - .gap_2() - .items_center() - .child(icon) - .child(div().flex_1().min_w_0().child(self.name.clone())) - .into_any_element() - } -} - -impl SelectItem for ShellSelectItem { - type Value = String; - - fn title(&self) -> SharedString { - self.name.clone() - } - - fn display_title(&self) -> Option { - // 闭态留出 chevron 与上下内边距;20px 在 32px Select 内不会挤字。 - Some(self.view(20.0, self.closed_image.as_ref())) - } - - fn render(&self, _: &mut Window, _: &mut App) -> impl IntoElement { - // 旧壳 ShellPickerRow 的品牌图标是 24×24 逻辑像素。 - self.view(24.0, self.row_image.as_ref()) - } - - fn value(&self) -> &Self::Value { - &self.id - } - - fn matches(&self, query: &str) -> bool { - self.name.to_lowercase().contains(&query.to_lowercase()) - || self.id.to_lowercase().contains(&query.to_lowercase()) - } -} - -pub struct SettingsPane { - focus_handle: FocusHandle, - /// 渲染与写盘的单一事实源;每次 persist 后整体重载。 - runtime: RuntimeSettings, - /// 当前分区(`SECTIONS` 下标);旧壳默认落在外观。 - active_section: usize, - /// tty7 Settings 同款左上角搜索。它过滤平铺导航,并把当前页定位到 - /// 第一个命中的高内聚 section;各 section 仍独立拥有自己的业务状态。 - settings_search_input: Entity, - selects: Vec<(&'static str, SharedSelect)>, - shell_select: SharedShellSelect, - dir_input: Entity, - /// 外观页使用组件库的真实交互控件;状态实体是渲染与拖拽的唯一来源。 - background_color: Entity, - opacity_slider: Entity, - wallpaper_opacity_slider: Entity, - proxy_url_input: Entity, - proxy_bypass_input: Entity, - provider_store: crate::ai_providers::ProviderStore, - /// Name / note / website / endpoint / model. API keys deliberately do not - /// use a GPUI text widget; the native credential dialog is write-only. - provider_inputs: Vec>, - provider_status: Option<(String, bool)>, - provider_test_seq: u64, - provider_test_running: bool, - provider_codex_confirm: Option, - /// SSH 主机列表(共享三键 + merge 权威);操作后整体重载防漂移。 - ssh_hosts: crate::gpui_shell::ssh_hosts::SshHostLists, - /// SSH 编辑器的文本字段常驻,以便所有文本改动都能使进行中的测试失效。 - ssh_destination_input: Entity, - ssh_port_input: Entity, - ssh_label_input: Entity, - ssh_password_input: Entity, - ssh_icon_select: SharedSelect, - ssh_editor: Option, - ssh_editor_seq: u64, - ssh_test_seq: u64, - ssh_status: Option<(String, bool)>, - ssh_show_hidden: bool, - /// 删除确认(二次点击生效,旧壳确认对话框的轻量对应)。 - ssh_delete_confirm: Option, - /// 字体选择器(旧壳 `SettingsDropdown::Font` 的 GPUI 形态):展开态 + - /// 「显示全部」临时过滤(不落盘)+ 惰性枚举的系统/导入字体目录。 - font_picker_open: bool, - font_show_all: bool, - font_loading: bool, - /// None = 尚未枚举;首次展开时在后台线程装配(几百字体的机器上 - /// `IsMonospacedFont` 逐族探询是实打实的开销,不挡 UI 帧)。 - font_system: Option>, - /// GPUI text system 已注册的导入族名(启动扫描 + 本次导入累计)。 - font_imported: Vec, - font_query_input: Entity, - /// 触发按钮上一帧的窗口坐标。字体目录是宽弹层,不能把整条设置行当 - /// 锚点;否则按钮在右侧、菜单却会从正文左缘展开。 - font_picker_trigger_bounds: Option>, - /// 导入/选择的错误驻留条(旧壳 `nebula_font_notice` 同义)。 - font_notice: Option, - /// 备份类别选择(本地 UI 态;出厂默认 = 共享 `BackupSelection::default`)。 - backup_selection: crate::encrypted_backup::BackupSelection, - /// 备份密码(masked;只在导出/恢复动作瞬时读取,不落任何配置)。 - backup_pass_input: Entity, - backup_status: Option<(String, bool)>, - /// 导出/恢复/推送进行中(按钮禁用 + 忽略过期完成回调)。 - backup_busy: bool, - backup_seq: u64, - /// 远端同步配置(`nebula_backup.txt` 共享权威)。 - backup_remote: crate::backup_remote::BackupRemoteConfig, - /// 非密文槽位输入(容量按最多槽位的协议 S3 = 4)。 - backup_remote_inputs: Vec>, - /// 密文槽(WebDAV 密码 / S3 Secret):masked,写入凭据管理器后即清。 - backup_secret_input: Entity, - /// 按键映射编辑器(旧壳 spec 002 的 GPUI 形态):搜索输入 + 捕获态 + - /// `keybind=` 行的工作镜像。模型层(combo 解析/展示/冲突/默认表)复用 - /// `display::keymap`,两壳同一套存储与语义。 - keymap_search_input: Entity, - keymap_capture: Option, - keymap_capture_preview: String, - keymap_binds: Vec<(String, String)>, - _subscriptions: Vec, -} - -impl SettingsPane { - pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let runtime = RuntimeSettings::load(); - let mut selects: Vec<(&'static str, SharedSelect)> = Vec::new(); - let mut subscriptions = Vec::new(); - - let mut add_select = |key: &'static str, - labels: &'static [&'static str], - values: &'static [&'static str], - current: &str, - window: &mut Window, - cx: &mut Context| { - let ix = values.iter().position(|v| *v == current).unwrap_or(0); - let select = cx.new(|cx| { - SelectState::new( - labels.iter().map(|l| SharedString::from(*l)).collect::>(), - Some(IndexPath::default().row(ix)), - window, - cx, - ) - }); - subscriptions.push(cx.subscribe_in( - &select, - window, - move |this: &mut Self, - entity: &SharedSelect, - event: &SelectEvent>, - _window: &mut Window, - cx: &mut Context| { - if let SelectEvent::Confirm(Some(_)) = event { - let row = entity.read(cx).selected_index(cx).map(|path| path.row); - if let Some(value) = row.and_then(|row| values.get(row)) { - this.persist(&[(key, (*value).to_string())], cx); - } - } - }, - )); - selects.push((key, select)); - }; - - let cursor_current = - runtime.cursor_shape.map(|shape| shape.settings_value()).unwrap_or("beam"); - let shell_current = runtime.shell.clone().unwrap_or_else(|| "powershell".into()); - - add_select( - "language", - &["跟随系统", "简体中文", "English"], - &["system", "zh-CN", "en-US"], - runtime.language.settings_value(), - window, - cx, - ); - add_select("theme", &THEME_VALUES, &THEME_VALUES, runtime.theme.prompt_name(), window, cx); - add_select( - "cursor_shape", - &["块状", "竖线", "下划线", "空心块"], - &["block", "beam", "underline", "hollow"], - cursor_current, - window, - cx, - ); - add_select( - "tab_reveal", - &["滑动", "立即"], - &["slide", "instant"], - runtime.tab_reveal.settings_value(), - window, - cx, - ); - add_select( - "density", - &["标准", "紧凑"], - &["standard", "compact"], - runtime.density.settings_value(), - window, - cx, - ); - add_select( - "new_tab_position", - &["当前标签之后", "列表末尾"], - &["after_current", "end"], - runtime.new_tab_position.settings_value(), - window, - cx, - ); - add_select( - "cell_width_mode", - &["紧凑", "宽松"], - &["compact", "relaxed"], - runtime.cell_width_mode.settings_value(), - window, - cx, - ); - add_select( - "accept", - &["→ 方向键", "Tab", "两者皆可"], - &["right", "tab", "both"], - runtime.accept.settings_value(), - window, - cx, - ); - add_select( - "completion_style", - &["内联 ghost", "弹出列表"], - &["inline", "popup"], - runtime.completion_style.settings_value(), - window, - cx, - ); - // 壁纸 fit/对齐:存原文,经旧壳 renderer::image 的 parse 归一化 - // (兼容 cover/contain 等别名),展示用规范记号。 - let bgimg_fit = crate::renderer::image::BackgroundImageFit::parse( - runtime.background_image_fit.as_deref().unwrap_or(""), - ) - .unwrap_or_default() - .settings_value(); - add_select( - "background_image_fit", - &["裁剪铺满", "完整显示", "拉伸", "原始尺寸"], - &["uniform_to_fill", "uniform", "fill", "none"], - bgimg_fit, - window, - cx, - ); - let bgimg_align = crate::renderer::image::BackgroundImageAlignment::parse( - runtime.background_image_alignment.as_deref().unwrap_or(""), - ) - .unwrap_or_default() - .settings_value(); - add_select( - "background_image_alignment", - &["居中", "左上", "上", "右上", "左", "右", "左下", "下", "右下"], - &[ - "center", - "top_left", - "top", - "top_right", - "left", - "right", - "bottom_left", - "bottom", - "bottom_right", - ], - bgimg_align, - window, - cx, - ); - add_select( - "ssh_proxy_mode", - &["关闭", "系统代理", "自定义"], - &["off", "system", "custom"], - runtime.ssh_proxy_mode.settings_value(), - window, - cx, - ); - - // 与旧壳的默认 Shell 菜单共用检测层:不能在设置页另维护一份两项 - // 白名单,否则 CMD/Nushell/WSL 会出现在新建终端菜单,却无法设为默认。 - // 选项 = 彩色品牌 PNG(extra/shell-icons,与旧壳设置页/命令面板同 - // 一批资产)+ 名称,闭态与下拉同源(SelectItem::display_title/render)。 - let shell_icon_scale = window.scale_factor().max(0.5); - let detected = crate::shell_detect::detect_shells(); - let mut shell_items: Vec = detected - .into_iter() - .map(|shell| ShellSelectItem::new(shell.id, shell.name, shell_icon_scale)) - .collect(); - if shell_items.is_empty() { - // 非 Windows 构建不做安装探测,但历史配置仍支持这两个由 PTY - // 集成层负责启动的稳定 id,设置页不能因此变成空下拉。 - shell_items = vec![ - ShellSelectItem::new( - "powershell".into(), - "PowerShell".into(), - shell_icon_scale, - ), - ShellSelectItem::new("bash".into(), "Git Bash".into(), shell_icon_scale), - ]; - } - if !shell_items.iter().any(|item| item.id == shell_current) { - // 检测结果可能暂时找不到已保存的 WSL/profile id;先把它保留在首位, - // 用户仍可看到并重新选择,下一次检测恢复后不会丢失持久化值。 - shell_items.insert( - 0, - ShellSelectItem::new( - shell_current.clone(), - crate::shell_detect::display_name_for_id(&shell_current).to_owned(), - shell_icon_scale, - ), - ); - } - let shell_index = shell_items - .iter() - .position(|item| item.id == shell_current) - .unwrap_or(0); - let shell_select = cx.new(|cx| { - SelectState::new(shell_items, Some(IndexPath::default().row(shell_index)), window, cx) - }); - subscriptions.push(cx.subscribe_in( - &shell_select, - window, - move |this: &mut Self, - _entity: &SharedShellSelect, - event: &SelectEvent>, - _window: &mut Window, - cx: &mut Context| { - if let SelectEvent::Confirm(Some(id)) = event { - this.persist(&[("shell", id.clone())], cx); - } - }, - )); - - let dir_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("留空 = 继承当前窗格目录") - .default_value(runtime.startup_directory.clone().unwrap_or_default()) - }); - let background_color_value = runtime - .background - .map(|[r, g, b]| rgb_hsla(r, g, b)) - .unwrap_or_else(|| cx.theme().background); - let background_color = cx.new(|cx| { - ColorPickerState::new(window, cx).default_value(background_color_value) - }); - subscriptions.push(cx.subscribe( - &background_color, - |this, _, event: &ColorPickerEvent, cx| { - if let ColorPickerEvent::Change(Some(color)) = event { - this.persist(&[("background", color.to_hex())], cx); - } - }, - )); - let opacity_slider = cx.new(|_| { - SliderState::new() - .min(0.20) - .max(1.00) - .step(0.05) - .default_value(runtime.opacity) - }); - subscriptions.push(cx.subscribe( - &opacity_slider, - |this, _, event: &SliderEvent, cx| match event { - SliderEvent::Change(value) => this.set_opacity(value.start(), cx), - }, - )); - let wallpaper_opacity_slider = cx.new(|_| { - SliderState::new() - .min(0.05) - .max(1.00) - .step(0.05) - .default_value(runtime.background_image_opacity) - }); - subscriptions.push(cx.subscribe( - &wallpaper_opacity_slider, - |this, _, event: &SliderEvent, cx| match event { - SliderEvent::Change(value) => this.set_wallpaper_opacity(value.start(), cx), - }, - )); - let proxy_url_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("socks5://127.0.0.1:7890") - .default_value(runtime.ssh_proxy_url.clone()) - }); - let proxy_bypass_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("逗号分隔,如 10.0.0.0/8,*.internal") - .default_value(runtime.ssh_proxy_no_proxy.clone()) - }); - let provider_store = crate::ai_providers::load(); - let active_provider = provider_store - .providers - .iter() - .find(|provider| provider.id == provider_store.active_id) - .or_else(|| provider_store.providers.first()) - .cloned() - .unwrap_or_else(|| { - crate::ai_providers::AiProvider::preset( - crate::ai_providers::ProviderKind::Custom, - "custom-1", - ) - }); - let provider_values = [ - active_provider.name, - active_provider.note, - active_provider.website_url, - active_provider.base_url, - active_provider.model, - ]; - let provider_placeholders = - ["供应商名称", "备注(可包含空格)", "官方网站", "API 请求地址", "默认模型"]; - let provider_inputs = provider_values - .into_iter() - .zip(provider_placeholders) - .map(|(value, placeholder)| { - cx.new(|cx| { - InputState::new(window, cx).placeholder(placeholder).default_value(value) - }) - }) - .collect(); - - // 远端备份的非密文槽位输入(容量按最多槽位的协议 S3 = 4);值在 - // 构造体内按当前协议回填。 - let backup_remote_inputs: Vec> = - (0..4).map(|_| cx.new(|cx| InputState::new(window, cx))).collect(); - - let ssh_destination_input = cx.new(|cx| { - InputState::new(window, cx).placeholder("user@host 或 ~/.ssh/config 别名") - }); - let ssh_port_input = cx.new(|cx| InputState::new(window, cx).placeholder("22")); - let ssh_label_input = cx.new(|cx| InputState::new(window, cx).placeholder("可选,留空自动命名")); - let ssh_password_input = - cx.new(|cx| InputState::new(window, cx).masked(true).placeholder("留空保留已有凭据")); - let ssh_icon_items = std::iter::once(SharedString::from("自动识别")) - .chain(crate::display::ui::os_icons::CATALOG.iter().map(|icon| { - SharedString::from(format!("{} {}", icon.glyph, icon.zh)) - })) - .collect::>(); - let ssh_icon_select = cx.new(|cx| { - SelectState::new( - ssh_icon_items, - Some(IndexPath::default().row(0)), - window, - cx, - ) - }); - subscriptions.push(cx.subscribe_in( - &ssh_icon_select, - window, - |this: &mut Self, - entity: &SharedSelect, - event: &SelectEvent>, - _window: &mut Window, - cx: &mut Context| { - if !matches!(event, SelectEvent::Confirm(Some(_))) { - return; - } - let Some(editor) = this.ssh_editor.as_mut() else { return }; - let row = entity.read(cx).selected_index(cx).map(|path| path.row).unwrap_or(0); - editor.icon = row - .checked_sub(1) - .and_then(|index| crate::display::ui::os_icons::CATALOG.get(index)) - .map(|icon| icon.id.to_owned()); - // 图标不影响连接有效性,因此保留测试结果;只清掉上一条 - // 保存/校验提示,避免它看起来仍在描述当前草稿。 - this.ssh_status = None; - cx.notify(); - }, - )); - for input in [ - ssh_destination_input.clone(), - ssh_port_input.clone(), - ssh_label_input.clone(), - ssh_password_input.clone(), - ] { - subscriptions.push(cx.subscribe_in( - &input, - window, - |this: &mut Self, _, event: &InputEvent, _, cx: &mut Context| { - if matches!(event, InputEvent::Change) { - this.touch_ssh_editor(cx); - } - }, - )); - } - - let settings_search_input = - cx.new(|cx| InputState::new(window, cx).placeholder("搜索设置…")); - subscriptions.push(cx.subscribe_in( - &settings_search_input, - window, - |this: &mut Self, - input: &Entity, - event: &InputEvent, - _: &mut Window, - cx: &mut Context| { - if !matches!(event, InputEvent::Change) { - return; - } - let query = input.read(cx).value().to_string(); - if !section_matches(this.active_section, &query) { - if let Some(first) = NAV_ORDER - .iter() - .copied() - .find(|index| section_matches(*index, &query)) - { - this.active_section = first; - } - } - cx.notify(); - }, - )); - - let font_query_input = cx.new(|cx| InputState::new(window, cx).placeholder("搜索字体…")); - subscriptions.push(cx.subscribe_in( - &font_query_input, - window, - |_: &mut Self, _, event: &InputEvent, _, cx| { - // 列表跟着打字走(旧壳「所见即所搜」);目录装配在渲染时按 - // 当前查询串现算,这里只需要触发一帧。 - if matches!(event, InputEvent::Change) { - cx.notify(); - } - }, - )); - - Self { - focus_handle: cx.focus_handle(), - runtime, - active_section: 0, - settings_search_input, - selects, - shell_select, - dir_input, - background_color, - opacity_slider, - wallpaper_opacity_slider, - proxy_url_input, - proxy_bypass_input, - provider_store, - provider_inputs, - provider_status: None, - provider_test_seq: 0, - provider_test_running: false, - provider_codex_confirm: None, - ssh_hosts: crate::gpui_shell::ssh_hosts::SshHostLists::load(), - ssh_destination_input, - ssh_port_input, - ssh_label_input, - ssh_password_input, - ssh_icon_select, - ssh_editor: None, - ssh_editor_seq: 0, - ssh_test_seq: 0, - ssh_status: None, - ssh_show_hidden: false, - ssh_delete_confirm: None, - font_picker_open: false, - font_show_all: false, - font_loading: false, - font_system: None, - font_imported: Vec::new(), - font_query_input, - font_picker_trigger_bounds: None, - font_notice: None, - backup_selection: crate::encrypted_backup::BackupSelection::default(), - backup_pass_input: cx.new(|cx| { - InputState::new(window, cx).masked(true).placeholder("备份密码(至少 8 位)") - }), - backup_status: None, - backup_busy: false, - backup_seq: 0, - backup_remote: { - let cfg = crate::backup_remote::BackupRemoteConfig::load(); - for (ix, input) in backup_remote_inputs.iter().enumerate() { - let value = cfg.slot(ix).unwrap_or_default().to_owned(); - input.update(cx, |input, cx| input.set_value(value, window, cx)); - } - cfg - }, - backup_remote_inputs, - backup_secret_input: cx.new(|cx| { - InputState::new(window, cx).masked(true).placeholder("凭据只写入系统凭据管理器") - }), - keymap_search_input: { - let input = cx.new(|cx| InputState::new(window, cx).placeholder("搜索动作或按键…")); - subscriptions.push(cx.subscribe_in( - &input, - window, - |_this: &mut Self, _: &Entity, event: &InputEvent, _: &mut Window, cx: &mut Context| { - // 搜索词变化只影响可见行集合;捕获态不因打字被打断 - // (捕获期间焦点在分区根上,输入框收不到键)。 - if matches!(event, InputEvent::Change) { - cx.notify(); - } - }, - )); - input - }, - keymap_capture: None, - keymap_capture_preview: String::new(), - keymap_binds: nebula_settings::keybind_pairs(), - _subscriptions: subscriptions, - } - } - - /// 写盘 → 重载单一事实源与全局 `Settings` → 通知宿主热应用。 - fn persist(&mut self, updates: &[(&str, String)], cx: &mut Context) { - if let Err(err) = persist_keys(updates) { - eprintln!("[nebula:gpui] failed to persist settings: {err}"); - } - self.runtime = RuntimeSettings::load(); - let settings = crate::gpui_shell::config::Settings::load( - crate::gpui_shell::theme::effective_theme_name(cx), - ); - cx.set_global(settings); - cx.emit(SettingsPaneEvent::Changed); - cx.notify(); - } - - fn toggle(&mut self, key: &'static str, value: bool, cx: &mut Context) { - self.persist(&[(key, (value as u8).to_string())], cx); - } - - /// Workspace tab、设置导航和设置内容共用的主文字字号事实源。 - fn font_size_px(&self, cx: &App) -> f32 { - cx.global::().font_size_px - } - - fn set_font_size(&mut self, size: f32, cx: &mut Context) { - let size = size.clamp(4.0, 96.0); - self.persist(&[("font_size", format!("{size:.2}"))], cx); - } - - fn set_opacity(&mut self, opacity: f32, cx: &mut Context) { - let opacity = opacity.clamp(0.2, 1.0); - self.persist(&[("opacity", format!("{opacity:.2}"))], cx); - } - - fn set_wallpaper_opacity(&mut self, opacity: f32, cx: &mut Context) { - let opacity = opacity.clamp(0.05, 1.0); - self.persist(&[("background_image_opacity", format!("{opacity:.2}"))], cx); - } - - // ---- 字体选择器(旧壳 toggle_font_picker / font_catalog 的 GPUI 形态)---- - - /// 当前生效字体链(settings.txt 覆盖 toml 后的值,可能含逗号 fallback)。 - fn current_font_chain(&self, cx: &App) -> String { - cx.try_global::() - .map(|settings| settings.font_family.clone()) - .unwrap_or_else(|| String::from(crate::font_install::REQUIRED_FONT_FAMILY)) - } - - fn toggle_font_picker(&mut self, window: &mut Window, cx: &mut Context) { - if self.font_picker_open { - self.close_font_picker(window, true, cx); - return; - } - - // 旧壳每次重新展开都从完整目录开始,搜索串和上次错误不跨弹层 - // 生命周期残留;昂贵目录只在第一次打开时异步枚举。 - self.font_notice = None; - self.font_query_input.update(cx, |input, cx| input.set_value("", window, cx)); - self.font_picker_open = true; - self.ensure_font_catalog(cx); - self.font_query_input.update(cx, |input, cx| input.focus(window, cx)); - cx.notify(); - } - - /// 关闭字体弹层并清理这次展开的临时查询。`restore_focus` 只在 Esc、 - /// 选中和点击外部时使用;系统文件选择器接管焦点期间不强抢窗口焦点。 - fn close_font_picker( - &mut self, - window: &mut Window, - restore_focus: bool, - cx: &mut Context, - ) { - if !self.font_picker_open { - return; - } - self.font_picker_open = false; - self.font_query_input.update(cx, |input, cx| input.set_value("", window, cx)); - if restore_focus { - window.focus(&self.focus_handle); - } - cx.notify(); - } - - /// 惰性装配字体目录:系统族枚举 + 已导入文件逐个探测族名,全部丢 - /// 后台线程(旧壳在 UI 线程同步做;GPUI 帧预算更紧,不值得卡一帧)。 - fn ensure_font_catalog(&mut self, cx: &mut Context) { - if self.font_system.is_some() || self.font_loading { - return; - } - #[cfg(windows)] - { - self.font_loading = true; - let task = cx.background_executor().spawn(async move { - let system = crate::font_install::enumerate_system_font_families(); - let imported: Vec = crate::font_install::imported_font_files() - .iter() - .filter_map(|path| crate::font_install::probe_font_file_families(path).ok()) - .flatten() - .collect(); - (system, imported) - }); - cx.spawn(async move |this, cx| { - let (system, imported) = task.await; - let _ = this.update(cx, |pane, cx| { - pane.font_system = Some(system); - for family in imported { - if !pane - .font_imported - .iter() - .any(|known| known.eq_ignore_ascii_case(&family)) - { - pane.font_imported.push(family); - } - } - pane.font_loading = false; - cx.notify(); - }); - }) - .detach(); - } - #[cfg(not(windows))] - { - self.font_system = Some(Vec::new()); - } - } - - /// 选中即生效:只换主族、保留用户手写的 fallback 链(共享 - /// `replace_primary_font_family`),写盘后经 `persist` 的 Changed 事件 - /// 让宿主逐终端热应用——字体度量变化会走 viewport observe 自动重排 - /// 网格并 resize PTY。 - fn pick_font_family( - &mut self, - family: String, - window: &mut Window, - cx: &mut Context, - ) { - let chain = self.current_font_chain(cx); - let next = crate::font_install::replace_primary_font_family(&chain, &family); - self.font_notice = None; - self.close_font_picker(window, true, cx); - self.persist(&[("font_family", next)], cx); - } - - #[cfg(windows)] - fn import_font_file(&mut self, window: &mut Window, cx: &mut Context) { - let receiver = cx.prompt_for_paths(gpui::PathPromptOptions { - files: true, - directories: false, - multiple: false, - prompt: Some("导入终端字体".into()), - }); - cx.spawn_in(window, async move |this, cx| { - let Ok(Ok(Some(paths))) = receiver.await else { return }; - let Some(source) = paths.into_iter().next() else { return }; - let _ = this.update_in(cx, |pane, window, cx| { - pane.finish_font_import(&source, window, cx); - }); - }) - .detach(); - } - - /// 导入落库(共享 `store_imported_font`:哈希去重存入数据目录)→ - /// DirectWrite 探测族名 → 注册进 GPUI text system → 立即应用首族 - /// (旧壳 `set_terminal_font_by_index` 的导入分支同义)。 - #[cfg(windows)] - fn finish_font_import( - &mut self, - source: &std::path::Path, - window: &mut Window, - cx: &mut Context, - ) { - let stored = match crate::font_install::store_imported_font(source) { - Ok(stored) => stored, - Err(error) => { - self.font_notice = Some(error); - cx.notify(); - return; - }, - }; - let cleanup = |stored: &crate::font_install::StoredFont| { - if stored.created { - let _ = std::fs::remove_file(&stored.path); - } - }; - let families = match crate::font_install::probe_font_file_families(&stored.path) { - Ok(families) => families, - Err(error) => { - cleanup(&stored); - self.font_notice = Some(format!("字体无法加载:{error}")); - cx.notify(); - return; - }, - }; - let bytes = match std::fs::read(&stored.path) { - Ok(bytes) => bytes, - Err(error) => { - cleanup(&stored); - self.font_notice = Some(format!("无法读取导入字体:{error}")); - cx.notify(); - return; - }, - }; - if let Err(error) = cx.text_system().add_fonts(vec![std::borrow::Cow::Owned(bytes)]) { - cleanup(&stored); - self.font_notice = Some(format!("字体注册失败:{error}")); - cx.notify(); - return; - } - for family in &families { - if !self.font_imported.iter().any(|known| known.eq_ignore_ascii_case(family)) { - self.font_imported.push(family.clone()); - } - } - if let Some(first) = families.into_iter().next() { - self.pick_font_family(first, window, cx); - } - } - - /// 字体行(收起态):用真实 GPUI Button 承载下拉锚点。当前主族继承 - /// 设置页正在使用的字体链,因而按钮文字本身就是 WYSIWYG 预览。 - fn font_picker_row(&self, cx: &mut Context) -> gpui::AnyElement { - let chain = self.current_font_chain(cx); - let primary: SharedString = crate::renderer::primary_font_family(&chain).to_owned().into(); - let picker = cx.entity().downgrade(); - let control = div() - .relative() - .w(px(220.0)) - .child( - Button::new("font-picker-toggle") - .w_full() - .selected(self.font_picker_open) - .child( - h_flex() - .w_full() - .min_w_0() - .justify_between() - .gap_2() - .child( - div() - .flex_1() - .min_w_0() - .truncate() - .text_left() - .font_family(primary.clone()) - .child(primary), - ) - .child( - Icon::new(if self.font_picker_open { - IconName::ChevronUp - } else { - IconName::ChevronDown - }) - .xsmall(), - ), - ) - .on_click(cx.listener(|this, _, window, cx| { - this.toggle_font_picker(window, cx); - })), - ) - // 与组件库 Popover 相同:用零绘制 canvas 捕获触发控件真实 - // Bounds。坐标来自布局结果,滚动、DPI 和窗口缩放后仍然准确。 - .child( - gpui::canvas( - move |bounds, _, cx| { - let _ = picker.update(cx, |picker, _| { - picker.font_picker_trigger_bounds = Some(bounds); - }); - }, - |_, _, _, _| {}, - ) - .absolute() - .size_full(), - ); - Self::row("字体", control).into_any_element() - } - - /// 展开面板:搜索 + 「显示全部」临时过滤 + 目录列表(真实字体预览、 - /// 等宽/导入/内置徽标、当前项高亮)+ 导入按钮。目录装配走共享 - /// `font_catalog`(同名合并、当前项保底),内置字体按旧壳规则恒排首位。 - fn font_picker_panel(&mut self, cx: &mut Context) -> gpui::Div { - use crate::font_install::{FontCatalogEntry, FontSource, REQUIRED_FONT_FAMILY}; - - let theme = cx.theme(); - let muted = theme.muted_foreground; - let hover_bg = theme.list_hover; - let selected_bg = theme.list_active; - let warning = theme.warning; - let chain = self.current_font_chain(cx); - let primary = crate::renderer::primary_font_family(&chain).to_owned(); - let query = self.font_query_input.read(cx).value().to_string(); - let system = self.font_system.clone().unwrap_or_default(); - let mut catalog = crate::font_install::font_catalog( - &system, - &self.font_imported, - self.font_show_all, - &query, - &primary, - ); - // 内置字体永远排在最前(旧壳 rebuild_font_catalog 同规则), - // 搜索不命中也不藏——它是出厂兜底,消失会让人以为坏了。 - catalog.retain(|entry| !entry.name.eq_ignore_ascii_case(REQUIRED_FONT_FAMILY)); - catalog.insert( - 0, - FontCatalogEntry { - name: REQUIRED_FONT_FAMILY.to_owned(), - monospaced: true, - source: FontSource::Imported, - }, - ); - - let badge = |text: &'static str, color: Hsla| { - div() - .px(px(6.0)) - .py(px(1.0)) - .rounded_sm() - .text_xs() - .text_color(color) - .border_1() - .border_color(color.opacity(0.4)) - .child(text) - }; - - let rows: Vec<_> = catalog - .into_iter() - .enumerate() - .map(|(ix, entry)| { - let name = entry.name.clone(); - let family: SharedString = entry.name.clone().into(); - let selected = entry.name.eq_ignore_ascii_case(&primary); - let required = entry.name.eq_ignore_ascii_case(REQUIRED_FONT_FAMILY); - h_flex() - .id(SharedString::from(format!("font-row-{ix}"))) - .h(px(36.0)) - .w_full() - .px_2() - .gap_2() - .items_center() - .rounded_md() - .cursor_pointer() - .when(selected, |row| row.bg(selected_bg)) - .hover(|row| row.bg(hover_bg)) - .child( - div() - .flex_1() - .min_w_0() - .truncate() - .text_sm() - .font_family(family.clone()) - .child(entry.name.clone()), - ) - .when(required, |row| row.child(badge("内置", muted))) - .when(!required && entry.source == FontSource::Imported, |row| { - row.child(badge("导入", muted)) - }) - .when(!entry.monospaced, |row| row.child(badge("比例", warning))) - .child( - div() - .flex_shrink_0() - .text_sm() - .text_color(muted) - .font_family(family) - .child("AaBb 123 终端"), - ) - .on_click(cx.listener(move |this, _, window, cx| { - this.pick_font_family(name.clone(), window, cx); - })) - }) - .collect(); - let empty = rows.is_empty(); - - v_flex() - // 面板由字体行的 deferred/anchored 槽托管,不参与设置文档流高度; - // 固定宽度让族名、徽标和 WYSIWYG 样张同时可读,窄窗由 anchored - // 自动贴边,行为与组件库 Select 的弹层一致。 - .w(px(520.0)) - .max_w_full() - .p_3() - .gap_2() - // 与 Select/Popover 共用组件库表面,避免字体菜单成为另一套 - // 边框、圆角和阴影语言。 - .popover_style(cx) - .occlude() - .child( - h_flex() - .gap_3() - .items_center() - .child(div().flex_1().child(Input::new(&self.font_query_input))) - .child(div().text_sm().text_color(muted).child("显示全部")) - .child(Switch::new("font-show-all").checked(self.font_show_all).on_click( - cx.listener(|this, checked: &bool, _, cx| { - // 临时过滤,不写入设置(旧壳 toggle_font_show_all)。 - this.font_show_all = *checked; - cx.notify(); - }), - )), - ) - .when(self.font_loading, |panel| { - panel.child( - h_flex() - .gap_2() - .items_center() - .child(Spinner::new().xsmall()) - .child(div().text_sm().text_color(muted).child("正在枚举系统字体…")), - ) - }) - .when(!self.font_loading, |panel| { - panel.child( - v_flex().max_h(px(320.0)).gap_1().overflow_y_scrollbar().children(rows).when( - empty, - |list| { - list.child( - div().py_2().text_sm().text_color(muted).child("没有匹配的字体"), - ) - }, - ), - ) - }) - .child( - h_flex() - .gap_2() - .items_center() - .child({ - let button = Button::new("font-import").label("导入字体文件…").small(); - #[cfg(windows)] - let button = button.on_click(cx.listener(|this, _, window, cx| { - this.import_font_file(window, cx); - })); - button - }) - .child( - div().text_xs().text_color(muted).child("支持 .ttf / .otf / .ttc / .otc"), - ) - .when_some(self.font_notice.clone(), |row, notice| { - row.child(div().text_xs().text_color(theme.danger).child(notice)) - }), - ) - } - - /// 字体行和浮层的锚点。使用 GPUI 与 `Popover` 相同的真实触发框 - /// Bounds + `deferred(anchored())` 管线:浮层不参与文档流,右边缘与 - /// 按钮右边缘对齐,窗口空间不足时再由 anchored 贴边收拢。 - fn font_picker_dropdown(&mut self, cx: &mut Context) -> gpui::Div { - let row = self.font_picker_row(cx); - let panel = self.font_picker_open.then(|| self.font_picker_panel(cx)); - let trigger_bounds = self.font_picker_trigger_bounds; - - div() - .relative() - .w_full() - .h(px(SETTINGS_ROW_HEIGHT)) - .flex_shrink_0() - .child(row) - .when_some(panel.zip(trigger_bounds), |anchor, (panel, trigger_bounds)| { - anchor.child( - deferred( - anchored() - .anchor(gpui::Corner::TopRight) - .position(trigger_bounds.bottom_right()) - .offset(gpui::point(px(0.0), px(6.0))) - .snap_to_window_with_margin(px(8.0)) - .child(panel), - ) - // 页面级透明拦截层优先级为普通元素;弹层必须始终压在 - // 它上面,否则鼠标到不了搜索框和候选行。 - .with_priority(2), - ) - }) - } - - fn select_of(&self, key: &str) -> Option { - self.selects.iter().find(|(k, _)| *k == key).map(|(_, entity)| entity.clone()) - } - - /// 旧壳设置页靠留白与标题分组,不用外层线框把每段内容圈成盒子。 - fn group(&self, title: &'static str, cx: &Context) -> gpui::Div { - // 分组标题与侧栏 tab、设置导航共用正文基准字号和 Regular 字重; - // 层级只靠颜色与留白表达,避免标题额外放大后整页出现三套字号。 - let title_px = self.font_size_px(cx); - v_flex() - .w_full() - .child( - div() - .h(px(SETTINGS_GROUP_TITLE_HEIGHT)) - .flex() - .items_center() - .text_size(px(title_px)) - .font_weight(gpui::FontWeight::NORMAL) - .text_color(cx.theme().sidebar_accent_foreground) - .child(title), - ) - // 旧壳组标题占 26px,首行位于标题顶端下方 42px;中间这 16px - // 是形成分组呼吸感的关键,不能交给组件默认 gap 随意变化。 - .child(div().h(px(SETTINGS_GROUP_TITLE_GAP)).flex_shrink_0()) - } - - /// 一级设置组之间的分隔。容器高度保持原有 32px 组间距,细线位于 - /// 中央,因此增加层级提示但不改变页面纵向密度。 - fn group_divider(cx: &Context) -> gpui::Div { - div() - .w_full() - .h(px(SETTINGS_GROUP_GAP)) - .flex_shrink_0() - .flex() - .items_center() - .child(div().w_full().h(px(1.0)).bg(cx.theme().border)) - } - - fn row(label: &'static str, control: impl IntoElement) -> impl IntoElement { - h_flex() - .w_full() - .h(px(SETTINGS_ROW_HEIGHT)) - .flex_shrink_0() - .items_center() - // 旧壳 `settings_geometry`:标签起 row_x+16,控件右缘距行右 16 - // (ROW_INSET)。GPUI 行内两端原来都贴边,长表单左缘和右缘会 - // 呈一条光柱——旧壳的两档缩进是刻意留的呼吸边。 - .pr_4() - .child(div().flex_1().min_w_0().pl_4().child(label)) - .child(control) - } - - fn select_row(&self, key: &'static str, label: &'static str, cx: &Context) -> impl IntoElement { - let select = self.select_of(key); - // 闭态选中值 = accent(旧壳 combobox_value 15 处调用 14 处传 - // sk.accent)。闭框/背景都不带文字色,包一层就能继承下去;右侧 - // chevron 在组件内自带 muted,不会被染色。 - Self::row( - label, - div() - .w(px(220.0)) - .text_color(cx.theme().link) - .children(select.map(|state| Select::new(&state))), - ) - } - - fn shell_select_row(&self, cx: &Context) -> impl IntoElement { - let family: SharedString = cx - .try_global::() - .map(|settings| settings.font_family.clone()) - .unwrap_or_else(|| String::from("Maple Mono Normal NF CN")) - .into(); - Self::row( - "默认 Shell", - div() - .w(px(220.0)) - .font_family(family) - .text_color(cx.theme().link) - .child(Select::new(&self.shell_select)), - ) - } - - fn appearance_preview(&self, cx: &Context) -> gpui::Div { - let theme = chrome_theme(self.runtime.theme); - let palette = theme.palette(); - let ink = theme.card_ink().fg; - let background = self.runtime.background.unwrap_or([ - palette.term_bg.r, - palette.term_bg.g, - palette.term_bg.b, - ]); - let family: SharedString = cx - .try_global::() - .map(|settings| settings.font_family.clone()) - .unwrap_or_else(|| String::from("Maple Mono Normal NF CN")) - .into(); - let size = self.font_size_px(cx).clamp(11.0, 20.0); - let foreground = rgb_hsla(ink.r, ink.g, ink.b); - let accent = theme.accent(); - - v_flex() - .w_full() - .h(px(150.0)) - .p_4() - .gap_1() - .rounded_lg() - .bg(rgb_hsla(background[0], background[1], background[2])) - .font_family(family) - .text_size(px(size)) - .text_color(foreground) - .child("user@nebula ~ $ nebula --version") - .child(format!( - "Nebula Terminal · {} · {:.0}px", - self.runtime.font_family.as_deref().unwrap_or("Maple Mono Normal NF CN"), - size - )) - .child( - h_flex() - .gap_1() - .items_center() - .child(div().text_color(rgb_hsla(accent.r, accent.g, accent.b)).child("❯")) - .child(div().w(px(8.0)).h(px(size)).bg(foreground)), - ) - } - - fn theme_previews(&self, cx: &mut Context) -> gpui::Div { - h_flex().w_full().flex_wrap().gap(px(20.0)).children(THEME_NAMES.into_iter().map(|name| { - let theme = chrome_theme(name); - let palette = theme.palette(); - let accent = theme.accent(); - let selected = self.runtime.theme == name; - v_flex() - .id(SharedString::from(format!("theme-preview-{}", name.prompt_name()))) - .w(px(170.0)) - .h(px(92.0)) - .gap_2() - .cursor_pointer() - .child( - v_flex() - .h(px(64.0)) - .flex_shrink_0() - .p_2() - .gap_1() - .rounded_lg() - .bg(rgb_hsla(palette.shell_bg.r, palette.shell_bg.g, palette.shell_bg.b)) - .when(selected, |card| { - card.border_1().border_color(rgb_hsla(accent.r, accent.g, accent.b)) - }) - .when(!selected, |card| card.hover(|style| style.shadow_md())) - .child( - v_flex() - .flex_1() - .p_2() - .gap_1() - .rounded_md() - .bg(rgb_hsla( - palette.term_bg.r, - palette.term_bg.g, - palette.term_bg.b, - )) - .child( - h_flex() - .gap_1() - .child( - div() - .w(px(10.0)) - .h(px(4.0)) - .rounded_full() - .bg(rgb_hsla(accent.r, accent.g, accent.b)), - ) - .child(div().w(px(52.0)).h(px(4.0)).rounded_full().bg( - rgb_hsla( - theme.card_ink().fg.r, - theme.card_ink().fg.g, - theme.card_ink().fg.b, - ), - )), - ) - .child(div().w(px(82.0)).h(px(4.0)).rounded_full().bg(rgb_hsla( - palette.edge_l.r, - palette.edge_l.g, - palette.edge_l.b, - ))) - .child(div().w(px(58.0)).h(px(4.0)).rounded_full().bg(rgb_hsla( - palette.edge_r.r, - palette.edge_r.g, - palette.edge_r.b, - ))), - ), - ) - .child( - div() - .w_full() - .text_xs() - .text_center() - .text_color(if selected { - rgb_hsla(accent.r, accent.g, accent.b) - } else { - cx.theme().muted_foreground - }) - .child(theme.short_label()), - ) - .on_click(cx.listener(move |this, _, _, cx| { - this.persist(&[("theme", name.prompt_name().to_owned())], cx); - })) - })) - } - - /// 布尔项 = 滑动开关(旧壳设置的 toggle pill 形态),不是勾选框。 - fn switch_row( - &self, - key: &'static str, - label: &'static str, - checked: bool, - cx: &mut Context, - ) -> impl IntoElement { - Self::row( - label, - Switch::new(key).checked(checked).on_click(cx.listener( - move |this, checked: &bool, _, cx| { - this.toggle(key, *checked, cx); - }, - )), - ) - } - - /// 文本输入 + 保存按钮(保存时读值写盘)。 - fn input_row( - &self, - label: &'static str, - key: &'static str, - input: &Entity, - cx: &mut Context, - ) -> impl IntoElement { - let state = input.clone(); - Self::row( - label, - h_flex() - .gap_2() - .items_center() - .child(div().w(px(280.0)).child(Input::new(input))) - .child( - Button::new(SharedString::from(format!("save-{key}"))) - .label("保存") - .small() - .on_click(cx.listener(move |this, _, _, cx| { - let value = state.read(cx).value().to_string(); - this.persist(&[(key, value)], cx); - })), - ), - ) - } - - fn stepper_row( - &self, - label: &'static str, - id: &'static str, - display: SharedString, - on_minus: impl Fn(&mut Self, &mut Context) + 'static, - on_plus: impl Fn(&mut Self, &mut Context) + 'static, - cx: &mut Context, - ) -> impl IntoElement { - Self::row( - label, - h_flex() - .gap_2() - .items_center() - .child( - Button::new(SharedString::from(format!("minus-{id}"))) - .label("−") - .small() - .on_click(cx.listener(move |this, _, _, cx| { - on_minus(this, cx); - })), - ) - .child(div().min_w(px(64.0)).child(display)) - .child( - Button::new(SharedString::from(format!("plus-{id}"))) - .label("+") - .small() - .on_click(cx.listener(move |this, _, _, cx| { - on_plus(this, cx); - })), - ), - ) - } - - /// 旧壳连续量使用轨道而不是加减按钮;数值和轨道共享同一个 - /// `SliderState`,拖动时由订阅统一写盘并热应用。 - fn slider_row( - &self, - label: &'static str, - state: &Entity, - display: SharedString, - ) -> impl IntoElement { - Self::row( - label, - h_flex() - .w(px(220.0)) - .items_center() - .gap_3() - .child(div().flex_1().min_w_0().child(Slider::new(state))) - .child( - div() - .w(px(48.0)) - .flex_shrink_0() - // 固定数值列宽,百分比位数变化时轨道不会左右跳动。 - .child(display), - ), - ) - } - - fn background_color_row(&self, cx: &mut Context) -> impl IntoElement { - let featured = crate::display::BACKGROUND_SWATCHES - .iter() - .map(|color| { - let (r, g, b) = color.as_tuple(); - rgb_hsla(r, g, b) - }) - .collect::>(); - let current: SharedString = self - .runtime - .background - .map(format_hex_rgb) - .unwrap_or_else(|| "跟随主题".to_owned()) - .into(); - let custom = self.runtime.background.is_some(); - Self::row( - "背景色", - h_flex() - .w(px(280.0)) - .items_center() - .gap_2() - .child( - ColorPicker::new(&self.background_color) - .featured_colors(featured) - .small(), - ) - .child(div().flex_1().min_w_0().truncate().child(current)) - .child( - Button::new("background-color-reset") - .label("跟随主题") - .small() - .disabled(!custom) - .on_click(cx.listener(|this, _, window, cx| { - let theme_background = cx.theme().background; - this.background_color.update(cx, |state, cx| { - state.set_value(theme_background, window, cx); - }); - this.persist(&[("background", String::new())], cx); - })), - ), - ) - } - - fn choose_background_image(&mut self, cx: &mut Context) { - let picked = cx.prompt_for_paths(gpui::PathPromptOptions { - files: true, - directories: false, - multiple: false, - prompt: Some("选择终端背景图片".into()), - }); - cx.spawn(async move |this, cx| { - let Ok(Ok(Some(paths))) = picked.await else { return }; - let Some(path) = paths.into_iter().next() else { return }; - let value = path.to_string_lossy().into_owned(); - let _ = this.update(cx, |pane, cx| { - pane.persist(&[("background_image", value)], cx); - }); - }) - .detach(); - } - - fn background_image_row(&self, cx: &mut Context) -> impl IntoElement { - let current = self.runtime.background_image.clone(); - let has_image = current.as_ref().is_some_and(|path| !path.trim().is_empty()); - let display: SharedString = current - .as_deref() - .filter(|path| !path.trim().is_empty()) - .unwrap_or("未选择") - .to_owned() - .into(); - Self::row( - "背景图片", - h_flex() - .w(px(420.0)) - .items_center() - .gap_2() - .child( - div() - .flex_1() - .min_w_0() - .text_color(cx.theme().muted_foreground) - .truncate() - .child(display), - ) - .child( - Button::new("background-image-choose") - .label("选择图片…") - .small() - .on_click(cx.listener(|this, _, _, cx| { - this.choose_background_image(cx); - })), - ) - .child( - Button::new("background-image-clear") - .label("清除") - .small() - .disabled(!has_image) - .on_click(cx.listener(|this, _, _, cx| { - this.persist(&[("background_image", String::new())], cx); - })), - ), - ) - } - - fn active_provider_index(&self) -> Option { - self.provider_store - .providers - .iter() - .position(|provider| provider.id == self.provider_store.active_id) - .or_else(|| (!self.provider_store.providers.is_empty()).then_some(0)) - } - - fn sync_provider_inputs(&self, window: &mut Window, cx: &mut Context) { - let Some(index) = self.active_provider_index() else { return }; - let draft = - crate::ai_providers::ProviderMetadataDraft::from(&self.provider_store.providers[index]); - let values = [draft.name, draft.note, draft.website_url, draft.base_url, draft.model]; - for (input, value) in self.provider_inputs.iter().zip(values) { - input.update(cx, |input, cx| input.set_value(value, window, cx)); - } - } - - fn select_provider(&mut self, id: String, window: &mut Window, cx: &mut Context) { - if self.provider_store.providers.iter().any(|provider| provider.id == id) { - self.provider_store.active_id = id; - let _ = crate::ai_providers::save(&self.provider_store); - self.provider_codex_confirm = None; - self.provider_status = None; - self.sync_provider_inputs(window, cx); - cx.notify(); - } - } - - fn save_provider_metadata(&mut self, cx: &mut Context) -> bool { - let Some(index) = self.active_provider_index() else { return false }; - let values: Vec = - self.provider_inputs.iter().map(|input| input.read(cx).value().to_string()).collect(); - let draft = crate::ai_providers::ProviderMetadataDraft { - name: values[0].clone(), - note: values[1].clone(), - website_url: values[2].clone(), - base_url: values[3].clone(), - model: values[4].clone(), - }; - crate::ai_providers::apply_metadata_draft(&mut self.provider_store.providers[index], draft); - match crate::ai_providers::save(&self.provider_store) { - Ok(()) => { - self.provider_status = Some(("供应商配置已保存".to_owned(), false)); - true - }, - Err(error) => { - self.provider_status = Some((error.to_string(), true)); - false - }, - } - } - - fn add_provider(&mut self, window: &mut Window, cx: &mut Context) { - self.save_provider_metadata(cx); - let id = crate::ai_providers::next_custom_id(&self.provider_store); - self.provider_store.providers.push(crate::ai_providers::AiProvider::preset( - crate::ai_providers::ProviderKind::Custom, - &id, - )); - self.provider_store.active_id = id; - self.provider_codex_confirm = None; - match crate::ai_providers::save(&self.provider_store) { - Ok(()) => self.provider_status = Some(("已添加自定义供应商".to_owned(), false)), - Err(error) => self.provider_status = Some((error.to_string(), true)), - } - self.sync_provider_inputs(window, cx); - cx.notify(); - } - - fn delete_provider(&mut self, window: &mut Window, cx: &mut Context) { - let Some(index) = self.active_provider_index() else { return }; - if self.provider_store.providers.len() <= 1 { - self.provider_status = Some(("至少保留一个供应商".to_owned(), true)); - cx.notify(); - return; - } - let id = self.provider_store.providers[index].id.clone(); - match crate::ai_providers::remove_provider(&mut self.provider_store, &id) { - Ok(()) => { - self.provider_status = Some(("供应商及其凭据已删除".to_owned(), false)); - self.provider_codex_confirm = None; - self.sync_provider_inputs(window, cx); - }, - Err(error) => self.provider_status = Some((error.to_string(), true)), - } - cx.notify(); - } - - fn toggle_provider_flag(&mut self, flag: &'static str, value: bool, cx: &mut Context) { - let Some(index) = self.active_provider_index() else { return }; - let provider = &mut self.provider_store.providers[index]; - match flag { - "enabled" => provider.enabled = value, - "codex_goals" => provider.codex_goals = value, - "codex_remote_compaction" => provider.codex_remote_compaction = value, - _ => return, - } - self.provider_codex_confirm = None; - if let Err(error) = crate::ai_providers::save(&self.provider_store) { - self.provider_status = Some((error.to_string(), true)); - } - cx.notify(); - } - - fn prompt_provider_key(&mut self, cx: &mut Context) { - let Some(index) = self.active_provider_index() else { return }; - let provider = &mut self.provider_store.providers[index]; - match crate::ai_providers::prompt_and_store_api_key(provider) { - Ok(true) => { - self.provider_status = Some(("API Key 已保存到系统凭据管理器".to_owned(), false)); - if let Err(error) = crate::ai_providers::save(&self.provider_store) { - self.provider_status = Some((error.to_string(), true)); - } - }, - Ok(false) => {}, - Err(error) => self.provider_status = Some((error.to_string(), true)), - } - cx.notify(); - } - - fn test_provider(&mut self, cx: &mut Context) { - if self.provider_test_running || !self.save_provider_metadata(cx) { - return; - } - let Some(index) = self.active_provider_index() else { return }; - let provider = self.provider_store.providers[index].clone(); - self.provider_test_seq = self.provider_test_seq.wrapping_add(1); - let sequence = self.provider_test_seq; - self.provider_test_running = true; - self.provider_status = Some(("正在测试连接…".to_owned(), false)); - - let task = cx - .background_executor() - .spawn(async move { crate::ai_providers::test_provider(&provider) }); - cx.spawn(async move |this, cx| { - let result = task.await; - let _ = this.update(cx, |pane, cx| { - if sequence != pane.provider_test_seq - || result.provider_id != pane.provider_store.active_id - { - return; - } - pane.provider_test_running = false; - pane.provider_status = - Some((format!("{} · {} ms", result.message, result.elapsed_ms), !result.ok)); - cx.notify(); - }); - }) - .detach(); - cx.notify(); - } - - fn apply_provider_to_codex(&mut self, cx: &mut Context) { - if !self.save_provider_metadata(cx) { - return; - } - let Some(index) = self.active_provider_index() else { return }; - let provider = self.provider_store.providers[index].clone(); - if self.provider_codex_confirm.as_deref() != Some(provider.id.as_str()) { - self.provider_codex_confirm = Some(provider.id); - self.provider_status = Some(( - "再次点击确认:API Key 将明文写入 Codex auth.json(原文件会备份)".to_owned(), - false, - )); - cx.notify(); - return; - } - self.provider_codex_confirm = None; - self.provider_status = Some(match crate::codex_config::apply_provider(&provider) { - Ok(path) => (format!("已应用到 Codex:{}", path.display()), false), - Err(error) => (error, true), - }); - cx.notify(); - } - - // ---- 分区内容(归属对照旧壳各 section)---- - - fn section_appearance(&mut self, cx: &mut Context) -> gpui::Div { - let font_size: SharedString = format!("{:.1} px", self.font_size_px(cx)).into(); - let opacity: SharedString = format!("{:.0}%", self.runtime.opacity * 100.0).into(); - let wallpaper_opacity: SharedString = - format!("{:.0}%", self.runtime.background_image_opacity * 100.0).into(); - let preview = self.group("预览", cx).child(self.appearance_preview(cx)); - let themes = self.group("主题", cx).child(self.theme_previews(cx)); - let theme_mode = self.group("主题模式", cx).child(self.switch_row( - "follow_system_theme", - "跟随系统明暗模式", - self.runtime.follow_system_theme, - cx, - )); - let custom_background = self - .group("自定义背景", cx) - .child(self.background_color_row(cx)) - .child(self.background_image_row(cx)) - .child(self.select_row("background_image_fit", "背景图像拉伸模式", cx)) - .child(self.select_row("background_image_alignment", "背景图像对齐", cx)) - .child(self.slider_row( - "背景图像不透明度", - &self.wallpaper_opacity_slider, - wallpaper_opacity, - )) - .child(self.switch_row( - "background_image_cover_chrome", - "将背景图扩展到标题栏和侧边栏", - self.runtime.background_image_cover_chrome, - cx, - )); - let cursor = self - .group("光标", cx) - .child(self.select_row("cursor_shape", "光标形状", cx)) - .child(self.switch_row( - "cursor_blink", - "光标闪烁", - self.runtime.cursor_blink.unwrap_or(true), - cx, - )); - let interface = self - .group("界面", cx) - .child(self.select_row("language", "语言", cx)) - .child(self.select_row("density", "界面外观", cx)) - .child(self.slider_row( - "终端正文不透明度", - &self.opacity_slider, - opacity, - )) - .child(self.switch_row("blur", "背景模糊", self.runtime.blur, cx)); - let terminal = self - .group("终端外观", cx) - .child(self.stepper_row( - "终端字号(Ctrl+滚轮缩放)", - "font-size", - font_size, - |this, cx| { - let size = this.font_size_px(cx) - 0.5; - this.set_font_size(size, cx); - }, - |this, cx| { - let size = this.font_size_px(cx) + 0.5; - this.set_font_size(size, cx); - }, - cx, - )) - .child(self.select_row("cell_width_mode", "字体间距", cx)) - .child(self.switch_row("fetch", "启动欢迎信息", self.runtime.fetch, cx)) - .child(self.switch_row("powerline", "Powerline 提示符", self.runtime.powerline, cx)); - - v_flex() - .w_full() - .child(preview) - .child(Self::group_divider(cx)) - .child(themes) - .child(Self::group_divider(cx)) - .child(theme_mode) - .child(Self::group_divider(cx)) - .child(custom_background) - .child(Self::group_divider(cx)) - .child(cursor) - .child(Self::group_divider(cx)) - .child(interface) - .child(Self::group_divider(cx)) - .child(terminal) - } - - fn section_profiles(&mut self, cx: &mut Context) -> gpui::Div { - let font_picker = self.font_picker_dropdown(cx); - let terminal = self - .group("终端", cx) - .child(self.shell_select_row(cx)) - .child(self.input_row("启动目录", "startup_directory", &self.dir_input.clone(), cx)) - .child(font_picker); - let completion = self - .group("补全", cx) - .child(self.switch_row("ghost", "启用命令补全", self.runtime.ghost, cx)) - .child(self.select_row("accept", "补全接受键", cx)) - .child(self.select_row("completion_style", "补全样式", cx)); - v_flex() - .w_full() - .child(terminal) - .child(Self::group_divider(cx)) - .child(completion) - } - - fn section_providers(&mut self, cx: &mut Context) -> gpui::Div { - let theme = cx.theme(); - let hover_bg = crate::gpui_shell::theme::settings_hover_bg(cx, false); - let active_index = self.active_provider_index().unwrap_or(0); - let active = self.provider_store.providers.get(active_index).cloned(); - let active_id = active.as_ref().map(|provider| provider.id.clone()).unwrap_or_default(); - let provider_rows = self.provider_store.providers.iter().map(|provider| { - let id = provider.id.clone(); - let selected = provider.id == active_id; - let name = provider.name.clone(); - let kind = provider.kind.label(); - h_flex() - .id(SharedString::from(format!("provider-row-{}", provider.id))) - .h(px(34.0)) - .w_full() - .px_2() - .gap_2() - .items_center() - .rounded_md() - .when(selected, |row| row.bg(theme.list_active)) - .hover(move |row| row.bg(hover_bg)) - .child(Icon::new(IconName::Bot).xsmall().text_color(theme.muted_foreground)) - .child(div().flex_1().min_w_0().truncate().child(name)) - .child( - div() - .max_w(px(78.0)) - .text_xs() - .text_color(theme.muted_foreground) - .truncate() - .child(kind), - ) - .on_click(cx.listener(move |this, _, window, cx| { - this.select_provider(id.clone(), window, cx); - })) - }); - - let mut editor = v_flex().flex_1().min_w_0().gap_3(); - if let Some(provider) = active { - let key_status: SharedString = if provider.api_key_set { - if provider.api_key_hint.is_empty() { - "已保存在系统凭据管理器".into() - } else { - provider.api_key_hint.clone().into() - } - } else if provider.kind.requires_api_key() { - "未设置".into() - } else { - "此供应商不需要 API Key".into() - }; - let enabled = provider.enabled; - let goals = provider.codex_goals; - let remote = provider.codex_remote_compaction; - editor = editor - .child(Self::row( - "启用", - Switch::new("provider-enabled").checked(enabled).on_click(cx.listener( - |this, value: &bool, _, cx| { - this.toggle_provider_flag("enabled", *value, cx); - }, - )), - )) - .child(Self::row( - "名称", - div().w(px(330.0)).child(Input::new(&self.provider_inputs[0])), - )) - .child(Self::row( - "备注", - div().w(px(330.0)).child(Input::new(&self.provider_inputs[1])), - )) - .child(Self::row( - "官方网站", - div().w(px(330.0)).child(Input::new(&self.provider_inputs[2])), - )) - .child(Self::row( - "API 请求地址", - div().w(px(330.0)).child(Input::new(&self.provider_inputs[3])), - )) - .child(Self::row( - "默认模型", - div().w(px(330.0)).child(Input::new(&self.provider_inputs[4])), - )) - .child(Self::row( - "API Key", - h_flex() - .gap_2() - .items_center() - .child(div().text_xs().text_color(theme.muted_foreground).child(key_status)) - .child( - Button::new("provider-set-key") - .label(if provider.api_key_set { "替换…" } else { "设置…" }) - .small() - .on_click(cx.listener(|this, _, _, cx| { - this.prompt_provider_key(cx); - })), - ), - )) - .child(Self::row( - "Codex Goals", - Switch::new("provider-codex-goals").checked(goals).on_click(cx.listener( - |this, value: &bool, _, cx| { - this.toggle_provider_flag("codex_goals", *value, cx); - }, - )), - )) - .child(Self::row( - "Codex 远程压缩", - Switch::new("provider-codex-remote").checked(remote).on_click(cx.listener( - |this, value: &bool, _, cx| { - this.toggle_provider_flag("codex_remote_compaction", *value, cx); - }, - )), - )) - .child( - h_flex() - .gap_2() - .child(Button::new("provider-save").label("保存").small().on_click( - cx.listener(|this, _, _, cx| { - this.save_provider_metadata(cx); - cx.notify(); - }), - )) - .child( - Button::new("provider-test") - .label(if self.provider_test_running { - "测试中…" - } else { - "测试连接" - }) - .small() - .disabled(self.provider_test_running) - .on_click(cx.listener(|this, _, _, cx| { - this.test_provider(cx); - })), - ) - .child( - Button::new("provider-codex").label("应用到 Codex").small().on_click( - cx.listener(|this, _, _, cx| { - this.apply_provider_to_codex(cx); - }), - ), - ) - .child(Button::new("provider-delete").label("删除").small().on_click( - cx.listener(|this, _, window, cx| { - this.delete_provider(window, cx); - }), - )), - ); - } else { - editor = editor - .child(div().text_color(theme.muted_foreground).child("没有供应商配置")); - } - - self.group("供应商", cx) - .child( - h_flex() - .w_full() - .items_start() - .gap_4() - .child( - v_flex() - .w(px(210.0)) - .flex_shrink_0() - .h(px(420.0)) - .gap_1() - .overflow_y_scrollbar() - .children(provider_rows) - .child( - Button::new("provider-add") - .label("+ 自定义供应商") - .small() - .on_click(cx.listener(|this, _, window, cx| { - this.add_provider(window, cx); - })), - ), - ) - .child(editor), - ) - .when_some(self.provider_status.clone(), |group, (message, error)| { - group.child( - div() - .text_color(if error { theme.danger } else { theme.success }) - .child(message), - ) - }) - } - - /// SSH 非破坏性列表操作的统一收尾:写盘、报状态、清确认态。 - /// Profile/凭据的最终删除走 [`Self::delete_ssh_host`],不能混进置顶/恢复。 - fn ssh_apply( - &mut self, - mutate: impl FnOnce(&mut crate::gpui_shell::ssh_hosts::SshHostLists), - status: &str, - cx: &mut Context, - ) { - mutate(&mut self.ssh_hosts); - match self.ssh_hosts.persist() { - Ok(()) => self.ssh_status = Some((status.to_owned(), false)), - Err(err) => self.ssh_status = Some((format!("写入设置失败: {err}"), true)), - } - self.ssh_delete_confirm = None; - cx.notify(); - } - - /// 二次确认后的最终删除。列表层仍只管理三组地址;这里作为设置编辑器 - /// 协调 Profile 与 Credential Manager,和旧壳撤销期结束后的提交同义。 - fn delete_ssh_host(&mut self, host: &str, cx: &mut Context) { - let from_config = self.ssh_hosts.is_from_config(host); - let mut hosts = self.ssh_hosts.clone(); - hosts.remove(host); - if let Err(error) = hosts.persist() { - self.ssh_status = Some((format!("删除主机失败: {error}"), true)); - self.ssh_delete_confirm = None; - cx.notify(); - return; - } - self.ssh_hosts = hosts; - - let mut cleanup_errors = Vec::new(); - let profile_path = crate::display::nebula_data_dir().join("ssh_profiles.json"); - match crate::ssh_profiles::SshProfiles::load(&profile_path) { - Ok(mut profiles) => { - profiles.remove(host); - if let Err(error) = profiles.save(&profile_path) { - cleanup_errors.push(format!("Profile: {error}")); - } - }, - Err(error) => cleanup_errors.push(format!("Profile: {error}")), - } - #[cfg(windows)] - if let Err(error) = crate::ssh_credentials::forget_password(host) { - cleanup_errors.push(format!("凭据: {error}")); - } - - self.ssh_delete_confirm = None; - self.ssh_status = if cleanup_errors.is_empty() { - Some(( - if from_config { - "已隐藏 config 别名,并清理 Nebula Profile 与凭据".to_owned() - } else { - "已删除主机、Profile 与凭据".to_owned() - }, - false, - )) - } else { - Some(( - format!( - "主机已从列表移除,但部分清理失败: {}", - cleanup_errors.join(";") - ), - true, - )) - }; - cx.notify(); - } - - /// 一旦草稿字段变动,之前那次测试就不再能证明当前配置。请求本身不取消 - /// (网络任务应当自行收尾),但它返回时会按 revision 丢弃过期结果。 - fn touch_ssh_editor(&mut self, cx: &mut Context) { - if let Some(editor) = self.ssh_editor.as_mut() { - editor.revision = editor.revision.wrapping_add(1); - editor.test_request_id = None; - editor.test_status = None; - self.ssh_status = None; - cx.notify(); - } - } - - fn set_ssh_editor_masking(&mut self, window: &mut Window, cx: &mut Context) { - let masked = self.ssh_editor.as_ref().is_none_or(|editor| !editor.show_password); - self.ssh_password_input.update(cx, |input, cx| input.set_masked(masked, window, cx)); - } - - fn open_ssh_editor( - &mut self, - destination: Option, - window: &mut Window, - cx: &mut Context, - ) { - let profile_path = crate::display::nebula_data_dir().join("ssh_profiles.json"); - let profiles = crate::ssh_profiles::SshProfiles::load(&profile_path).unwrap_or_else(|err| { - log::warn!("加载 SSH Profile 失败,使用默认草稿: {err}"); - crate::ssh_profiles::SshProfiles::default() - }); - let profile = destination - .as_deref() - .map(|host| profiles.for_destination(host)); - let (address, port) = destination - .as_deref() - .map(crate::display::split_destination_port) - .unwrap_or_default(); - self.ssh_editor_seq = self.ssh_editor_seq.wrapping_add(1).max(1); - let mut editor = SshEditorState::new(self.ssh_editor_seq, destination); - if let Some(profile) = profile.as_ref() { - editor.auth = profile.auth; - editor.icon = profile.icon.clone(); - editor.private_keys = profile.private_keys.clone(); - } - let label = profile.and_then(|profile| profile.label).unwrap_or_default(); - let icon_index = editor - .icon - .as_deref() - .and_then(|id| crate::display::ui::os_icons::CATALOG.iter().position(|icon| icon.id == id)) - .map(|index| index + 1) - .unwrap_or(0); - self.ssh_destination_input.update(cx, |input, cx| input.set_value(address, window, cx)); - self.ssh_port_input.update(cx, |input, cx| input.set_value(port, window, cx)); - self.ssh_label_input.update(cx, |input, cx| input.set_value(label, window, cx)); - // 存储的秘密绝不回填文本框;空值意味着编辑已有主机时保留原凭据。 - self.ssh_password_input.update(cx, |input, cx| input.set_value("", window, cx)); - self.ssh_icon_select.update(cx, |select, cx| { - select.set_selected_index(Some(IndexPath::default().row(icon_index)), window, cx); - }); - // 弹层只能有一个;字体目录若还开着会用它的页面级拦截层盖住 - // SSH 编辑器,因此先把它收起。 - self.font_picker_open = false; - self.ssh_editor = Some(editor); - self.ssh_status = None; - self.set_ssh_editor_masking(window, cx); - self.ssh_destination_input.update(cx, |input, cx| input.focus(window, cx)); - cx.notify(); - } - - fn close_ssh_editor(&mut self, window: &mut Window, cx: &mut Context) { - self.ssh_editor = None; - self.ssh_password_input.update(cx, |input, cx| input.set_value("", window, cx)); - window.focus(&self.focus_handle); - cx.notify(); - } - - fn ssh_destination_from_draft(&self, cx: &App) -> Result { - let port = self.ssh_port_input.read(cx).value().trim().to_string(); - if !port.is_empty() && !port.parse::().is_ok_and(|value| value > 0) { - return Err("端口需要是 1-65535 之间的数字".to_owned()); - } - let address = self.ssh_destination_input.read(cx).value().trim().to_string(); - let destination = crate::display::join_destination_port(&address, &port); - if destination.is_empty() { - return Err("请输入 SSH 地址,例如 user@example.com".to_owned()); - } - if destination - .chars() - .any(|ch| ch.is_whitespace() || ch.is_control() || ";&|<>\"'`".contains(ch)) - { - return Err("地址不能包含空白、控制字符或 shell 分隔符".to_owned()); - } - Ok(destination) - } - - fn add_ssh_private_key(&mut self, cx: &mut Context) { - let Some(editor_id) = self.ssh_editor.as_ref().map(|editor| editor.id) else { - return; - }; - let receiver = cx.prompt_for_paths(gpui::PathPromptOptions { - files: true, - directories: false, - multiple: false, - prompt: Some("选择 SSH 私钥".into()), - }); - cx.spawn(async move |this, cx| { - let Ok(Ok(Some(paths))) = receiver.await else { return }; - let Some(path) = paths.into_iter().next() else { return }; - let _ = this.update(cx, |pane, cx| { - // 用户可能在原生文件选择器打开期间关闭表单并编辑另一台 - // 主机;旧选择结果绝不能落进后来打开的编辑会话。 - if pane.ssh_editor.as_ref().map(|editor| editor.id) != Some(editor_id) { - return; - } - let result = crate::display::file_dialog::validate_private_key_path(&path); - match result { - Ok(path) => { - if let Some(editor) = pane.ssh_editor.as_mut() { - if crate::display::push_private_key(&mut editor.private_keys, path) { - editor.revision = editor.revision.wrapping_add(1); - editor.test_request_id = None; - editor.test_status = None; - pane.ssh_status = None; - } - } - }, - Err(message) => pane.ssh_status = Some((message, true)), - } - cx.notify(); - }); - }) - .detach(); - } - - fn test_ssh_editor(&mut self, cx: &mut Context) { - let destination = match self.ssh_destination_from_draft(cx) { - Ok(destination) => destination, - Err(message) => { - self.ssh_status = Some((message, true)); - cx.notify(); - return; - }, - }; - let Some(editor) = self.ssh_editor.as_mut() else { return }; - if editor.testing() { - return; - } - self.ssh_test_seq = self.ssh_test_seq.wrapping_add(1).max(1); - let request_id = self.ssh_test_seq; - let revision = editor.revision; - let request = crate::ssh_session::SshTestRequest { - request_id, - destination: destination.clone(), - auth: editor.auth, - private_keys: editor.private_keys.clone(), - password: crate::display::auth_sections(editor.auth).0.then(|| { - self.ssh_password_input.read(cx).value().to_string() - }).filter(|password| !password.is_empty()), - }; - let receiver = match crate::ssh_session::start_test(request) { - Ok(receiver) => receiver, - Err(error) => { - self.ssh_status = Some((format!("无法启动连接测试: {error}"), true)); - cx.notify(); - return; - }, - }; - editor.test_request_id = Some(request_id); - editor.test_status = Some(("正在测试连接…".to_owned(), false)); - self.ssh_status = None; - cx.spawn(async move |this, cx| { - let result = receiver.await; - let _ = this.update(cx, |pane, cx| { - let Some(editor) = pane.ssh_editor.as_mut() else { return }; - if editor.revision != revision || editor.test_request_id != Some(request_id) { - return; - } - editor.test_request_id = None; - editor.test_status = Some(match result { - Ok(result) if result.ok => { - (format!("连接成功 · {} ms", result.elapsed_ms), false) - }, - Ok(result) => (result.message, true), - Err(_) => ("连接测试任务意外结束,请重试".to_owned(), true), - }); - cx.notify(); - }); - }) - .detach(); - cx.notify(); - } - - fn save_ssh_editor(&mut self, window: &mut Window, cx: &mut Context) { - let destination = match self.ssh_destination_from_draft(cx) { - Ok(destination) => destination, - Err(message) => { - self.ssh_status = Some((message, true)); - cx.notify(); - return; - }, - }; - let Some(mut editor) = self.ssh_editor.take() else { return }; - let original = editor.original_destination.clone(); - let profile_path = crate::display::nebula_data_dir().join("ssh_profiles.json"); - let mut profiles = match crate::ssh_profiles::SshProfiles::load(&profile_path) { - Ok(profiles) => profiles, - Err(error) => { - self.ssh_status = Some((format!("加载 SSH Profile 失败: {error}"), true)); - self.ssh_editor = Some(editor); - cx.notify(); - return; - }, - }; - // 先在副本里计算新列表,直到 Profile 与 settings 两份数据都写成功 - // 才替换页面状态;Profile 写盘失败时背景列表不能先显示新地址。 - let mut hosts = self.ssh_hosts.clone(); - if let Some(original) = original.as_deref().filter(|old| *old != destination) { - profiles.rename(original, &destination); - hosts.remove(original); - } - let label = match self.ssh_label_input.read(cx).value().trim() { - "" => profiles.next_default_label("主机"), - value => value.to_owned(), - }; - profiles.upsert(crate::ssh_profiles::SshProfileAuth { - destination: destination.clone(), - auth: editor.auth, - private_keys: editor.private_keys.clone(), - label: Some(label), - icon: editor.icon.clone(), - }); - if let Err(error) = profiles.save(&profile_path) { - self.ssh_status = Some((format!("保存 SSH Profile 失败: {error}"), true)); - self.ssh_editor = Some(editor); - cx.notify(); - return; - } - hosts.remember(&destination); - if let Err(error) = hosts.persist() { - self.ssh_status = Some((format!("保存主机列表失败: {error}"), true)); - self.ssh_editor = Some(editor); - cx.notify(); - return; - } - self.ssh_hosts = hosts; - let password = self.ssh_password_input.read(cx).value().to_string(); - if crate::display::auth_sections(editor.auth).0 && editor.save_password && !password.is_empty() { - #[cfg(windows)] - if let Err(error) = crate::ssh_credentials::store_password(&destination, password.as_bytes()) { - self.ssh_status = Some((format!("Profile 已保存,但密码写入凭据管理器失败: {error}"), true)); - self.ssh_editor = Some(editor); - cx.notify(); - return; - } - } - // Credential Manager 以 destination 为键。重命名后旧键既不会被新 - // 连接使用,也不应无限留下;与旧壳一致,在配置和列表都成功写入后 - // 才删除它,避免前面的落盘失败反而丢掉仍可用的凭据。 - let credential_cleanup_error = original - .as_deref() - .filter(|old| *old != destination) - .and_then(|old| { - #[cfg(windows)] - { - crate::ssh_credentials::forget_password(old).err() - } - #[cfg(not(windows))] - { - let _ = old; - None - } - }); - // 只在落盘流程结束后清掉明文;编辑已有主机且密码框为空时不触碰 - // 当前 destination 的凭据。 - self.ssh_password_input.update(cx, |input, cx| input.set_value("", window, cx)); - editor.private_keys.clear(); - self.ssh_editor = None; - self.ssh_status = credential_cleanup_error.map_or_else( - || Some((format!("已保存 {destination}"), false)), - |error| Some((format!("已保存 {destination},但旧地址凭据清理失败: {error}"), true)), - ); - window.focus(&self.focus_handle); - cx.notify(); - } - - fn ssh_editor_modal(&mut self, cx: &mut Context) -> Option { - let editor = self.ssh_editor.as_ref()?.clone(); - let theme = cx.theme(); - let muted = theme.muted_foreground; - let danger = theme.danger; - let (shows_password, shows_keys) = crate::display::auth_sections(editor.auth); - let title = if editor.original_destination.is_some() { "编辑 SSH 主机" } else { "添加 SSH 主机" }; - let status = editor.test_status.clone().or_else(|| self.ssh_status.clone()); - let icon = crate::display::ui::os_icons::resolve(editor.icon.as_deref()); - let mode_button = |id: &'static str, - label: &'static str, - mode: crate::ssh_profiles::SshAuthMode, - selected: bool| { - Button::new(id) - .label(label) - .small() - .selected(selected) - .on_click(cx.listener(move |this, _, _, cx| { - if let Some(editor) = this.ssh_editor.as_mut() { - editor.auth = mode; - editor.revision = editor.revision.wrapping_add(1); - editor.test_request_id = None; - editor.test_status = None; - } - this.ssh_status = None; - cx.notify(); - })) - }; - let key_rows = editor.private_keys.iter().enumerate().map(|(index, path)| { - let shown: SharedString = path.to_string_lossy().into_owned().into(); - h_flex() - .id(SharedString::from(format!("ssh-key-{index}"))) - .w_full() - .h(px(30.0)) - .px_2() - .gap_2() - .items_center() - .rounded_md() - .bg(theme.input) - .child(div().flex_1().min_w_0().text_xs().truncate().child(shown)) - .child( - Button::new(SharedString::from(format!("ssh-key-remove-{index}"))) - .icon(IconName::Close) - .ghost() - .xsmall() - .tooltip("移除私钥") - .on_click(cx.listener(move |this, _, _, cx| { - if let Some(editor) = this.ssh_editor.as_mut() { - if index < editor.private_keys.len() { - editor.private_keys.remove(index); - editor.revision = editor.revision.wrapping_add(1); - editor.test_request_id = None; - editor.test_status = None; - } - } - this.ssh_status = None; - cx.notify(); - })), - ) - }); - - Some( - div() - .absolute() - .inset_0() - .occlude() - .bg(theme.overlay) - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { - if event.keystroke.key.eq_ignore_ascii_case("escape") { - cx.stop_propagation(); - this.close_ssh_editor(window, cx); - } - })) - .child( - div() - .absolute() - .inset_0() - .flex() - .items_center() - .justify_center() - .p_6() - .child( - v_flex() - .w(px(560.0)) - .max_w(gpui::relative(1.0)) - .max_h(gpui::relative(1.0)) - .rounded(px(8.0)) - .border_1() - .border_color(theme.border) - .bg(theme.popover) - .shadow_md() - .overflow_hidden() - .child( - h_flex() - .h(px(52.0)) - .px_5() - .items_center() - .border_b_1() - .border_color(theme.border) - .child(div().flex_1().text_lg().child(title)) - .child( - Button::new("ssh-editor-close") - .icon(IconName::Close) - .ghost() - .tooltip("关闭") - .on_click(cx.listener(|this, _, window, cx| { - this.close_ssh_editor(window, cx); - })), - ), - ) - .child( - v_flex() - .flex_1() - .min_h_0() - .overflow_y_scrollbar() - .p_5() - .gap_4() - .child( - h_flex() - .gap_3() - .items_center() - .child( - div() - .size(px(42.0)) - .rounded(px(6.0)) - .border_1() - .border_color(theme.border) - .bg(theme.input) - .flex() - .items_center() - .justify_center() - .font_family(self.current_font_chain(cx)) - .text_size(px(20.0)) - .child(icon.glyph.to_string()), - ) - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_1() - .child(div().text_xs().text_color(muted).child("主机名称")) - .child(Input::new(&self.ssh_label_input)), - ) - .child(div().w(px(170.0)).child(Select::new(&self.ssh_icon_select))), - ) - .child( - v_flex() - .gap_2() - .child(div().text_color(theme.sidebar_accent_foreground).child("连接")) - .child( - h_flex() - .gap_3() - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_1() - .child(div().text_xs().text_color(muted).child("地址")) - .child(Input::new(&self.ssh_destination_input)), - ) - .child( - v_flex() - .w(px(86.0)) - .gap_1() - .child(div().text_xs().text_color(muted).child("端口")) - .child(Input::new(&self.ssh_port_input)), - ), - ) - .child(div().text_xs().text_color(muted).child( - "支持 user@host、ssh://user@host:2222 和 ~/.ssh/config 别名", - )), - ) - .child( - v_flex() - .gap_2() - .child(div().text_color(theme.sidebar_accent_foreground).child("认证")) - .child( - h_flex() - .gap_1() - .child(mode_button("ssh-auth-password", "密码", crate::ssh_profiles::SshAuthMode::Password, editor.auth == crate::ssh_profiles::SshAuthMode::Password)) - .child(mode_button("ssh-auth-key", "私钥", crate::ssh_profiles::SshAuthMode::PublicKey, editor.auth == crate::ssh_profiles::SshAuthMode::PublicKey)) - .child(mode_button("ssh-auth-auto", "自动", crate::ssh_profiles::SshAuthMode::Auto, editor.auth == crate::ssh_profiles::SshAuthMode::Auto)) - .child(mode_button("ssh-auth-interactive", "交互式", crate::ssh_profiles::SshAuthMode::KeyboardInteractive, editor.auth == crate::ssh_profiles::SshAuthMode::KeyboardInteractive)), - ) - .when(shows_password, |section| { - section - .child(div().text_xs().text_color(muted).child("密码不会写入配置文件,只保存到 Windows 凭据管理器。")) - .child( - h_flex() - .gap_2() - .items_center() - .child(div().flex_1().child(Input::new(&self.ssh_password_input))) - .child( - Button::new("ssh-password-visibility") - .icon(if editor.show_password { IconName::EyeOff } else { IconName::Eye }) - .ghost() - .tooltip(if editor.show_password { "隐藏密码" } else { "显示密码" }) - .on_click(cx.listener(|this, _, window, cx| { - if let Some(editor) = this.ssh_editor.as_mut() { - editor.show_password = !editor.show_password; - } - this.set_ssh_editor_masking(window, cx); - cx.notify(); - })), - ), - ) - .child( - h_flex() - .gap_2() - .items_center() - .child(Switch::new("ssh-save-password").checked(editor.save_password).on_click( - cx.listener(|this, value: &bool, _, cx| { - if let Some(editor) = this.ssh_editor.as_mut() { - editor.save_password = *value; - editor.revision = editor.revision.wrapping_add(1); - editor.test_request_id = None; - editor.test_status = None; - } - this.ssh_status = None; - cx.notify(); - }), - )) - .child(div().child("保存密码")), - ) - }) - .when(shows_keys, |section| { - section - .child( - h_flex() - .items_center() - .child(div().flex_1().text_xs().text_color(muted).child( - if editor.private_keys.is_empty() { - "未指定时使用 ~/.ssh/config 的 IdentityFile 和默认 id_* 私钥" - } else { - "按列出的顺序尝试私钥" - }, - )) - .child( - Button::new("ssh-add-private-key") - .icon(IconName::Plus) - .ghost() - .small() - .tooltip("添加私钥") - .on_click(cx.listener(|this, _, _, cx| { - this.add_ssh_private_key(cx); - })), - ), - ) - .children(key_rows) - }) - .when(!shows_password && !shows_keys, |section| { - section.child( - div().text_xs().text_color(muted).child( - "连接时在终端中按服务器提示输入密码或 MFA,不保存任何凭据。", - ), - ) - }), - ) - .when_some(status, |content, (message, error)| { - content.child( - h_flex() - .gap_2() - .items_center() - .text_color(if error { danger } else { theme.success }) - .child(if error { Icon::new(IconName::CircleX).xsmall() } else { Icon::new(IconName::CircleCheck).xsmall() }) - .child(message), - ) - }), - ) - .child( - h_flex() - .h(px(58.0)) - .px_5() - .gap_2() - .items_center() - .justify_end() - .border_t_1() - .border_color(theme.border) - .child( - Button::new("ssh-editor-test") - .label(if editor.testing() { "测试中…" } else { "测试连接" }) - .outline() - .small() - .disabled(editor.testing()) - .on_click(cx.listener(|this, _, _, cx| this.test_ssh_editor(cx))), - ) - .child( - Button::new("ssh-editor-cancel") - .label("取消") - .outline() - .small() - .on_click(cx.listener(|this, _, window, cx| { - this.close_ssh_editor(window, cx); - })), - ) - .child( - Button::new("ssh-editor-save") - .label("保存") - .primary() - .small() - .on_click(cx.listener(|this, _, window, cx| { - this.save_ssh_editor(window, cx); - })), - ), - ), - ), - ) - .into_any_element(), - ) - } - - fn section_ssh(&mut self, cx: &mut Context) -> gpui::Div { - let theme = cx.theme(); - let hover_bg = crate::gpui_shell::theme::settings_hover_bg(cx, false); - let muted = theme.muted_foreground; - let hosts = self.ssh_hosts.merged(); - let profiles = crate::ssh_profiles::SshProfiles::load( - &crate::display::nebula_data_dir().join("ssh_profiles.json"), - ) - .ok(); - let labels = profiles.as_ref().map(|profiles| profiles.labels()).unwrap_or_default(); - let icons = profiles.as_ref().map(|profiles| profiles.icons()).unwrap_or_default(); - let family: SharedString = cx - .try_global::() - .map(|settings| settings.font_family.clone()) - .unwrap_or_else(|| String::from("Maple Mono Normal NF CN")) - .into(); - let hidden: Vec = self.ssh_hosts.hidden_hosts().to_vec(); - let delete_confirm = self.ssh_delete_confirm.clone(); - - let host_rows = hosts.into_iter().enumerate().map(|(ix, host)| { - let pinned = self.ssh_hosts.is_pinned(&host); - let from_config = self.ssh_hosts.is_from_config(&host); - let confirm = delete_confirm.as_deref() == Some(host.as_str()); - let label = labels.get(&host).cloned().unwrap_or_else(|| host.clone()); - // 行首 OS 图标(旧壳裁定 2026-08-09):id 取自 ssh_profiles 存储, - // 未认出回落通用终端形状;mono 字体渲染 Nerd Font 字位。 - let os_icon = crate::display::ui::os_icons::resolve(icons.get(&host).map(String::as_str)); - let subtitle = if from_config { - format!("~/.ssh/config · {host}") - } else { - format!("已保存 · {host}") - }; - let connect_host = host.clone(); - let edit_host = host.clone(); - let pin_host = host.clone(); - let delete_host = host.clone(); - // 主机动作只在 hover 时显形,静态行只保留身份信息;编辑器迁移后 - // 「编辑」与连接/置顶/删除同层,不再把认证配置藏在旧壳里。 - let row_group = SharedString::from(format!("ssh-host-actions-{ix}")); - h_flex() - .id(SharedString::from(format!("ssh-host-row-{ix}"))) - .group(row_group.clone()) - // 旧壳 `SSH_HOST_ROW_H` 固定 58px;两行文字与 OS 图标在 - // 这个高度里共用中线,不能压成普通 48px 设置行。 - .h(px(58.0)) - .w_full() - .px_2() - .items_center() - .gap_2() - .rounded_md() - .hover(move |row| row.bg(hover_bg)) - .child( - div() - .w(px(22.0)) - .h_full() - .flex_shrink_0() - .relative() - .flex() - .items_center() - .justify_center() - .font_family(family.clone()) - .text_size(px(18.0)) - .text_color(muted) - .text_center() - .child(os_icon.glyph.to_string()) - // 旧壳把置顶记号压在图标槽右缘,不额外占一列;否则 - // 置顶行的主机标题会比其它行整体右移。 - .when(pinned, |slot| { - slot.child( - div() - .absolute() - .right(px(-2.0)) - .bottom(px(7.0)) - .text_size(px(8.0)) - .text_color(theme.primary) - .child("\u{eab4}"), - ) - }), - ) - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_1() - .child(div().truncate().child(label)) - .child(div().text_xs().text_color(muted).truncate().child(subtitle)), - ) - .child( - Button::new(SharedString::from(format!("ssh-connect-{ix}"))) - .label("连接") - .small() - .invisible() - .group_hover(row_group.clone(), |button| button.visible()) - .on_click(cx.listener(move |this, _, _, cx| { - cx.emit(SettingsPaneEvent::LaunchSsh(connect_host.clone())); - this.ssh_status = Some((format!("正在打开 {connect_host}…"), false)); - cx.notify(); - })), - ) - .child( - Button::new(SharedString::from(format!("ssh-edit-{ix}"))) - .label("编辑") - .small() - .invisible() - .group_hover(row_group.clone(), |button| button.visible()) - .on_click(cx.listener(move |this, _, window, cx| { - this.open_ssh_editor(Some(edit_host.clone()), window, cx); - })), - ) - .child( - Button::new(SharedString::from(format!("ssh-pin-{ix}"))) - .label(if pinned { "取消置顶" } else { "置顶" }) - .small() - .invisible() - .group_hover(row_group.clone(), |button| button.visible()) - .on_click(cx.listener(move |this, _, _, cx| { - this.ssh_apply( - |lists| lists.toggle_pin(&pin_host), - "置顶状态已更新", - cx, - ); - })), - ) - .child( - Button::new(SharedString::from(format!("ssh-delete-{ix}"))) - .label(if confirm { - "确认删除" - } else if from_config { - "隐藏" - } else { - "删除" - }) - .small() - // 进了确认态就常显:指针移开还让它隐形,等于把「再点 - // 一次才真删」这个状态藏起来。 - .when(!confirm, |button| { - button - .invisible() - .group_hover(row_group.clone(), |button| button.visible()) - }) - .on_click(cx.listener(move |this, _, _, cx| { - if this.ssh_delete_confirm.as_deref() == Some(delete_host.as_str()) { - this.delete_ssh_host(&delete_host, cx); - } else { - this.ssh_delete_confirm = Some(delete_host.clone()); - cx.notify(); - } - })), - ) - }); - - let hidden_rows = self.ssh_show_hidden.then(|| { - hidden - .iter() - .enumerate() - .map(|(ix, host)| { - let restore_host = host.clone(); - h_flex() - .h(px(30.0)) - .w_full() - .px_2() - .items_center() - .gap_2() - .child( - div() - .flex_1() - .min_w_0() - .text_color(muted) - .truncate() - .child(host.clone()), - ) - .child( - Button::new(SharedString::from(format!("ssh-restore-{ix}"))) - .label("恢复") - .small() - .on_click(cx.listener(move |this, _, _, cx| { - this.ssh_apply( - |lists| lists.restore_hidden(&restore_host), - "已恢复到主机列表", - cx, - ); - })), - ) - }) - .collect::>() - }); - - let hidden_count = self.ssh_hosts.hidden_hosts().len(); - - self.group("SSH 主机", cx) - .child( - h_flex() - .h(px(32.0)) - .items_center() - .child( - div() - .flex_1() - .text_color(theme.foreground) - .child("已保存主机"), - ) - .child( - Button::new("ssh-add-host") - .label("+ 添加主机") - .small() - .primary() - .on_click(cx.listener(|this, _, window, cx| { - this.open_ssh_editor(None, window, cx); - })), - ), - ) - .child(div().text_xs().text_color(muted).child( - "保存的目的地 + ~/.ssh/config 别名合并展示(与旧壳同一份数据)。\ - 添加/编辑面板可配置认证方式、私钥、密码凭据、名称和图标。", - )) - .child(v_flex().gap_1().children(host_rows)) - .child( - h_flex() - .gap_2() - .items_center() - .child( - Button::new("ssh-import").label("重新读取 ~/.ssh/config").small().on_click( - cx.listener(|this, _, _, cx| { - this.ssh_hosts = crate::gpui_shell::ssh_hosts::SshHostLists::load(); - let count = crate::ssh::ssh_config_hosts().len(); - this.ssh_status = - Some((format!("已导入,config 源共 {count} 个别名"), false)); - cx.notify(); - }), - ), - ) - .when(hidden_count > 0, |row| { - let show = self.ssh_show_hidden; - row.child( - Button::new("ssh-toggle-hidden") - .label(if show { - SharedString::from("收起已隐藏") - } else { - SharedString::from(format!("已隐藏 {hidden_count} 项")) - }) - .small() - .on_click(cx.listener(|this, _, _, cx| { - this.ssh_show_hidden = !this.ssh_show_hidden; - cx.notify(); - })), - ) - }), - ) - .when_some(hidden_rows, |group, rows| group.child(v_flex().gap_1().children(rows))) - .when_some(self.ssh_status.clone(), |group, (message, error)| { - group.child( - div() - .text_color(if error { theme.danger } else { theme.success }) - .child(message), - ) - }) - } - - // ---- 备份(本地加密导出/恢复 + 远端同步)---- - - /// 读备份密码并预检(真正的强度校验在 `encrypted_backup` 内部再做一 - /// 次;这里只提前给出友好提示)。 - fn backup_passphrase(&mut self, cx: &mut Context) -> Option { - let pass = self.backup_pass_input.read(cx).value().to_string(); - if pass.chars().count() < 8 { - self.backup_status = Some(("备份密码至少 8 位".to_owned(), true)); - cx.notify(); - return None; - } - Some(pass) - } - - /// 后台执行一段备份计算并把结果写回状态行。`Ok` 文案由任务给出。 - fn backup_run_async( - &mut self, - task: impl std::future::Future> + Send + 'static, - cx: &mut Context, - ) { - self.backup_seq = self.backup_seq.wrapping_add(1); - let seq = self.backup_seq; - self.backup_busy = true; - self.backup_status = Some(("处理中…".to_owned(), false)); - let task = cx.background_executor().spawn(task); - cx.spawn(async move |this, cx| { - let result = task.await; - let _ = this.update(cx, |pane, cx| { - if seq != pane.backup_seq { - return; - } - pane.backup_busy = false; - pane.backup_status = Some(match result { - Ok(message) => (message, false), - Err(error) => (error, true), - }); - cx.notify(); - }); - }) - .detach(); - cx.notify(); - } - - /// 导出:先弹保存对话框(取消则零成本),再后台 collect + seal + 写盘。 - fn export_backup(&mut self, cx: &mut Context) { - if self.backup_busy { - return; - } - let Some(pass) = self.backup_passphrase(cx) else { return }; - if self.backup_selection.is_empty() { - self.backup_status = Some(("请至少勾选一个备份类别".to_owned(), true)); - cx.notify(); - return; - } - let selection = self.backup_selection; - let start_dir = std::env::var_os("USERPROFILE") - .map(std::path::PathBuf::from) - .unwrap_or_else(std::env::temp_dir); - let stamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let picked = - cx.prompt_for_new_path(&start_dir, Some(&format!("nebula-{stamp}.nebula-backup"))); - cx.spawn(async move |this, cx| { - let Ok(Ok(Some(path))) = picked.await else { return }; - let _ = this.update(cx, |pane, cx| { - pane.backup_run_async( - async move { - let archive = crate::encrypted_backup::collect(selection)?; - let packet = crate::encrypted_backup::seal(&archive, &pass)?; - std::fs::write(&path, packet) - .map_err(|err| format!("写入备份文件失败:{err}"))?; - Ok(format!("已导出加密备份:{}", path.display())) - }, - cx, - ); - }); - }) - .detach(); - } - - /// 恢复:选文件 → 后台解密落盘 → 热应用(设置/主机列表随之重载)。 - fn restore_backup(&mut self, cx: &mut Context) { - if self.backup_busy { - return; - } - let Some(pass) = self.backup_passphrase(cx) else { return }; - let picked = cx.prompt_for_paths(gpui::PathPromptOptions { - files: true, - directories: false, - multiple: false, - prompt: Some("选择 Nebula 加密备份".into()), - }); - cx.spawn(async move |this, cx| { - let Ok(Ok(Some(paths))) = picked.await else { return }; - let Some(path) = paths.into_iter().next() else { return }; - let _ = this.update(cx, |pane, cx| { - pane.backup_run_async( - async move { - let packet = std::fs::read(&path) - .map_err(|err| format!("读取备份文件失败:{err}"))?; - crate::encrypted_backup::restore(&packet, &pass)?; - Ok("已从备份恢复(字体/托盘等部分设置重启后生效)".to_owned()) - }, - cx, - ); - // 恢复覆盖了 settings/主机列表等文件:立即重载并通知宿主 - // 热应用。后台任务完成前 UI 短暂显示旧值,可接受。 - pane.reload_after_restore(cx); - }); - }) - .detach(); - } - - /// 恢复后的单一重载入口(设置/SSH 主机/远端配置)。 - fn reload_after_restore(&mut self, cx: &mut Context) { - self.runtime = RuntimeSettings::load(); - self.ssh_hosts = crate::gpui_shell::ssh_hosts::SshHostLists::load(); - self.backup_remote = crate::backup_remote::BackupRemoteConfig::load(); - let settings = crate::gpui_shell::config::Settings::load( - crate::gpui_shell::theme::effective_theme_name(cx), - ); - cx.set_global(settings); - cx.emit(SettingsPaneEvent::Changed); - cx.notify(); - } - - /// 远端配置写盘(读当前协议的非密文槽位输入)。 - fn save_remote_config(&mut self, cx: &mut Context) { - let protocol = self.backup_remote.protocol; - let secret_slot = crate::backup_remote::secret_field(protocol); - let mut input_ix = 0usize; - for slot in 0..crate::backup_remote::field_count(protocol) { - if Some(slot) == secret_slot { - continue; - } - let Some(input) = self.backup_remote_inputs.get(input_ix) else { break }; - let value = input.read(cx).value().trim().to_string(); - self.backup_remote.set_slot(slot, value); - input_ix += 1; - } - self.backup_status = Some(match self.backup_remote.save() { - Ok(()) => ("远端配置已保存".to_owned(), false), - Err(err) => (err, true), - }); - cx.notify(); - } - - /// 密文槽写入系统凭据管理器(WebDAV 密码 / S3 Secret),随后清空输入。 - fn store_remote_secret(&mut self, window: &mut Window, cx: &mut Context) { - self.save_remote_config(cx); - let secret = self.backup_secret_input.read(cx).value().to_string(); - if secret.is_empty() { - self.backup_status = Some(("凭据不能为空".to_owned(), true)); - cx.notify(); - return; - } - let result = match self.backup_remote.protocol { - crate::backup_remote::BackupProtocol::WebDav => { - crate::backup_remote::store_webdav_password( - &self.backup_remote.webdav_username, - &secret, - ) - }, - crate::backup_remote::BackupProtocol::S3 => { - crate::backup_remote::store_s3_secret(&self.backup_remote.s3_access_key, &secret) - }, - _ => Err("当前协议不需要独立凭据".to_owned()), - }; - self.backup_status = Some(match result { - Ok(()) => ("凭据已写入系统凭据管理器".to_owned(), false), - Err(err) => (err, true), - }); - self.backup_secret_input.update(cx, |input, cx| input.set_value("", window, cx)); - cx.notify(); - } - - /// 立即推送:collect + seal + 按配置协议上传(全部后台)。 - fn push_remote(&mut self, cx: &mut Context) { - if self.backup_busy { - return; - } - let Some(pass) = self.backup_passphrase(cx) else { return }; - self.save_remote_config(cx); - if self.backup_selection.is_empty() { - self.backup_status = Some(("请至少勾选一个备份类别".to_owned(), true)); - cx.notify(); - return; - } - let selection = self.backup_selection; - self.backup_run_async( - async move { - crate::backup_remote::validate()?; - let archive = crate::encrypted_backup::collect(selection)?; - let packet = crate::encrypted_backup::seal(&archive, &pass)?; - let location = crate::backup_remote::push(&packet)?; - Ok(format!("已推送到 {location}")) - }, - cx, - ); - } - - /// 恢复最新:按配置协议拉取最新备份并解密落盘(全部后台)。 - fn pull_remote(&mut self, cx: &mut Context) { - if self.backup_busy { - return; - } - let Some(pass) = self.backup_passphrase(cx) else { return }; - self.save_remote_config(cx); - self.backup_run_async( - async move { - crate::backup_remote::validate()?; - let (name, packet) = crate::backup_remote::pull_latest()?; - crate::encrypted_backup::restore(&packet, &pass)?; - Ok(format!("已从 {name} 恢复(部分设置重启后生效)")) - }, - cx, - ); - } - - /// 协议切换:字段独立保存(来回切换不丢配置),槽位输入随协议回填。 - fn select_backup_protocol( - &mut self, - protocol: crate::backup_remote::BackupProtocol, - window: &mut Window, - cx: &mut Context, - ) { - // 先把当前协议的输入落到内存配置,再切换(不写盘,写盘归保存钮)。 - let secret_slot = crate::backup_remote::secret_field(self.backup_remote.protocol); - let mut input_ix = 0usize; - for slot in 0..crate::backup_remote::field_count(self.backup_remote.protocol) { - if Some(slot) == secret_slot { - continue; - } - if let Some(input) = self.backup_remote_inputs.get(input_ix) { - let value = input.read(cx).value().trim().to_string(); - self.backup_remote.set_slot(slot, value); - } - input_ix += 1; - } - self.backup_remote.protocol = protocol; - let secret_slot = crate::backup_remote::secret_field(protocol); - let mut input_ix = 0usize; - for slot in 0..crate::backup_remote::field_count(protocol) { - if Some(slot) == secret_slot { - continue; - } - let value = self.backup_remote.slot(slot).unwrap_or_default().to_owned(); - if let Some(input) = self.backup_remote_inputs.get(input_ix) { - input.update(cx, |input, cx| input.set_value(value, window, cx)); - } - input_ix += 1; - } - cx.notify(); - } - - fn section_backup(&mut self, cx: &mut Context) -> gpui::Div { - use crate::backup_remote::BackupProtocol; - let theme = cx.theme(); - let muted = theme.muted_foreground; - let busy = self.backup_busy; - let selection = self.backup_selection; - - // 类别开关(与共享 `BackupSelection` 一一对应)。 - let categories: [(&'static str, &'static str, bool, fn(&mut Self, bool)); 9] = [ - ("bk-appearance", "外观与主题", selection.appearance, |s, v| { - s.backup_selection.appearance = v; - }), - ("bk-config", "终端配置", selection.config, |s, v| s.backup_selection.config = v), - ("bk-ssh", "SSH 主机(脱敏)", selection.ssh, |s, v| s.backup_selection.ssh = v), - ("bk-sync", "同步配置", selection.sync, |s, v| s.backup_selection.sync = v), - ("bk-assistant", "AI 助手配置", selection.assistant, |s, v| { - s.backup_selection.assistant = v; - }), - ("bk-session", "会话与工作区", selection.session, |s, v| { - s.backup_selection.session = v; - }), - ("bk-dirhist", "目录历史", selection.directory_history, |s, v| { - s.backup_selection.directory_history = v; - }), - ("bk-cmdhist", "命令历史", selection.command_history, |s, v| { - s.backup_selection.command_history = v; - }), - ("bk-fonts", "自装字体", selection.fonts, |s, v| s.backup_selection.fonts = v), - ]; - let category_rows = categories.map(|(id, label, checked, apply)| { - Self::row( - label, - Switch::new(id).checked(checked).on_click(cx.listener( - move |this, value: &bool, _, cx| { - apply(this, *value); - cx.notify(); - }, - )), - ) - }); - - let protocol = self.backup_remote.protocol; - let protocol_buttons = [ - (BackupProtocol::Off, "关闭"), - (BackupProtocol::Folder, "目录"), - (BackupProtocol::WebDav, "WebDAV"), - (BackupProtocol::S3, "S3"), - (BackupProtocol::Sftp, "SFTP"), - ] - .map(|(value, label)| { - let selected = value == protocol; - Button::new(SharedString::from(format!("bk-protocol-{}", value.settings_value()))) - .label(label) - .small() - .when(selected, |b| b.primary()) - .on_click(cx.listener(move |this, _, window, cx| { - this.select_backup_protocol(value, window, cx); - })) - }); - - // 非密文槽位标签(顺序 = `BackupRemoteConfig::slot` 的下标序)。 - let slot_labels: &[&'static str] = match protocol { - BackupProtocol::Off => &[], - BackupProtocol::Folder => &["目标目录"], - BackupProtocol::WebDav => &["WebDAV 地址", "用户名"], - BackupProtocol::S3 => &["Endpoint", "Region", "桶/前缀", "Access Key"], - BackupProtocol::Sftp => &["SSH 目的地 (user@host)", "远端目录"], - }; - let slot_rows = slot_labels.iter().enumerate().map(|(ix, label)| { - let input = self.backup_remote_inputs.get(ix).cloned(); - Self::row(label, div().w(px(300.0)).children(input.map(|input| Input::new(&input)))) - }); - - let secret_ready = crate::backup_remote::protocol_secret_set(protocol); - let secret_label = match protocol { - BackupProtocol::WebDav => Some("WebDAV 密码"), - BackupProtocol::S3 => Some("S3 Secret Key"), - _ => None, - }; - - let mut remote_group = self - .group("远端同步", cx) - .child(div().text_xs().text_color(muted).child( - "推送 = 当前勾选类别加密打包后上传;恢复最新 = 拉取远端最新包解密落盘。\ - SFTP 复用上方 SSH 主机的认证。", - )) - .child(h_flex().gap_2().children(protocol_buttons)) - .children(slot_rows); - if let Some(label) = secret_label { - remote_group = remote_group.child(Self::row( - label, - h_flex() - .gap_2() - .items_center() - .child(div().text_xs().text_color(muted).child(if secret_ready { - "已设置" - } else { - "未设置" - })) - .child(div().w(px(220.0)).child(Input::new(&self.backup_secret_input))) - .child(Button::new("bk-store-secret").label("保存凭据").small().on_click( - cx.listener(|this, _, window, cx| { - this.store_remote_secret(window, cx); - }), - )), - )); - } - if protocol != BackupProtocol::Off { - remote_group = remote_group.child( - h_flex() - .gap_2() - .child(Button::new("bk-save-remote").label("保存配置").small().on_click( - cx.listener(|this, _, _, cx| { - this.save_remote_config(cx); - }), - )) - .child( - Button::new("bk-push") - .label(if busy { "处理中…" } else { "立即推送" }) - .small() - .disabled(busy) - .on_click(cx.listener(|this, _, _, cx| { - this.push_remote(cx); - })), - ) - .child( - Button::new("bk-pull") - .label("恢复最新备份") - .small() - .disabled(busy) - .on_click(cx.listener(|this, _, _, cx| { - this.pull_remote(cx); - })), - ), - ); - } - - let local_group = self - .group("加密备份", cx) - .child( - div() - .text_xs() - .text_color(muted) - .child("端到端加密(密码不落盘);SSH 私钥永不进包,主机列表脱敏导出。"), - ) - .children(category_rows) - .child(Self::row( - "备份密码", - div().w(px(300.0)).child(Input::new(&self.backup_pass_input)), - )) - .child( - h_flex() - .gap_2() - .child( - Button::new("bk-export") - .label(if busy { "处理中…" } else { "导出到文件…" }) - .small() - .disabled(busy) - .on_click(cx.listener(|this, _, _, cx| { - this.export_backup(cx); - })), - ) - .child( - Button::new("bk-restore") - .label("从文件恢复…") - .small() - .disabled(busy) - .on_click(cx.listener(|this, _, _, cx| { - this.restore_backup(cx); - })), - ), - ); - - v_flex() - .w_full() - .child(local_group) - .child(Self::group_divider(cx)) - .child(remote_group) - .when_some(self.backup_status.clone(), |page, (message, error)| { - page.child( - div() - .pt_4() - .text_color(if error { theme.danger } else { theme.success }) - .child(message), - ) - }) - } - - fn section_network(&mut self, cx: &mut Context) -> gpui::Div { - self.group("网络(SSH 出站代理)", cx) - .child(self.select_row("ssh_proxy_mode", "代理模式", cx)) - .child(self.input_row("代理 URL", "ssh_proxy_url", &self.proxy_url_input.clone(), cx)) - .child(self.input_row( - "绕过列表", - "ssh_proxy_no_proxy", - &self.proxy_bypass_input.clone(), - cx, - )) - } - - fn section_interaction(&mut self, cx: &mut Context) -> gpui::Div { - self.group("交互", cx) - .child(self.switch_row( - "copy_on_select", - "选中即复制(关 = 右键复制/粘贴)", - self.runtime.copy_on_select, - cx, - )) - .child(self.select_row("tab_reveal", "标签展开动效", cx)) - .child(self.select_row("new_tab_position", "新标签位置", cx)) - .child(self.switch_row( - "panel_resize", - "拖拽调节侧栏宽度", - self.runtime.panel_resize, - cx, - )) - .child(self.switch_row( - "cjk_bold_regular", - "CJK 粗体使用常规字形(提亮不加粗)", - self.runtime.cjk_bold_regular, - cx, - )) - } - - // ---- 按键映射编辑器(模型层 `display::keymap`,两壳同读同写)---- - - /// 搜索口径与旧壳一致:动作名(中/英)+ 当前生效键文本。 - fn keymap_row_haystack(&self, flat: usize) -> String { - use crate::display::keymap; - let custom = keymap::build_bindings(&self.keymap_binds); - let combo = if flat == keymap::QUICK_TERMINAL_ROW { - keymap::display_stored_combo(&self.runtime.quick_terminal_hotkey) - } else { - keymap::EDITABLE_ACTIONS - .get(flat - 1) - .and_then(|(action, ..)| keymap::effective_combo(action, &custom)) - .map(|(combo, _)| combo) - .unwrap_or_default() - }; - let (zh, en) = if flat == keymap::QUICK_TERMINAL_ROW { - ("快速终端", "Quick terminal") - } else { - keymap::EDITABLE_ACTIONS.get(flat - 1).map(|(_, zh, en)| (*zh, *en)).unwrap_or(("", "")) - }; - format!("{zh} {en} {combo}").to_lowercase() - } - - /// 过滤后的可见行(flat 下标,升序)。空查询 = 全部。 - fn keymap_visible(&self, cx: &App) -> Vec { - use crate::display::keymap; - let query = self.keymap_search_input.read(cx).value().trim().to_lowercase(); - (0..keymap::editable_row_count()) - .filter(|flat| query.is_empty() || self.keymap_row_haystack(*flat).contains(&query)) - .collect() - } - - /// 冲突检测(旧壳 `keymap_clash_info` 同义):同一 combo 多动作 → 逐行 - /// 标记 + 只报第一组的提示条。 - fn keymap_clashes(&self) -> (Vec, Option) { - use crate::display::keymap; - let total = keymap::editable_row_count(); - let custom = keymap::build_bindings(&self.keymap_binds); - let mut combos: Vec> = Vec::with_capacity(total); - for flat in 0..total { - let combo = if flat == keymap::QUICK_TERMINAL_ROW { - Some(keymap::display_stored_combo(&self.runtime.quick_terminal_hotkey)) - } else { - keymap::EDITABLE_ACTIONS - .get(flat - 1) - .and_then(|(action, ..)| keymap::effective_combo(action, &custom)) - .map(|(combo, _)| combo) - }; - combos.push(combo.filter(|combo| !combo.is_empty())); - } - let name = |flat: usize| -> String { - if flat == keymap::QUICK_TERMINAL_ROW { - "快速终端".to_owned() - } else { - keymap::EDITABLE_ACTIONS - .get(flat - 1) - .map(|(_, zh, _)| (*zh).to_owned()) - .unwrap_or_default() - } - }; - let mut rows = vec![false; total]; - let mut note = None; - for a in 0..total { - let Some(combo_a) = combos[a].clone() else { continue }; - for b in (a + 1)..total { - let Some(combo_b) = &combos[b] else { continue }; - if !combo_a.eq_ignore_ascii_case(combo_b) { - continue; - } - rows[a] = true; - rows[b] = true; - if note.is_none() { - let (a_name, b_name) = (name(a), name(b)); - note = Some(format!( - "{combo_a} 同时绑定了「{a_name}」与「{b_name}」——只有排前面的「{a_name}」会触发" - )); - } - } - } - (rows, note) - } - - /// 捕获完成:一个动作只保留一条自定义绑定,但同一 combo 可以同时归属 - /// 多个动作。冲突不再靠静默注销旧动作来“解决”,而是由 - /// `keymap_clashes` 标记双方并显示警告条,让用户自己决定改哪一行。 - fn keymap_assign(&mut self, row: usize, combo: String, cx: &mut Context) { - use crate::display::keymap; - self.keymap_capture = None; - self.keymap_capture_preview.clear(); - if row == keymap::QUICK_TERMINAL_ROW { - self.persist(&[("quick_terminal_hotkey", combo)], cx); - return; - } - let Some((action, ..)) = keymap::EDITABLE_ACTIONS.get(row - 1) else { return }; - let name = keymap::action_storage_name(action); - self.keymap_binds.retain(|(_, a)| !a.eq_ignore_ascii_case(&name)); - self.keymap_binds.push((combo, name)); - self.persist_keybinds(cx); - } - - /// 捕获态裸 Backspace:删除自定义绑定,回落内置默认。 - fn keymap_clear_custom(&mut self, row: usize, cx: &mut Context) { - use crate::display::keymap; - self.keymap_capture = None; - self.keymap_capture_preview.clear(); - if row == keymap::QUICK_TERMINAL_ROW { - self.persist( - &[("quick_terminal_hotkey", keymap::DEFAULT_QUICK_TERMINAL_HOTKEY.to_owned())], - cx, - ); - return; - } - let Some((action, ..)) = keymap::EDITABLE_ACTIONS.get(row - 1) else { return }; - let name = keymap::action_storage_name(action); - self.keymap_binds.retain(|(_, a)| !a.eq_ignore_ascii_case(&name)); - self.persist_keybinds(cx); - } - - /// keybind= 整表落盘 → 重载镜像 → 通知宿主热应用(工作区会重注入 - /// gpui 键位表)。persist 走通用路径拿重载与事件,这里补镜像。 - fn persist_keybinds(&mut self, cx: &mut Context) { - if let Err(err) = nebula_settings::persist_keybinds(&self.keymap_binds) { - eprintln!("[nebula:gpui] failed to persist keybinds: {err}"); - } - self.keymap_binds = nebula_settings::keybind_pairs(); - self.runtime = RuntimeSettings::load(); - cx.emit(SettingsPaneEvent::Changed); - cx.notify(); - } - - /// 键帽 chip:捕获行回显预览;自定义 accent 描边;冲突 danger 底; - /// 默认 ink_dim;未绑定 ink_faint(旧壳墨色分级裁定)。 - fn keymap_keycap( - &self, - text: &str, - custom: bool, - clash: bool, - capturing: bool, - cx: &Context, - ) -> impl IntoElement { - let theme = cx.theme(); - let color = if clash { - theme.danger - } else if capturing { - theme.link - } else if custom { - theme.link - } else if text.is_empty() { - crate::gpui_shell::theme::faint_ink(cx) - } else { - theme.muted_foreground - }; - div() - .min_w(px(72.0)) - .px_2() - .h(px(24.0)) - .flex() - .items_center() - .justify_center() - .rounded(px(crate::display::UI_CORNER_RADIUS_LOGICAL * 0.75)) - .text_size(px(self.font_size_px(cx) * 0.86)) - .when(clash, |chip| chip.bg(theme.danger).text_color(theme.danger_foreground)) - .when(!clash && (custom || capturing), |chip| { - chip.border_1().border_color(theme.link).text_color(color) - }) - .when(!clash && !custom && !capturing, |chip| { - chip.border_1().border_color(theme.border).text_color(color) - }) - .child(if text.is_empty() && !capturing { - SharedString::from("未绑定") - } else { - SharedString::from(text.to_owned()) - }) - } - - /// 一行「动作 + 键帽」。点击行进入捕获态(旧壳点行即捕获)。 - fn keymap_row( - &self, - flat: usize, - clash: bool, - cx: &Context, - ) -> impl IntoElement { - use crate::display::keymap; - let custom = keymap::build_bindings(&self.keymap_binds); - let label: SharedString = if flat == keymap::QUICK_TERMINAL_ROW { - "快速终端".into() - } else { - keymap::EDITABLE_ACTIONS - .get(flat - 1) - .map(|(_, zh, _)| (*zh).to_owned()) - .unwrap_or_default() - .into() - }; - let (text, is_custom) = if flat == keymap::QUICK_TERMINAL_ROW { - ( - keymap::display_stored_combo(&self.runtime.quick_terminal_hotkey), - self.runtime.quick_terminal_hotkey != keymap::DEFAULT_QUICK_TERMINAL_HOTKEY, - ) - } else { - keymap::EDITABLE_ACTIONS - .get(flat - 1) - .and_then(|(action, ..)| keymap::effective_combo(action, &custom)) - .map(|(combo, custom)| (combo, custom)) - .unwrap_or_else(|| (String::new(), false)) - }; - let capturing = self.keymap_capture == Some(flat); - let cap_text = if capturing { - if self.keymap_capture_preview.is_empty() { - "按新按键…".to_owned() - } else { - format!("{}…", self.keymap_capture_preview) - } - } else { - text.clone() - }; - h_flex() - .id(("keymap-row", flat)) - .w_full() - .h(px(SETTINGS_ROW_HEIGHT)) - .flex_shrink_0() - .items_center() - .pr_4() - .rounded(px(crate::display::UI_CORNER_RADIUS_LOGICAL)) - .cursor_pointer() - .hover(|style| style.bg(crate::gpui_shell::theme::settings_hover_bg(cx, false))) - // mouse_down 而非 click:容器(section 根)同捕一个 mouse_down - // 做「点击任何位置先撤销」,这里 stop_propagation 抢先处理 - // 「点别的行 = 捕获转移、点同一行 = 取消」(旧壳 input/chrome.rs - // 的 SettingsHit::KeymapRow 分支同合同)。 - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, _, window, cx| { - cx.stop_propagation(); - if this.keymap_capture == Some(flat) { - this.keymap_capture = None; - this.keymap_capture_preview.clear(); - } else { - this.keymap_capture = Some(flat); - this.keymap_capture_preview.clear(); - // 焦点收到分区根:键盘事件沿根路径冒泡给捕获处理器, - // 搜索框不再分走按键。 - window.focus(&this.focus_handle); - } - cx.notify(); - }), - ) - .child(div().flex_1().min_w_0().pl_4().child(label)) - .child(self.keymap_keycap(&cap_text, is_custom, clash, capturing, cx)) - } - - fn section_keymap(&mut self, cx: &mut Context) -> gpui::Div { - use crate::display::keymap; - - let visible = self.keymap_visible(cx); - let (clash_rows, clash_note) = self.keymap_clashes(); - - // 分组渲染(旧壳无框分组裁定):组内可见行为空的组整组隐藏;组 - // 标题 0.86× 小字压在行块上方。 - let mut groups_block = v_flex().w_full().gap_1(); - let mut start = 0usize; - for (zh, _en, count) in keymap::GROUPS { - let end = start + count; - let rows: Vec<_> = visible - .iter() - .filter(|flat| (start..end).contains(*flat)) - .map(|flat| self.keymap_row(*flat, clash_rows[*flat], cx)) - .collect(); - if !rows.is_empty() { - groups_block = groups_block.child( - div() - .pt_3() - .pb_1() - .text_size(px(self.font_size_px(cx) * 0.86)) - .text_color(cx.theme().muted_foreground) - .child(*zh), - ); - for row in rows { - groups_block = groups_block.child(row); - } - } - start = end; - } - - // 只读行:数字系/AI 贴入键(表驱动,不可在图形页编辑)。随搜索过滤。 - let query = self.keymap_search_input.read(cx).value().trim().to_lowercase(); - let readonly: Vec<&(&str, &str, &str)> = keymap::READONLY_ROWS - .iter() - .filter(|(zh, en, combo)| { - query.is_empty() || format!("{zh} {en} {combo}").to_lowercase().contains(&query) - }) - .collect(); - if !readonly.is_empty() { - groups_block = groups_block.child( - div() - .pt_3() - .pb_1() - .text_size(px(self.font_size_px(cx) * 0.86)) - .text_color(cx.theme().muted_foreground) - .child("只读"), - ); - for (zh, _en, combo) in readonly { - groups_block = groups_block.child( - h_flex() - .w_full() - .h(px(SETTINGS_ROW_HEIGHT)) - .flex_shrink_0() - .items_center() - .pr_4() - .child( - div() - .flex_1() - .min_w_0() - .pl_4() - .text_color(crate::gpui_shell::theme::faint_ink(cx)) - .child(*zh), - ) - .child( - div() - .min_w(px(72.0)) - .px_2() - .h(px(24.0)) - .flex() - .items_center() - .justify_center() - .rounded(px(crate::display::UI_CORNER_RADIUS_LOGICAL * 0.75)) - .border_1() - .border_color(cx.theme().border) - .text_size(px(self.font_size_px(cx) * 0.86)) - .text_color(crate::gpui_shell::theme::faint_ink(cx)) - .child(*combo), - ), - ); - } - } - - self.group("按键映射", cx) - // 捕获态的「点击任何位置先撤销」(旧壳 input/chrome.rs 的统一撤 - // 销合同):行的 mouse_down 会 stop_propagation 自行处理转移/ - // 取消,搜索框这里显式撤销(旧壳点搜索框 = blur 捕获),其余 - // 任何落点冒泡到这里 = 纯取消。 - .when(self.keymap_capture.is_some(), |section| { - section.on_mouse_down( - MouseButton::Left, - cx.listener(|this, _, _, cx| { - if this.keymap_capture.take().is_some() { - this.keymap_capture_preview.clear(); - cx.notify(); - } - }), - ) - }) - // 搜索框:宽度同其它控件右列(220),旧壳搜索行几何的对应物。 - .child( - h_flex() - .w_full() - .h(px(SETTINGS_ROW_HEIGHT)) - .flex_shrink_0() - .items_center() - .pr_4() - .child(div().flex_1().min_w_0().pl_4().child("搜索")) - .child( - div().w(px(220.0)).child(Input::new(&self.keymap_search_input).small()), - ), - ) - // 冲突是允许存在的可见状态,用组件库的 Warning Alert 呈现; - // 不再用自绘 danger 色块,也不静默删掉另一个动作。 - .when_some(clash_note, |section, note| { - section.child(Alert::warning("keymap-clash-warning", note).small().mt_2()) - }) - .child(groups_block) - .child( - div() - .pt_4() - .text_size(px(self.font_size_px(cx) * 0.86)) - .text_color(cx.theme().muted_foreground) - .child("点击行改键 · Backspace 恢复默认 · Esc 取消"), - ) - } - - fn section_advanced(&mut self, cx: &mut Context) -> gpui::Div { - self.group("高级", cx) - .child(self.switch_row( - "keep_session", - "关窗后保留后台会话", - self.runtime.keep_session, - cx, - )) - .child(self.switch_row( - "restore_session", - "启动时恢复上次标签", - self.runtime.restore_session, - cx, - )) - .child(self.switch_row( - "resume_ai", - "恢复会话时自动接续 AI 对话", - self.runtime.resume_ai, - cx, - )) - .child(self.switch_row("tray", "常驻系统托盘图标(旧壳生效)", self.runtime.tray, cx)) - } - - fn section_content(&mut self, cx: &mut Context) -> gpui::AnyElement { - use gpui::IntoElement as _; - match self.active_section { - 0 => self.section_appearance(cx), - 1 => self.section_profiles(cx), - 2 => self.section_providers(cx), - 3 => self.section_ssh(cx), - 4 => self.section_network(cx), - 5 => self.section_interaction(cx), - 6 => self.section_keymap(cx), - 7 => self.section_advanced(cx), - _ => self.section_backup(cx), - } - .into_any_element() - } - - fn render_nav(&self, cx: &mut Context) -> gpui::AnyElement { - use gpui::IntoElement as _; - let theme = cx.theme(); - let muted = theme.muted_foreground; - let query = self.settings_search_input.read(cx).value().to_string(); - let active_bg = crate::gpui_shell::theme::settings_hover_bg(cx, true); - let active_fg = theme.sidebar_accent_foreground; - let hover_bg = crate::gpui_shell::theme::settings_hover_bg(cx, false); - let row_h = px(32.0); - // 设置导航、内容和 workspace 左侧 tab 共享这一个主文字字号。 - let main_text_px = self.font_size_px(cx); - - // tty7 Settings:左栏顶部是搜索框,页面入口直接平铺在下面。保留 - // Nebula 原有 section 身份与显示顺序,只移除分类 caption。 - let mut nav = v_flex() - .w(px(SETTINGS_NAV_WIDTH)) - .h_full() - .flex_shrink_0() - .px_2() - .gap(px(2.0)) - .child( - div() - .h(px(72.0)) - .w_full() - .px_1() - .pt_4() - .child( - Input::new(&self.settings_search_input) - .prefix(IconName::Search) - .small(), - ), - ); - let mut visible_count = 0usize; - for ix in NAV_ORDER { - if !section_matches(ix, &query) { - continue; - } - visible_count += 1; - let active = ix == self.active_section; - nav = nav.child( - div() - .id(("settings-nav", ix)) - .px_2() - .h(row_h) - .flex() - .items_center() - .gap_2() - .rounded_md() - .cursor_pointer() - .text_size(px(main_text_px)) - .font_weight(gpui::FontWeight::NORMAL) - .when(active, |item| item.bg(active_bg).text_color(active_fg)) - .when(!active, |item| item.text_color(muted).hover(|s| s.bg(hover_bg))) - .on_click(cx.listener(move |this, _, _, cx| { - this.active_section = ix; - cx.notify(); - })) - .child(Icon::new(section_icon(ix)).small().text_color(if active { - active_fg - } else { - muted - })) - .child(SECTIONS[ix]), - ); - } - if visible_count == 0 { - nav = nav.child( - div() - .px_3() - .py_2() - .text_sm() - .text_color(muted) - .child("没有匹配的设置"), - ); - } - nav.into_any_element() - } -} - -impl EventEmitter for SettingsPane {} - -impl Focusable for SettingsPane { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for SettingsPane { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let nav = self.render_nav(cx); - let content = self.section_content(cx); - // 旧壳设置页全部文字走终端 mono 字体(draw_chrome_text 的字形缓存), - // sans 只属于组件库默认;根上挂一次,nav/表单/下拉弹层全部继承。 - let family: SharedString = self.current_font_chain(cx).into(); - let base_px = self.font_size_px(cx); - let font_picker_open = self.font_picker_open; - let ssh_editor_modal = self.ssh_editor_modal(cx); - - div() - .size_full() - .track_focus(&self.focus_handle) - // 旧壳 `input/chrome.rs` 的合同是页面任何左键先撤销捕获。 - // 行自身会 stop_propagation 并完成「同行取消 / 他行转移」;其它 - // 控件的 mouse_down 仍先取得自己的焦点,这里只清状态,不抢焦点, - // 因而点进搜索框后键盘完整归 InputState。 - .on_mouse_down( - MouseButton::Left, - cx.listener(|this, _, _, cx| { - if this.keymap_capture.take().is_some() { - this.keymap_capture_preview.clear(); - cx.notify(); - } - }), - ) - // 按键捕获态的键盘独占:焦点在本根 div(点行时收进来),键盘 - // 事件沿焦点路径冒泡到这里;捕获未激活时不挂处理器,输入框、 - // 下拉等组件照常吃键。修饰键实时回显同址(ModifiersChanged - // 不是 KeyDown,走独立通道)。 - .when_some(self.keymap_capture, |root, _| { - root.on_key_down(cx.listener( - |this, event: &KeyDownEvent, _window, cx| { - let Some(row) = this.keymap_capture else { return }; - cx.stop_propagation(); - match crate::display::keymap::capture_gpui(&event.keystroke) { - crate::display::keymap::CaptureOutcome::Cancel => { - this.keymap_capture = None; - this.keymap_capture_preview.clear(); - cx.notify(); - }, - crate::display::keymap::CaptureOutcome::ClearCustom => { - this.keymap_clear_custom(row, cx); - }, - crate::display::keymap::CaptureOutcome::Bind(combo) => { - this.keymap_assign(row, combo, cx); - }, - crate::display::keymap::CaptureOutcome::Pending => {}, - } - }, - )) - .on_modifiers_changed(cx.listener( - |this, event: &ModifiersChangedEvent, _window, cx| { - if this.keymap_capture.is_none() { - return; - } - let prefix = - crate::display::keymap::gpui_mods_prefix(&event.modifiers); - if this.keymap_capture_preview != prefix { - this.keymap_capture_preview = prefix; - cx.notify(); - } - }, - )) - }) - // 旧壳设置页是独立的不透明 panel;终端壁纸只属于终端内容, - // 不应穿透设置文字和控件。 - // - // 这层不透明底必须自带卡圆角:外层终端卡虽然 `overflow_hidden`, - // 但 GPUI 的裁剪是矩形 content_mask、不跟圆角,方角底会直接盖掉 - // 卡的四个圆角——这就是设置页看着「四角是直角」的原因。 - .rounded(crate::gpui_shell::theme::card_radius()) - .bg(crate::gpui_shell::theme::settings_panel_bg(cx)) - .text_color(cx.theme().foreground) - .font_family(family) - // 主文字的字号/字重从设置根向两栏继承;局部仅允许说明文字、 - // 徽标等语义上的次级信息覆盖为更小字号。 - .text_size(px(base_px)) - .font_weight(gpui::FontWeight::NORMAL) - .flex() - .flex_row() - .child(nav) - .child( - v_flex() - .flex_1() - .min_w_0() - .h_full() - // 旧壳标题栏固定 72px;正文单独滚动,滚动设置时仍能知道 - // 自己在哪个分区,也不会让标题与首组的距离随内容变化。 - .child( - div() - .h(px(SETTINGS_HEADER_HEIGHT)) - .flex_shrink_0() - .px_6() - .flex() - .items_center() - .text_size(px(base_px)) - .font_weight(gpui::FontWeight::NORMAL) - .child(SECTIONS[self.active_section]), - ) - .child( - v_flex() - .flex_1() - .min_h_0() - .px_6() - .pt_8() - .pb_8() - // 正文继承设置根的统一主字号;只有说明、徽标、 - // 快捷键提示等次级信息在各自元素上显式缩小。 - .overflow_y_scrollbar() - .child(content) - .child( - div() - .pt_8() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child( - "写入 nebula_settings.txt,与旧壳共享同一份设置;两边可交替修改。", - ), - ), - ), - ) - .when_some(ssh_editor_modal, |root, modal| root.child(modal)) - .when(font_picker_open, |root| { - root - // 搜索框是当前焦点时 Escape 仍沿元素树冒泡到设置根; - // 这里只接管弹层生命周期,不干扰其它设置输入。 - .on_key_down(cx.listener( - |this, event: &KeyDownEvent, window, cx| { - if event.keystroke.key.eq_ignore_ascii_case("escape") { - cx.stop_propagation(); - this.close_font_picker(window, true, cx); - } - }, - )) - // 旧壳的“第一击只关闭浮层”合同:透明层覆盖设置页其余 - // 控件并吃掉 mouse-down;字体面板自身通过 deferred - // priority=2 绘制在该层之上,内部搜索/滚动/点击照常工作。 - .child( - div() - .id("font-picker-dismiss-layer") - .absolute() - .top_0() - .right_0() - .bottom_0() - .left_0() - .cursor_default() - .on_mouse_down( - MouseButton::Left, - cx.listener(|this, _, window, cx| { - cx.stop_propagation(); - this.close_font_picker(window, true, cx); - }), - ), - ) - }) - } -} +//! 设置页(GPUI 组件版,覆盖旧壳设置的全部纯键值项)。 +//! +//! 版面对齐旧壳:**左侧分区导航 + 右侧单分区内容**(分区名与顺序对照 +//! `NebulaSettingsSection`:外观/配置文件/供应商/SSH/网络/交互/按键映射/ +//! 高级/备份),并以应用主页承载关于、更新与支持入口。 +//! +//! 薄壳纪律:本文件不定义任何设置语义——键名、值域、出厂默认、主题色表、 +//! 持久化格式全部在共享 crate `nebula-settings`(从旧壳逐字迁移并有单测 +//! 锁定)。这里只负责用组件库把字段摆出来;改动经 `persist_keys` 原地 +//! 写回 `nebula_settings.txt`,与旧壳读写同一份文件、同一套语义。 +//! +//! 生效时机(对齐旧壳):主题/配色/背景色/字号/copy_on_select 即时热应用; +//! 字体族与默认光标形状对新标签页生效;窗口透明/模糊/托盘/快速终端热键 +//! 等依赖尚未迁移的窗口子系统,写盘后由旧壳消费(本页如实标注)。 +//! +//! 仍依赖旧壳窗口子系统的设置会在对应分区明确标注;SSH 主机、AI +//! Provider、按键映射和备份编辑器已经复用共享业务层与持久化合同。 + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, Hsla, Image, + ImageFormat, InteractiveElement as _, IntoElement, KeyDownEvent, ModifiersChangedEvent, + MouseButton, ParentElement as _, Render, RenderImage, Rgba as GpuiRgba, SharedString, + StatefulInteractiveElement as _, Styled as _, Subscription, Window, anchored, deferred, div, + img, px, +}; +use gpui_component::input::InputEvent; +use gpui_component::select::{SelectEvent, SelectItem}; +use nebula_settings::{RuntimeSettings, ThemeName, format_hex_rgb, persist_keys}; +use std::sync::Arc; + +use crate::gpui_shell::prelude::*; +use crate::gpui_shell::widgets::NebulaButton; + +/// 主题下拉(展示名 = 持久化名,与旧壳一致)。 +const THEME_VALUES: [&str; 7] = + ["Nebula", "SilverLight", "SteelDark", "LimestoneLight", "CoalDark", "LinenLight", "MossDark"]; + +const REPOSITORY_URL: &str = "https://github.com/Kuddev/nebula"; +const BUG_REPORT_TEMPLATE: &str = "bug_report.yml"; + +/// 左侧分区导航。主页承载应用身份、版本与支持入口;供应商和备份仍保留 +/// 业务实现,但不作为设置主导航入口,避免把旧壳没有的管理页塞进侧栏。 +const SECTIONS: [&str; 10] = + ["主页", "外观", "配置文件", "供应商", "SSH", "网络", "交互", "按键映射", "高级", "备份"]; + +/// 左侧导航保留稳定的 [`SECTIONS`] 下标,只控制旧壳式显示顺序;AI +/// 供应商(3)和备份(9)仍可由业务层复用,但不再出现在设置侧栏。 +const NAV_ORDER: [usize; 8] = [0, 1, 2, 6, 7, 4, 5, 8]; + +// 这些几何值逐项来自旧壳 `display/settings.rs::settings_geometry`。GPUI +// 设置页沿用同一节奏,避免组件默认间距把标题、分组和表单压成一条均匀列表。 +const SETTINGS_NAV_WIDTH: f32 = 196.0; +const SETTINGS_HEADER_HEIGHT: f32 = 72.0; +const SETTINGS_GROUP_GAP: f32 = 32.0; +const SETTINGS_GROUP_TITLE_HEIGHT: f32 = 26.0; +const SETTINGS_GROUP_TITLE_GAP: f32 = 16.0; +const SETTINGS_ROW_HEIGHT: f32 = 44.0; +// 旧壳下拉框要求弹层与触发器同宽;集中维护可避免任一侧调整后再次漂移。 +const FONT_PICKER_WIDTH: f32 = 220.0; +/// 展开面板比触发按钮宽:长族名("Maple Mono Normal NF CN")+ 预览小样 + +/// 徽标一行放下,名字不再被徽标挤断。 +const FONT_PICKER_PANEL_WIDTH: f32 = 400.0; + +const THEME_NAMES: [ThemeName; 7] = [ + ThemeName::Nebula, + ThemeName::SilverLight, + ThemeName::SteelDark, + ThemeName::LimestoneLight, + ThemeName::CoalDark, + ThemeName::LinenLight, + ThemeName::MossDark, +]; + +fn section_icon(index: usize) -> IconName { + match index { + 0 => IconName::LayoutDashboard, + 1 => IconName::Palette, + 2 => IconName::GalleryVerticalEnd, + 3 => IconName::Bot, + 4 => IconName::SquareTerminal, + 5 => IconName::Globe, + 6 => IconName::Inspector, + 7 => IconName::ALargeSmall, + 8 => IconName::Settings2, + _ => IconName::Inbox, + } +} + +fn chrome_theme(theme: ThemeName) -> crate::display::NebulaTheme { + use crate::display::NebulaTheme; + match theme { + ThemeName::Nebula => NebulaTheme::Nebula, + ThemeName::SilverLight => NebulaTheme::SilverLight, + ThemeName::SteelDark => NebulaTheme::SteelDark, + ThemeName::LimestoneLight => NebulaTheme::LimestoneLight, + ThemeName::CoalDark => NebulaTheme::CoalDark, + ThemeName::LinenLight => NebulaTheme::LinenLight, + ThemeName::MossDark => NebulaTheme::MossDark, + } +} + +fn rgb_hsla(r: u8, g: u8, b: u8) -> Hsla { + GpuiRgba { r: f32::from(r) / 255.0, g: f32::from(g) / 255.0, b: f32::from(b) / 255.0, a: 1.0 } + .into() +} + +fn query_component(value: &str) -> String { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + encoded.push(char::from(byte)); + } else { + encoded.push('%'); + encoded.push(char::from(HEX[(byte >> 4) as usize])); + encoded.push(char::from(HEX[(byte & 0x0f) as usize])); + } + } + encoded +} + +/// 对齐 Netcatty 的 GitHub issue form 预填合同:模板、版本、平台、安装来源 +/// 和诊断摘要都来自当前运行实例,用户只需补充复现步骤与实际表现。 +fn issue_url() -> String { + let version = env!("CARGO_PKG_VERSION"); + let platform = match std::env::consts::OS { + "windows" => "Windows", + "macos" => "macOS", + "linux" => "Linux", + _ => "Other", + }; + let install_source = if cfg!(debug_assertions) { + "Built from source (cargo build / cargo run)" + } else { + "GitHub Release (.msi / .exe / portable archive)" + }; + let build = if cfg!(debug_assertions) { "debug" } else { "release" }; + let logs = format!( + "Reported from Nebula Settings (Nebula {version}).\n\nPlatform: {} {}\nBuild: {build}", + std::env::consts::OS, + std::env::consts::ARCH, + ); + let params = [ + ("template", BUG_REPORT_TEMPLATE), + ("title", "[Bug] "), + ("version", version), + ("platform", platform), + ("install_source", install_source), + ("logs", logs.as_str()), + ] + .into_iter() + .map(|(key, value)| format!("{key}={}", query_component(value))) + .collect::>() + .join("&"); + format!("{REPOSITORY_URL}/issues/new?{params}") +} + +/// 宿主(workspace)监听:设置已写盘 / 请求打开 SSH 会话。 +pub enum SettingsPaneEvent { + Changed, + /// 设置页"连接"按钮:宿主开 SSH tab(连接语义在业务层)。 + LaunchSsh(String), +} + +type SharedSelect = Entity>>; +type SharedShellSelect = Entity>>; + +#[derive(Clone)] +struct ShellSelectItem { + id: String, + name: SharedString, + closed_image: Option>, + row_image: Option>, +} + +#[derive(Clone, Debug, Default)] +enum AboutUpdateState { + #[default] + Idle, + Checking, + UpToDate(String), + Available(String), + Failed(String), +} + +use super::ssh_settings::{SshDeleteUndo, SshEditorState}; + +impl ShellSelectItem { + fn new(id: String, name: String, scale_factor: f32) -> Self { + // Select 的闭态和菜单行尺寸不同。分别生成与物理像素一一对应的纹理, + // 避免把 128px 原图交给 GPUI 在每帧缩小而产生模糊边缘。 + let closed_image = + crate::gpui_shell::widgets::shell_brand_image(&id, 20.0, scale_factor); + let row_image = crate::gpui_shell::widgets::shell_brand_image(&id, 24.0, scale_factor); + Self { id, name: name.into(), closed_image, row_image } + } + + fn view(&self, size: f32, image: Option<&Arc>) -> gpui::AnyElement { + let icon: gpui::AnyElement = if let Some(image) = image { + gpui::StyledImage::object_fit( + img(image.clone()).size(px(size)).flex_shrink_0(), + gpui::ObjectFit::Contain, + ) + .into_any_element() + } else { + Icon::new(IconName::SquareTerminal).xsmall().into_any_element() + }; + h_flex() + .gap_2() + .items_center() + .child(icon) + .child(div().flex_1().min_w_0().child(self.name.clone())) + .into_any_element() + } +} + +impl SelectItem for ShellSelectItem { + type Value = String; + + fn title(&self) -> SharedString { + self.name.clone() + } + + fn display_title(&self) -> Option { + // 闭态留出 chevron 与上下内边距;20px 在 32px Select 内不会挤字。 + Some(self.view(20.0, self.closed_image.as_ref())) + } + + fn render(&self, _: &mut Window, _: &mut App) -> impl IntoElement { + // 旧壳 ShellPickerRow 的品牌图标是 24×24 逻辑像素。 + self.view(24.0, self.row_image.as_ref()) + } + + fn value(&self) -> &Self::Value { + &self.id + } + + fn matches(&self, query: &str) -> bool { + self.name.to_lowercase().contains(&query.to_lowercase()) + || self.id.to_lowercase().contains(&query.to_lowercase()) + } +} + +pub struct SettingsPane { + pub(super) focus_handle: FocusHandle, + /// 渲染与写盘的单一事实源;每次 persist 后整体重载。 + runtime: RuntimeSettings, + /// 当前分区(`SECTIONS` 下标);默认落在应用主页。 + active_section: usize, + about_logo: Arc, + about_update: AboutUpdateState, + about_update_seq: u64, + selects: Vec<(&'static str, SharedSelect)>, + shell_select: SharedShellSelect, + dir_input: Entity, + /// 外观页使用组件库的真实交互控件;状态实体是渲染与拖拽的唯一来源。 + background_color: Entity, + opacity_slider: Entity, + wallpaper_opacity_slider: Entity, + proxy_url_input: Entity, + proxy_bypass_input: Entity, + provider_store: crate::ai_providers::ProviderStore, + /// Name / note / website / endpoint / model. API keys deliberately do not + /// use a GPUI text widget; the native credential dialog is write-only. + provider_inputs: Vec>, + provider_status: Option<(String, bool)>, + provider_test_seq: u64, + provider_test_running: bool, + provider_codex_confirm: Option, + /// SSH 主机列表(共享三键 + merge 权威);操作后整体重载防漂移。 + /// SSH 区的行为实现拆在 `ssh_settings.rs`(同类型第二个 impl 块)。 + pub(super) ssh_hosts: crate::gpui_shell::ssh_hosts::SshHostLists, + /// SSH 编辑器的文本字段常驻,以便所有文本改动都能使进行中的测试失效。 + pub(super) ssh_destination_input: Entity, + pub(super) ssh_port_input: Entity, + pub(super) ssh_label_input: Entity, + pub(super) ssh_password_input: Entity, + /// 身份条头像的图标选择器(旧壳 `SshEditorHit::Avatar` + `icon_popup`): + /// 点头像展开,顶部一个正经搜索框,下面是分组过的目录。不做成右上角 + /// 的下拉框——图标属于头像那件事,摆成独立字段就成了可填可不填的杂项。 + pub(super) ssh_icon_picker_open: bool, + pub(super) ssh_icon_filter_input: Entity, + /// 头像上一帧的窗口坐标,供弹层锚定(同字体目录的做法)。 + pub(super) ssh_icon_trigger_bounds: Option>, + pub(super) ssh_editor: Option, + pub(super) ssh_editor_seq: u64, + pub(super) ssh_test_seq: u64, + pub(super) ssh_status: Option<(String, bool)>, + pub(super) ssh_show_hidden: bool, + /// 删除确认(二次点击生效,旧壳确认对话框的轻量对应)。 + pub(super) ssh_delete_confirm: Option, + /// 未决删除的撤销窗口(8 秒;见 `ssh_settings::SshDeleteUndo`)。 + pub(super) ssh_delete_undo: Option, + pub(super) ssh_undo_seq: u64, + /// 字体选择器(旧壳 `SettingsDropdown::Font` 的 GPUI 形态):展开态 + + /// 「显示全部」临时过滤(不落盘)+ 惰性枚举的系统/导入字体目录。 + pub(super) font_picker_open: bool, + font_show_all: bool, + font_loading: bool, + /// None = 尚未枚举;首次展开时在后台线程装配(几百字体的机器上 + /// `IsMonospacedFont` 逐族探询是实打实的开销,不挡 UI 帧)。 + font_system: Option>, + /// GPUI text system 已注册的导入族名(启动扫描 + 本次导入累计)。 + font_imported: Vec, + font_query_input: Entity, + /// 触发按钮上一帧的窗口坐标。字体目录是宽弹层,不能把整条设置行当 + /// 锚点;否则按钮在右侧、菜单却会从正文左缘展开。 + font_picker_trigger_bounds: Option>, + /// 导入/选择的错误驻留条(旧壳 `nebula_font_notice` 同义)。 + font_notice: Option, + /// 备份类别选择(本地 UI 态;出厂默认 = 共享 `BackupSelection::default`)。 + backup_selection: crate::encrypted_backup::BackupSelection, + /// 备份密码(masked;只在导出/恢复动作瞬时读取,不落任何配置)。 + backup_pass_input: Entity, + backup_status: Option<(String, bool)>, + /// 导出/恢复/推送进行中(按钮禁用 + 忽略过期完成回调)。 + backup_busy: bool, + backup_seq: u64, + /// 远端同步配置(`nebula_backup.txt` 共享权威)。 + backup_remote: crate::backup_remote::BackupRemoteConfig, + /// 非密文槽位输入(容量按最多槽位的协议 S3 = 4)。 + backup_remote_inputs: Vec>, + /// 密文槽(WebDAV 密码 / S3 Secret):masked,写入凭据管理器后即清。 + backup_secret_input: Entity, + /// 按键映射编辑器(旧壳 spec 002 的 GPUI 形态):搜索输入 + 捕获态 + + /// `keybind=` 行的工作镜像。模型层(combo 解析/展示/冲突/默认表)复用 + /// `display::keymap`,两壳同一套存储与语义。 + keymap_search_input: Entity, + keymap_capture: Option, + keymap_capture_preview: String, + keymap_binds: Vec<(String, String)>, + _subscriptions: Vec, +} + +impl SettingsPane { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let runtime = RuntimeSettings::load(); + let mut selects: Vec<(&'static str, SharedSelect)> = Vec::new(); + let mut subscriptions = Vec::new(); + + let mut add_select = |key: &'static str, + labels: &'static [&'static str], + values: &'static [&'static str], + current: &str, + window: &mut Window, + cx: &mut Context| { + let ix = values.iter().position(|v| *v == current).unwrap_or(0); + let select = cx.new(|cx| { + SelectState::new( + labels.iter().map(|l| SharedString::from(*l)).collect::>(), + Some(IndexPath::default().row(ix)), + window, + cx, + ) + }); + subscriptions.push(cx.subscribe_in( + &select, + window, + move |this: &mut Self, + entity: &SharedSelect, + event: &SelectEvent>, + _window: &mut Window, + cx: &mut Context| { + if let SelectEvent::Confirm(Some(_)) = event { + let row = entity.read(cx).selected_index(cx).map(|path| path.row); + if let Some(value) = row.and_then(|row| values.get(row)) { + this.persist(&[(key, (*value).to_string())], cx); + } + } + }, + )); + selects.push((key, select)); + }; + + let cursor_current = + runtime.cursor_shape.map(|shape| shape.settings_value()).unwrap_or("beam"); + let shell_current = runtime.shell.clone().unwrap_or_else(|| "powershell".into()); + + add_select( + "language", + &["跟随系统", "简体中文", "English"], + &["system", "zh-CN", "en-US"], + runtime.language.settings_value(), + window, + cx, + ); + add_select("theme", &THEME_VALUES, &THEME_VALUES, runtime.theme.prompt_name(), window, cx); + // 选项顺序与文案照抄旧壳 `CURSOR_SHAPE_OPTIONS` / `cursor_shape_label`。 + add_select( + "cursor_shape", + &["条形(│)", "下划线(_)", "实心框(█)", "空心框(□)"], + &["beam", "underline", "block", "hollow"], + cursor_current, + window, + cx, + ); + add_select( + "tab_reveal", + &["滑动", "立即"], + &["slide", "instant"], + runtime.tab_reveal.settings_value(), + window, + cx, + ); + add_select( + "density", + &["标准", "紧凑"], + &["standard", "compact"], + runtime.density.settings_value(), + window, + cx, + ); + add_select( + "new_tab_position", + &["当前标签之后", "列表末尾"], + &["after_current", "end"], + runtime.new_tab_position.settings_value(), + window, + cx, + ); + add_select( + "vcs_display", + &["自动检测", "仅 Git", "仅 SVN"], + &["auto", "git", "svn"], + runtime.vcs_display.settings_value(), + window, + cx, + ); + add_select( + "cell_width_mode", + &["紧凑", "宽松"], + &["compact", "relaxed"], + runtime.cell_width_mode.settings_value(), + window, + cx, + ); + // 文案照抄旧壳 `accept_label` / `completion_style_label`。 + add_select( + "accept", + &["右方向键", "Tab", "Tab 或右方向键"], + &["right", "tab", "both"], + runtime.accept.settings_value(), + window, + cx, + ); + add_select( + "completion_style", + &["行内灰字", "弹窗列表"], + &["inline", "popup"], + runtime.completion_style.settings_value(), + window, + cx, + ); + // 壁纸 fit/对齐:存原文,经旧壳 renderer::image 的 parse 归一化 + // (兼容 cover/contain 等别名),展示用规范记号。 + let bgimg_fit = crate::renderer::image::BackgroundImageFit::parse( + runtime.background_image_fit.as_deref().unwrap_or(""), + ) + .unwrap_or_default() + .settings_value(); + // 顺序与文案照抄旧壳 `BACKGROUND_FIT_OPTIONS` / `background_image_fit_label`。 + add_select( + "background_image_fit", + &["拉伸", "适应", "填充", "原始尺寸"], + &["fill", "uniform", "uniform_to_fill", "none"], + bgimg_fit, + window, + cx, + ); + let bgimg_align = crate::renderer::image::BackgroundImageAlignment::parse( + runtime.background_image_alignment.as_deref().unwrap_or(""), + ) + .unwrap_or_default() + .settings_value(); + // 九宫格顺序照抄旧壳 `BACKGROUND_ALIGNMENT_OPTIONS`(左上 → 右下)。 + add_select( + "background_image_alignment", + &["左上", "顶部", "右上", "左侧", "居中", "右侧", "左下", "底部", "右下"], + &[ + "top_left", + "top", + "top_right", + "left", + "center", + "right", + "bottom_left", + "bottom", + "bottom_right", + ], + bgimg_align, + window, + cx, + ); + add_select( + "ssh_proxy_mode", + &["关闭", "系统代理", "自定义"], + &["off", "system", "custom"], + runtime.ssh_proxy_mode.settings_value(), + window, + cx, + ); + + // 与旧壳的默认 Shell 菜单共用检测层:不能在设置页另维护一份两项 + // 白名单,否则 CMD/Nushell/WSL 会出现在新建终端菜单,却无法设为默认。 + // 选项 = 彩色品牌 PNG(extra/shell-icons,与旧壳设置页/命令面板同 + // 一批资产)+ 名称,闭态与下拉同源(SelectItem::display_title/render)。 + let shell_icon_scale = window.scale_factor().max(0.5); + let detected = crate::shell_detect::detect_shells(); + let mut shell_items: Vec = detected + .into_iter() + .map(|shell| ShellSelectItem::new(shell.id, shell.name, shell_icon_scale)) + .collect(); + if shell_items.is_empty() { + // 非 Windows 构建不做安装探测,但历史配置仍支持这两个由 PTY + // 集成层负责启动的稳定 id,设置页不能因此变成空下拉。 + shell_items = vec![ + ShellSelectItem::new("powershell".into(), "PowerShell".into(), shell_icon_scale), + ShellSelectItem::new("bash".into(), "Git Bash".into(), shell_icon_scale), + ]; + } + if !shell_items.iter().any(|item| item.id == shell_current) { + // 检测结果可能暂时找不到已保存的 WSL/profile id;先把它保留在首位, + // 用户仍可看到并重新选择,下一次检测恢复后不会丢失持久化值。 + shell_items.insert( + 0, + ShellSelectItem::new( + shell_current.clone(), + crate::shell_detect::display_name_for_id(&shell_current).to_owned(), + shell_icon_scale, + ), + ); + } + let shell_index = shell_items.iter().position(|item| item.id == shell_current).unwrap_or(0); + let shell_select = cx.new(|cx| { + SelectState::new(shell_items, Some(IndexPath::default().row(shell_index)), window, cx) + }); + subscriptions.push(cx.subscribe_in( + &shell_select, + window, + move |this: &mut Self, + _entity: &SharedShellSelect, + event: &SelectEvent>, + _window: &mut Window, + cx: &mut Context| { + if let SelectEvent::Confirm(Some(id)) = event { + this.persist(&[("shell", id.clone())], cx); + } + }, + )); + + let dir_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("留空 = 继承当前窗格目录") + .default_value(runtime.startup_directory.clone().unwrap_or_default()) + }); + let background_color_value = runtime + .background + .map(|[r, g, b]| rgb_hsla(r, g, b)) + .unwrap_or_else(|| cx.theme().background); + let background_color = + cx.new(|cx| ColorPickerState::new(window, cx).default_value(background_color_value)); + subscriptions.push(cx.subscribe( + &background_color, + |this, _, event: &ColorPickerEvent, cx| { + if let ColorPickerEvent::Change(Some(color)) = event { + this.persist(&[("background", color.to_hex())], cx); + } + }, + )); + let opacity_slider = cx.new(|_| { + SliderState::new().min(0.20).max(1.00).step(0.05).default_value(runtime.opacity) + }); + subscriptions.push(cx.subscribe(&opacity_slider, |this, _, event: &SliderEvent, cx| { + match event { + SliderEvent::Change(value) => this.set_opacity(value.start(), cx), + } + })); + let wallpaper_opacity_slider = cx.new(|_| { + SliderState::new() + .min(0.05) + .max(1.00) + .step(0.05) + .default_value(runtime.background_image_opacity) + }); + subscriptions.push(cx.subscribe( + &wallpaper_opacity_slider, + |this, _, event: &SliderEvent, cx| match event { + SliderEvent::Change(value) => this.set_wallpaper_opacity(value.start(), cx), + }, + )); + let proxy_url_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("socks5://127.0.0.1:7890") + .default_value(runtime.ssh_proxy_url.clone()) + }); + let proxy_bypass_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("逗号分隔,如 10.0.0.0/8,*.internal") + .default_value(runtime.ssh_proxy_no_proxy.clone()) + }); + let provider_store = crate::ai_providers::load(); + let active_provider = provider_store + .providers + .iter() + .find(|provider| provider.id == provider_store.active_id) + .or_else(|| provider_store.providers.first()) + .cloned() + .unwrap_or_else(|| { + crate::ai_providers::AiProvider::preset( + crate::ai_providers::ProviderKind::Custom, + "custom-1", + ) + }); + let provider_values = [ + active_provider.name, + active_provider.note, + active_provider.website_url, + active_provider.base_url, + active_provider.model, + ]; + let provider_placeholders = + ["供应商名称", "备注(可包含空格)", "官方网站", "API 请求地址", "默认模型"]; + let provider_inputs = provider_values + .into_iter() + .zip(provider_placeholders) + .map(|(value, placeholder)| { + cx.new(|cx| { + InputState::new(window, cx).placeholder(placeholder).default_value(value) + }) + }) + .collect(); + + // 远端备份的非密文槽位输入(容量按最多槽位的协议 S3 = 4);值在 + // 构造体内按当前协议回填。 + let backup_remote_inputs: Vec> = + (0..4).map(|_| cx.new(|cx| InputState::new(window, cx))).collect(); + + // 占位文案与旧壳 `ssh_editor_render` 一字不差(对齐合同)。 + let ssh_destination_input = + cx.new(|cx| InputState::new(window, cx).placeholder("user@example.com")); + // 端口键入即过滤:至多 5 位数字(旧壳同规则;范围校验在保存/测试时做)。 + let ssh_port_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("22") + .pattern(regex::Regex::new(r"^\d{0,5}$").expect("static regex")) + }); + let ssh_label_input = + cx.new(|cx| InputState::new(window, cx).placeholder("给这台机器起个名字")); + let ssh_password_input = + cx.new(|cx| InputState::new(window, cx).masked(true).placeholder("留空则连接时询问")); + let ssh_icon_filter_input = + cx.new(|cx| InputState::new(window, cx).placeholder("搜索图标…")); + for input in [ + ssh_destination_input.clone(), + ssh_port_input.clone(), + ssh_label_input.clone(), + ssh_password_input.clone(), + ] { + subscriptions.push(cx.subscribe_in( + &input, + window, + |this: &mut Self, _, event: &InputEvent, _, cx: &mut Context| { + if matches!(event, InputEvent::Change) { + this.touch_ssh_editor(cx); + } + }, + )); + } + + let font_query_input = cx.new(|cx| InputState::new(window, cx).placeholder("搜索字体…")); + subscriptions.push(cx.subscribe_in( + &font_query_input, + window, + |_: &mut Self, _, event: &InputEvent, _, cx| { + // 列表跟着打字走(旧壳「所见即所搜」);目录装配在渲染时按 + // 当前查询串现算,这里只需要触发一帧。 + if matches!(event, InputEvent::Change) { + cx.notify(); + } + }, + )); + + Self { + focus_handle: cx.focus_handle(), + runtime, + active_section: 0, + about_logo: Arc::new(Image::from_bytes( + ImageFormat::Png, + include_bytes!("../../../extra/logo/nebula.png").to_vec(), + )), + about_update: AboutUpdateState::Idle, + about_update_seq: 0, + selects, + shell_select, + dir_input, + background_color, + opacity_slider, + wallpaper_opacity_slider, + proxy_url_input, + proxy_bypass_input, + provider_store, + provider_inputs, + provider_status: None, + provider_test_seq: 0, + provider_test_running: false, + provider_codex_confirm: None, + ssh_hosts: crate::gpui_shell::ssh_hosts::SshHostLists::load(), + ssh_destination_input, + ssh_port_input, + ssh_label_input, + ssh_password_input, + ssh_icon_picker_open: false, + ssh_icon_filter_input, + ssh_icon_trigger_bounds: None, + ssh_editor: None, + ssh_editor_seq: 0, + ssh_test_seq: 0, + ssh_status: None, + ssh_show_hidden: false, + ssh_delete_confirm: None, + ssh_delete_undo: None, + ssh_undo_seq: 0, + font_picker_open: false, + font_show_all: false, + font_loading: false, + font_system: None, + font_imported: Vec::new(), + font_query_input, + font_picker_trigger_bounds: None, + font_notice: None, + backup_selection: crate::encrypted_backup::BackupSelection::default(), + backup_pass_input: cx.new(|cx| { + InputState::new(window, cx).masked(true).placeholder("备份密码(至少 8 位)") + }), + backup_status: None, + backup_busy: false, + backup_seq: 0, + backup_remote: { + let cfg = crate::backup_remote::BackupRemoteConfig::load(); + for (ix, input) in backup_remote_inputs.iter().enumerate() { + let value = cfg.slot(ix).unwrap_or_default().to_owned(); + input.update(cx, |input, cx| input.set_value(value, window, cx)); + } + cfg + }, + backup_remote_inputs, + backup_secret_input: cx.new(|cx| { + InputState::new(window, cx).masked(true).placeholder("凭据只写入系统凭据管理器") + }), + keymap_search_input: { + let input = cx.new(|cx| InputState::new(window, cx).placeholder("搜索动作或按键…")); + subscriptions.push(cx.subscribe_in( + &input, + window, + |_this: &mut Self, + _: &Entity, + event: &InputEvent, + _: &mut Window, + cx: &mut Context| { + // 搜索词变化只影响可见行集合;捕获态不因打字被打断 + // (捕获期间焦点在分区根上,输入框收不到键)。 + if matches!(event, InputEvent::Change) { + cx.notify(); + } + }, + )); + input + }, + keymap_capture: None, + keymap_capture_preview: String::new(), + keymap_binds: nebula_settings::keybind_pairs(), + _subscriptions: subscriptions, + } + } + + /// 写盘 → 重载单一事实源与全局 `Settings` → 通知宿主热应用。 + fn persist(&mut self, updates: &[(&str, String)], cx: &mut Context) { + if let Err(err) = persist_keys(updates) { + eprintln!("[nebula:gpui] failed to persist settings: {err}"); + } + self.runtime = RuntimeSettings::load(); + let settings = crate::gpui_shell::config::Settings::load( + crate::gpui_shell::theme::effective_theme_name(cx), + ); + cx.set_global(settings); + cx.emit(SettingsPaneEvent::Changed); + cx.notify(); + } + + fn toggle(&mut self, key: &'static str, value: bool, window: &mut Window, cx: &mut Context) { + if key == "follow_system_theme" { + self.set_follow_system_theme(value, window, cx); + return; + } + self.persist(&[(key, (value as u8).to_string())], cx); + } + + /// 对齐旧壳 `toggle_system_theme_following`:开关会 `apply_nebula_theme`, + /// 把终端底色写成**此刻生效**主题的 `term_bg`。开启时按系统外观折算家族 + /// 成员;关闭时回到用户点选的 preference,不再继续跟 OS。 + fn set_follow_system_theme( + &mut self, + follow: bool, + window: &mut Window, + cx: &mut Context, + ) { + let applied = crate::gpui_shell::theme::resolve_theme_name( + self.runtime.theme, + follow, + crate::gpui_shell::theme::system_is_light(cx), + ); + self.persist( + &[ + ("follow_system_theme", (follow as u8).to_string()), + ( + "background", + format_hex_rgb(applied.term_theme().background), + ), + ], + cx, + ); + self.sync_background_color_picker(window, cx); + } + + /// 取色器只是预览,不能当事实源:空 `background=` 表示跟随主题, + /// 色块要显示生效主题的 `term_bg`,而不是被劫持成壳色的 `theme.background`。 + fn sync_background_color_picker(&mut self, window: &mut Window, cx: &mut Context) { + let color = self + .runtime + .background + .map(|[r, g, b]| rgb_hsla(r, g, b)) + .unwrap_or_else(|| crate::gpui_shell::theme::theme_term_background(cx)); + self.background_color.update(cx, |state, cx| { + state.set_value(color, window, cx); + }); + } + + /// 旧壳“恢复默认设置”只重置外观,不触碰 Shell、SSH、快捷键等业务配置。 + fn reset_appearance(&mut self, window: &mut Window, cx: &mut Context) { + self.persist( + &[ + ("theme", "Nebula".to_owned()), + ("follow_system_theme", "0".to_owned()), + ("opacity", "1".to_owned()), + ("background", String::new()), + ("background_image", String::new()), + ("background_image_opacity", "0.38".to_owned()), + ("background_image_fit", String::new()), + ("background_image_alignment", String::new()), + ("background_image_cover_chrome", "0".to_owned()), + ], + cx, + ); + self.sync_background_color_picker(window, cx); + } + + /// Workspace tab、设置导航和设置内容共用的主文字字号事实源。 + /// + /// 旧壳合同(display/mod.rs `ui_font_px`):chrome 排版锚定**配置字号** + /// (nebula.toml `font.size`,默认 11.25pt = 15px),终端的持久化缩放 + /// (设置 spinner / Ctrl+滚轮写入的 `font_size=`)只影响终端网格, + /// 不得放大侧栏与设置文字。 + fn font_size_px(&self, cx: &App) -> f32 { + cx.global::().base_font_size_px + } + + /// 终端字号(预览与「终端字号」步进行显示的值)。 + fn terminal_font_size_px(&self, cx: &App) -> f32 { + cx.global::().font_size_px + } + + fn set_font_size(&mut self, size: f32, cx: &mut Context) { + let size = size.clamp(4.0, 96.0); + self.persist(&[("font_size", format!("{size:.2}"))], cx); + } + + fn set_opacity(&mut self, opacity: f32, cx: &mut Context) { + let opacity = opacity.clamp(0.2, 1.0); + self.persist(&[("opacity", format!("{opacity:.2}"))], cx); + } + + fn set_wallpaper_opacity(&mut self, opacity: f32, cx: &mut Context) { + let opacity = opacity.clamp(0.05, 1.0); + self.persist(&[("background_image_opacity", format!("{opacity:.2}"))], cx); + } + + // ---- 字体选择器(旧壳 toggle_font_picker / font_catalog 的 GPUI 形态)---- + + /// 当前生效字体链(settings.txt 覆盖 toml 后的值,可能含逗号 fallback)。 + pub(super) fn current_font_chain(&self, cx: &App) -> String { + cx.try_global::() + .map(|settings| settings.font_family.clone()) + .unwrap_or_else(|| String::from(crate::font_install::REQUIRED_FONT_FAMILY)) + } + + fn toggle_font_picker(&mut self, window: &mut Window, cx: &mut Context) { + if self.font_picker_open { + self.close_font_picker(window, true, cx); + return; + } + + // 旧壳每次重新展开都从完整目录开始,搜索串和上次错误不跨弹层 + // 生命周期残留;昂贵目录只在第一次打开时异步枚举。 + self.font_notice = None; + self.font_query_input.update(cx, |input, cx| input.set_value("", window, cx)); + self.font_picker_open = true; + self.ensure_font_catalog(cx); + self.font_query_input.update(cx, |input, cx| input.focus(window, cx)); + cx.notify(); + } + + /// 关闭字体弹层并清理这次展开的临时查询。`restore_focus` 只在 Esc、 + /// 选中和点击外部时使用;系统文件选择器接管焦点期间不强抢窗口焦点。 + fn close_font_picker( + &mut self, + window: &mut Window, + restore_focus: bool, + cx: &mut Context, + ) { + if !self.font_picker_open { + return; + } + self.font_picker_open = false; + self.font_query_input.update(cx, |input, cx| input.set_value("", window, cx)); + if restore_focus { + window.focus(&self.focus_handle); + } + cx.notify(); + } + + /// 惰性装配字体目录:系统族枚举 + 已导入文件逐个探测族名,全部丢 + /// 后台线程(旧壳在 UI 线程同步做;GPUI 帧预算更紧,不值得卡一帧)。 + fn ensure_font_catalog(&mut self, cx: &mut Context) { + if self.font_system.is_some() || self.font_loading { + return; + } + #[cfg(windows)] + { + self.font_loading = true; + let task = cx.background_executor().spawn(async move { + let system = crate::font_install::enumerate_system_font_families(); + let imported: Vec = crate::font_install::imported_font_files() + .iter() + .filter_map(|path| crate::font_install::probe_font_file_families(path).ok()) + .flatten() + .collect(); + (system, imported) + }); + cx.spawn(async move |this, cx| { + let (system, imported) = task.await; + let _ = this.update(cx, |pane, cx| { + pane.font_system = Some(system); + for family in imported { + if !pane + .font_imported + .iter() + .any(|known| known.eq_ignore_ascii_case(&family)) + { + pane.font_imported.push(family); + } + } + pane.font_loading = false; + cx.notify(); + }); + }) + .detach(); + } + #[cfg(not(windows))] + { + self.font_system = Some(Vec::new()); + } + } + + /// 选中即生效:只换主族、保留用户手写的 fallback 链(共享 + /// `replace_primary_font_family`),写盘后经 `persist` 的 Changed 事件 + /// 让宿主逐终端热应用——字体度量变化会走 viewport observe 自动重排 + /// 网格并 resize PTY。 + fn pick_font_family(&mut self, family: String, window: &mut Window, cx: &mut Context) { + let chain = self.current_font_chain(cx); + let next = crate::font_install::replace_primary_font_family(&chain, &family); + self.font_notice = None; + self.close_font_picker(window, true, cx); + self.persist(&[("font_family", next)], cx); + } + + #[cfg(windows)] + fn import_font_file(&mut self, window: &mut Window, cx: &mut Context) { + let receiver = cx.prompt_for_paths(gpui::PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some("导入终端字体".into()), + }); + cx.spawn_in(window, async move |this, cx| { + let Ok(Ok(Some(paths))) = receiver.await else { return }; + let Some(source) = paths.into_iter().next() else { return }; + let _ = this.update_in(cx, |pane, window, cx| { + pane.finish_font_import(&source, window, cx); + }); + }) + .detach(); + } + + /// 导入落库(共享 `store_imported_font`:哈希去重存入数据目录)→ + /// DirectWrite 探测族名 → 注册进 GPUI text system → 立即应用首族 + /// (旧壳 `set_terminal_font_by_index` 的导入分支同义)。 + #[cfg(windows)] + fn finish_font_import( + &mut self, + source: &std::path::Path, + window: &mut Window, + cx: &mut Context, + ) { + let stored = match crate::font_install::store_imported_font(source) { + Ok(stored) => stored, + Err(error) => { + self.font_notice = Some(error); + cx.notify(); + return; + }, + }; + let cleanup = |stored: &crate::font_install::StoredFont| { + if stored.created { + let _ = std::fs::remove_file(&stored.path); + } + }; + let families = match crate::font_install::probe_font_file_families(&stored.path) { + Ok(families) => families, + Err(error) => { + cleanup(&stored); + self.font_notice = Some(format!("字体无法加载:{error}")); + cx.notify(); + return; + }, + }; + let bytes = match std::fs::read(&stored.path) { + Ok(bytes) => bytes, + Err(error) => { + cleanup(&stored); + self.font_notice = Some(format!("无法读取导入字体:{error}")); + cx.notify(); + return; + }, + }; + if let Err(error) = cx.text_system().add_fonts(vec![std::borrow::Cow::Owned(bytes)]) { + cleanup(&stored); + self.font_notice = Some(format!("字体注册失败:{error}")); + cx.notify(); + return; + } + for family in &families { + if !self.font_imported.iter().any(|known| known.eq_ignore_ascii_case(family)) { + self.font_imported.push(family.clone()); + } + } + if let Some(first) = families.into_iter().next() { + self.pick_font_family(first, window, cx); + } + } + + /// 字体行(收起态):用真实 GPUI Button 承载下拉锚点。当前主族继承 + /// 设置页正在使用的字体链,因而按钮文字本身就是 WYSIWYG 预览。 + fn font_picker_row(&self, cx: &mut Context) -> gpui::AnyElement { + let chain = self.current_font_chain(cx); + let primary: SharedString = crate::renderer::primary_font_family(&chain).to_owned().into(); + let picker = cx.entity().downgrade(); + let control = div() + .relative() + .w(px(FONT_PICKER_WIDTH)) + .child( + Button::new("font-picker-toggle") + .w_full() + .selected(self.font_picker_open) + .child( + h_flex() + .w_full() + .min_w_0() + .justify_between() + .gap_2() + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_left() + .font_family(primary.clone()) + .child(primary), + ) + .child( + Icon::new(if self.font_picker_open { + IconName::ChevronUp + } else { + IconName::ChevronDown + }) + .xsmall(), + ), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_font_picker(window, cx); + })), + ) + // 与组件库 Popover 相同:用零绘制 canvas 捕获触发控件真实 + // Bounds。坐标来自布局结果,滚动、DPI 和窗口缩放后仍然准确。 + .child( + gpui::canvas( + move |bounds, _, cx| { + let _ = picker.update(cx, |picker, _| { + picker.font_picker_trigger_bounds = Some(bounds); + }); + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ); + self.row("字体", control).into_any_element() + } + + /// 展开面板:搜索 + 「显示全部」临时过滤 + 目录列表(真实字体预览、 + /// 等宽/导入/内置徽标、当前项高亮)+ 导入按钮。目录装配走共享 + /// `font_catalog`(同名合并、当前项保底),内置字体按旧壳规则恒排首位。 + fn font_picker_panel(&mut self, cx: &mut Context) -> gpui::Div { + use crate::font_install::{FontCatalogEntry, FontSource, REQUIRED_FONT_FAMILY}; + + let theme = cx.theme(); + let muted = theme.muted_foreground; + let hover_bg = theme.list_hover; + let selected_bg = theme.list_active; + let warning = theme.warning; + let chain = self.current_font_chain(cx); + let primary = crate::renderer::primary_font_family(&chain).to_owned(); + let query = self.font_query_input.read(cx).value().to_string(); + let system = self.font_system.clone().unwrap_or_default(); + let mut catalog = crate::font_install::font_catalog( + &system, + &self.font_imported, + self.font_show_all, + &query, + &primary, + ); + // 内置字体永远排在最前(旧壳 rebuild_font_catalog 同规则), + // 搜索不命中也不藏——它是出厂兜底,消失会让人以为坏了。 + catalog.retain(|entry| !entry.name.eq_ignore_ascii_case(REQUIRED_FONT_FAMILY)); + catalog.insert( + 0, + FontCatalogEntry { + name: REQUIRED_FONT_FAMILY.to_owned(), + monospaced: true, + source: FontSource::Imported, + }, + ); + + let badge = |text: &'static str, color: Hsla| { + div() + .flex_shrink_0() + .px(px(6.0)) + .py(px(1.0)) + .rounded_sm() + .text_xs() + .text_color(color) + .border_1() + .border_color(color.opacity(0.4)) + .child(text) + }; + + let rows: Vec<_> = catalog + .into_iter() + .enumerate() + .map(|(ix, entry)| { + let name = entry.name.clone(); + let family: SharedString = entry.name.clone().into(); + let selected = entry.name.eq_ignore_ascii_case(&primary); + let required = entry.name.eq_ignore_ascii_case(REQUIRED_FONT_FAMILY); + h_flex() + .id(SharedString::from(format!("font-row-{ix}"))) + .h(px(36.0)) + .w_full() + .px_2() + .gap_2() + .items_center() + .rounded_md() + .cursor_pointer() + .when(selected, |row| row.bg(selected_bg)) + .hover(|row| row.bg(hover_bg)) + // 族名用界面字体排:行高稳定、任何名字都可读;该字体的 + // 真实字形交给右侧预览小样展示。 + .child(div().flex_1().min_w_0().truncate().text_sm().child(entry.name.clone())) + .child( + div() + .flex_shrink_0() + .text_sm() + .text_color(muted) + .font_family(family.clone()) + .child("AaBb 012"), + ) + .when(required, |row| row.child(badge("内置", muted))) + .when(!required && entry.source == FontSource::Imported, |row| { + row.child(badge("导入", muted)) + }) + .when(!entry.monospaced, |row| row.child(badge("比例", warning))) + .on_click(cx.listener(move |this, _, window, cx| { + this.pick_font_family(name.clone(), window, cx); + })) + }) + .collect(); + let empty = rows.is_empty(); + + v_flex() + // 面板由字体行的 deferred/anchored 槽托管,不参与设置文档流高度; + // 固定宽度让族名与徽标保持可读,窄窗由 anchored + // 自动贴边,行为与组件库 Select 的弹层一致。 + .w(px(FONT_PICKER_PANEL_WIDTH)) + .max_w_full() + .p_3() + .gap_2() + // 与 Select/Popover 共用组件库表面,避免字体菜单成为另一套 + // 边框、圆角和阴影语言。 + .popover_style(cx) + .occlude() + .child( + h_flex() + .gap_3() + .items_center() + // 搜索占剩余宽度;「显示全部」和开关 shrink_0 保证不被 + // 挤出面板。 + .child( + div() + .flex_1() + .min_w_0() + .max_w(px(200.0)) + .child(Input::new(&self.font_query_input)), + ) + .child(div().flex_shrink_0().text_sm().text_color(muted).child("显示全部")) + .child(Switch::new("font-show-all").checked(self.font_show_all).on_click( + cx.listener(|this, checked: &bool, _, cx| { + // 临时过滤,不写入设置(旧壳 toggle_font_show_all)。 + this.font_show_all = *checked; + cx.notify(); + }), + )), + ) + .when(self.font_loading, |panel| { + panel.child( + h_flex() + .gap_2() + .items_center() + .child(Spinner::new().xsmall()) + .child(div().text_sm().text_color(muted).child("正在枚举系统字体…")), + ) + }) + .when(!self.font_loading, |panel| { + panel.child( + // gap/宽度都必须写在滚动区内层:`overflow_y_scrollbar` 会把 + // 外层样式搬走(见本文件设置正文处的详注),写在它之前的 + // gap_1 落在只有一个子项的外层、对行间距无效,行上的 + // w_full 也会因父级宽度不确定而回落 max-content,使每行 + // 宽度随字体名长短参差。 + v_flex().max_h(px(320.0)).overflow_y_scrollbar().child( + v_flex().w_full().gap_1().children(rows).when(empty, |list| { + list.child( + div().py_2().text_sm().text_color(muted).child("没有匹配的字体"), + ) + }), + ), + ) + }) + .child( + h_flex() + .gap_2() + .items_center() + .child({ + let button = NebulaButton::new("font-import").label("导入字体文件…"); + #[cfg(windows)] + let button = button.on_click(cx.listener(|this, _, window, cx| { + this.import_font_file(window, cx); + })); + button + }) + .child( + div().text_xs().text_color(muted).child("支持 .ttf / .otf / .ttc / .otc"), + ) + .when_some(self.font_notice.clone(), |row, notice| { + row.child(div().text_xs().text_color(theme.danger).child(notice)) + }), + ) + } + + /// 字体行和浮层的锚点。使用 GPUI 与 `Popover` 相同的真实触发框 + /// Bounds + `deferred(anchored())` 管线:浮层不参与文档流,右边缘与 + /// 按钮右边缘对齐,窗口空间不足时再由 anchored 贴边收拢。 + fn font_picker_dropdown(&mut self, cx: &mut Context) -> gpui::Div { + let row = self.font_picker_row(cx); + let panel = self.font_picker_open.then(|| self.font_picker_panel(cx)); + let trigger_bounds = self.font_picker_trigger_bounds; + + div().relative().w_full().h(px(SETTINGS_ROW_HEIGHT)).flex_shrink_0().child(row).when_some( + panel.zip(trigger_bounds), + |anchor, (panel, trigger_bounds)| { + anchor.child( + deferred( + anchored() + .anchor(gpui::Corner::TopRight) + .position(trigger_bounds.bottom_right()) + .offset(gpui::point(px(0.0), px(6.0))) + .snap_to_window_with_margin(px(8.0)) + .child(panel), + ) + // 页面级透明拦截层优先级为普通元素;弹层必须始终压在 + // 它上面,否则鼠标到不了搜索框和候选行。 + .with_priority(2), + ) + }, + ) + } + + fn select_of(&self, key: &str) -> Option { + self.selects.iter().find(|(k, _)| *k == key).map(|(_, entity)| entity.clone()) + } + + /// 旧壳设置页靠留白与标题分组,不用外层线框把每段内容圈成盒子。 + pub(super) fn group(&self, title: &'static str, cx: &Context) -> gpui::Div { + // 分组标题与侧栏 tab、设置导航共用正文基准字号和 Regular 字重; + // 层级只靠颜色与留白表达,避免标题额外放大后整页出现三套字号。 + let title_px = self.font_size_px(cx); + v_flex() + .w_full() + .child( + div() + .h(px(SETTINGS_GROUP_TITLE_HEIGHT)) + .flex() + .items_center() + .text_size(px(title_px)) + .font_weight(gpui::FontWeight::NORMAL) + .text_color(cx.theme().sidebar_accent_foreground) + .child(title), + ) + // 旧壳组标题占 26px,首行位于标题顶端下方 42px;中间这 16px + // 是形成分组呼吸感的关键,不能交给组件默认 gap 随意变化。 + .child(div().h(px(SETTINGS_GROUP_TITLE_GAP)).flex_shrink_0()) + } + + /// 一级设置组之间的分隔。容器高度保持原有 32px 组间距,细线位于 + /// 中央,因此增加层级提示但不改变页面纵向密度。 + fn group_divider(cx: &Context) -> gpui::Div { + div() + .w_full() + .h(px(SETTINGS_GROUP_GAP)) + .flex_shrink_0() + .flex() + .items_center() + .child(div().w_full().h(px(1.0)).bg(cx.theme().border)) + } + + fn row(&self, label: &'static str, control: impl IntoElement) -> impl IntoElement { + // 行高走旧壳密度阶梯(`tokens::control::settings_row`):标准 44、 + // 紧凑 38——「界面外观」设置由此对设置页真实生效。 + let row_h = if self.runtime.density == nebula_settings::DensityName::Compact { + 38.0 + } else { + SETTINGS_ROW_HEIGHT + }; + h_flex() + .w_full() + .h(px(row_h)) + .flex_shrink_0() + .items_center() + // 旧壳 `settings_geometry`:标签起 row_x+16,控件右缘距行右 16 + // (ROW_INSET)。GPUI 行内两端原来都贴边,长表单左缘和右缘会 + // 呈一条光柱——旧壳的两档缩进是刻意留的呼吸边。 + .pr_4() + .child(div().flex_1().min_w_0().pl_4().child(label)) + .child(control) + } + + fn select_row( + &self, + key: &'static str, + label: &'static str, + cx: &Context, + ) -> impl IntoElement { + let select = self.select_of(key); + // 闭态选中值 = accent(旧壳 combobox_value 15 处调用 14 处传 + // sk.accent)。闭框/背景都不带文字色,包一层就能继承下去;右侧 + // chevron 在组件内自带 muted,不会被染色。 + self.row( + label, + div() + .w(px(220.0)) + .text_color(cx.theme().link) + .children(select.map(|state| Select::new(&state))), + ) + } + + fn shell_select_row(&self, cx: &Context) -> impl IntoElement { + let family: SharedString = cx + .try_global::() + .map(|settings| settings.font_family.clone()) + .unwrap_or_else(|| String::from("Maple Mono Normal NF CN")) + .into(); + self.row( + "默认 Shell", + div() + .w(px(220.0)) + .font_family(family) + .text_color(cx.theme().link) + .child(Select::new(&self.shell_select)), + ) + } + + fn appearance_preview(&self, cx: &Context) -> gpui::Div { + let theme = chrome_theme(crate::gpui_shell::theme::effective_theme_name(cx)); + let palette = theme.palette(); + let ink = theme.card_ink().fg; + let background = if self.runtime.follow_system_theme { + [palette.term_bg.r, palette.term_bg.g, palette.term_bg.b] + } else { + self.runtime.background.unwrap_or([ + palette.term_bg.r, + palette.term_bg.g, + palette.term_bg.b, + ]) + }; + let family: SharedString = cx + .try_global::() + .map(|settings| settings.font_family.clone()) + .unwrap_or_else(|| String::from("Maple Mono Normal NF CN")) + .into(); + let size = self.terminal_font_size_px(cx).clamp(11.0, 20.0); + let foreground = rgb_hsla(ink.r, ink.g, ink.b); + let accent = theme.accent(); + + v_flex() + .w_full() + .h(px(150.0)) + .p_4() + .gap_1() + .rounded_lg() + .bg(rgb_hsla(background[0], background[1], background[2])) + .font_family(family) + .text_size(px(size)) + .text_color(foreground) + .child("user@nebula ~ $ nebula --version") + .child(format!( + "Nebula Terminal · {} · {:.0}px", + self.runtime.font_family.as_deref().unwrap_or("Maple Mono Normal NF CN"), + size + )) + .child( + h_flex() + .gap_1() + .items_center() + .child(div().text_color(rgb_hsla(accent.r, accent.g, accent.b)).child("❯")) + .child(div().w(px(8.0)).h(px(size)).bg(foreground)), + ) + } + + fn theme_previews(&self, cx: &mut Context) -> gpui::Div { + h_flex().w_full().flex_wrap().gap(px(20.0)).children(THEME_NAMES.into_iter().map(|name| { + let theme = chrome_theme(name); + let palette = theme.palette(); + let accent = theme.accent(); + let selected = crate::gpui_shell::theme::effective_theme_name(cx) == name; + v_flex() + .id(SharedString::from(format!("theme-preview-{}", name.prompt_name()))) + .w(px(170.0)) + .h(px(92.0)) + .gap_2() + .cursor_pointer() + .child( + v_flex() + .h(px(64.0)) + .flex_shrink_0() + .p_2() + .gap_1() + .rounded_lg() + .bg(rgb_hsla(palette.shell_bg.r, palette.shell_bg.g, palette.shell_bg.b)) + .when(selected, |card| { + card.border_1().border_color(rgb_hsla(accent.r, accent.g, accent.b)) + }) + .when(!selected, |card| card.hover(|style| style.shadow_md())) + .child( + v_flex() + .flex_1() + .p_2() + .gap_1() + .rounded_md() + .bg(rgb_hsla( + palette.term_bg.r, + palette.term_bg.g, + palette.term_bg.b, + )) + .child( + h_flex() + .gap_1() + .child( + div() + .w(px(10.0)) + .h(px(4.0)) + .rounded_full() + .bg(rgb_hsla(accent.r, accent.g, accent.b)), + ) + .child(div().w(px(52.0)).h(px(4.0)).rounded_full().bg( + rgb_hsla( + theme.card_ink().fg.r, + theme.card_ink().fg.g, + theme.card_ink().fg.b, + ), + )), + ) + .child(div().w(px(82.0)).h(px(4.0)).rounded_full().bg(rgb_hsla( + palette.edge_l.r, + palette.edge_l.g, + palette.edge_l.b, + ))) + .child(div().w(px(58.0)).h(px(4.0)).rounded_full().bg(rgb_hsla( + palette.edge_r.r, + palette.edge_r.g, + palette.edge_r.b, + ))), + ), + ) + .child( + div() + .w_full() + .text_xs() + .text_center() + .text_color(if selected { + rgb_hsla(accent.r, accent.g, accent.b) + } else { + cx.theme().muted_foreground + }) + .child(theme.short_label()), + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.persist( + &crate::gpui_shell::theme::theme_card_persist_updates(name), + cx, + ); + this.sync_background_color_picker(window, cx); + })) + })) + } + + /// 布尔项 = 滑动开关(旧壳设置的液态胶囊 toggle),不是勾选框。 + /// 组件是移植的旧壳动画开关(48×26、0.3s ease、按压拉伸)。 + fn switch_row( + &self, + key: &'static str, + label: &'static str, + checked: bool, + cx: &mut Context, + ) -> impl IntoElement { + self.row( + label, + crate::gpui_shell::widgets::NebulaSwitch::new(key).checked(checked).on_click( + cx.listener(move |this, checked: &bool, window, cx| { + this.toggle(key, *checked, window, cx); + }), + ), + ) + } + + /// 文本输入 + 保存按钮(保存时读值写盘)。 + fn input_row( + &self, + label: &'static str, + key: &'static str, + input: &Entity, + cx: &mut Context, + ) -> impl IntoElement { + let state = input.clone(); + self.row( + label, + h_flex() + .gap_2() + .items_center() + .child(div().w(px(280.0)).child(Input::new(input))) + .child( + NebulaButton::new(SharedString::from(format!("save-{key}"))) + .label("保存") + .on_click(cx.listener(move |this, _, _, cx| { + let value = state.read(cx).value().to_string(); + this.persist(&[(key, value)], cx); + })), + ), + ) + } + + fn stepper_row( + &self, + label: &'static str, + id: &'static str, + display: SharedString, + on_minus: impl Fn(&mut Self, &mut Context) + 'static, + on_plus: impl Fn(&mut Self, &mut Context) + 'static, + cx: &mut Context, + ) -> impl IntoElement { + self.row( + label, + h_flex() + .gap_2() + .items_center() + .child( + NebulaButton::new(SharedString::from(format!("minus-{id}"))) + .label("−") + .on_click(cx.listener(move |this, _, _, cx| { + on_minus(this, cx); + })), + ) + .child(div().min_w(px(64.0)).child(display)) + .child( + NebulaButton::new(SharedString::from(format!("plus-{id}"))) + .label("+") + .on_click(cx.listener(move |this, _, _, cx| { + on_plus(this, cx); + })), + ), + ) + } + + /// 旧壳连续量使用轨道而不是加减按钮;数值和轨道共享同一个 + /// `SliderState`,拖动时由订阅统一写盘并热应用。 + fn slider_row( + &self, + label: &'static str, + state: &Entity, + display: SharedString, + ) -> impl IntoElement { + self.row( + label, + h_flex() + .w(px(220.0)) + .items_center() + .gap_3() + .child(div().flex_1().min_w_0().child(Slider::new(state))) + .child(div() + .w(px(48.0)) + .flex_shrink_0() + // 固定数值列宽,百分比位数变化时轨道不会左右跳动。 + .child(display)), + ) + } + + fn background_color_row(&self, cx: &mut Context) -> impl IntoElement { + let featured = crate::display::BACKGROUND_SWATCHES + .iter() + .map(|color| { + let (r, g, b) = color.as_tuple(); + rgb_hsla(r, g, b) + }) + .collect::>(); + let current: SharedString = self + .runtime + .background + .map(format_hex_rgb) + .unwrap_or_else(|| "跟随主题".to_owned()) + .into(); + let custom = self.runtime.background.is_some(); + self.row( + "背景色", + h_flex() + .w(px(280.0)) + .items_center() + .gap_2() + .child(ColorPicker::new(&self.background_color).featured_colors(featured).small()) + .child(div().flex_1().min_w_0().truncate().child(current)) + .child( + NebulaButton::new("background-color-reset") + .label("跟随主题") + .disabled(!custom) + .on_click(cx.listener(|this, _, window, cx| { + this.persist(&[("background", String::new())], cx); + this.sync_background_color_picker(window, cx); + })), + ), + ) + } + + fn choose_background_image(&mut self, cx: &mut Context) { + let picked = cx.prompt_for_paths(gpui::PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some("选择终端背景图片".into()), + }); + cx.spawn(async move |this, cx| { + let Ok(Ok(Some(paths))) = picked.await else { return }; + let Some(path) = paths.into_iter().next() else { return }; + let value = path.to_string_lossy().into_owned(); + let _ = this.update(cx, |pane, cx| { + pane.persist(&[("background_image", value)], cx); + }); + }) + .detach(); + } + + fn background_image_row(&self, cx: &mut Context) -> impl IntoElement { + let current = self.runtime.background_image.clone(); + let has_image = current.as_ref().is_some_and(|path| !path.trim().is_empty()); + let display: SharedString = current + .as_deref() + .filter(|path| !path.trim().is_empty()) + .unwrap_or("未选择") + .to_owned() + .into(); + self.row( + "背景图片", + h_flex() + .w(px(420.0)) + .items_center() + .gap_2() + .child( + div() + .flex_1() + .min_w_0() + .text_color(cx.theme().muted_foreground) + .truncate() + .child(display), + ) + .child(NebulaButton::new("background-image-choose").label("选择图片…").on_click( + cx.listener(|this, _, _, cx| { + this.choose_background_image(cx); + }), + )) + .child( + NebulaButton::new("background-image-clear") + .label("清除") + .disabled(!has_image) + .on_click(cx.listener(|this, _, _, cx| { + this.persist(&[("background_image", String::new())], cx); + })), + ), + ) + } + + fn active_provider_index(&self) -> Option { + self.provider_store + .providers + .iter() + .position(|provider| provider.id == self.provider_store.active_id) + .or_else(|| (!self.provider_store.providers.is_empty()).then_some(0)) + } + + fn sync_provider_inputs(&self, window: &mut Window, cx: &mut Context) { + let Some(index) = self.active_provider_index() else { return }; + let draft = + crate::ai_providers::ProviderMetadataDraft::from(&self.provider_store.providers[index]); + let values = [draft.name, draft.note, draft.website_url, draft.base_url, draft.model]; + for (input, value) in self.provider_inputs.iter().zip(values) { + input.update(cx, |input, cx| input.set_value(value, window, cx)); + } + } + + fn select_provider(&mut self, id: String, window: &mut Window, cx: &mut Context) { + if self.provider_store.providers.iter().any(|provider| provider.id == id) { + self.provider_store.active_id = id; + let _ = crate::ai_providers::save(&self.provider_store); + self.provider_codex_confirm = None; + self.provider_status = None; + self.sync_provider_inputs(window, cx); + cx.notify(); + } + } + + fn save_provider_metadata(&mut self, cx: &mut Context) -> bool { + let Some(index) = self.active_provider_index() else { return false }; + let values: Vec = + self.provider_inputs.iter().map(|input| input.read(cx).value().to_string()).collect(); + let draft = crate::ai_providers::ProviderMetadataDraft { + name: values[0].clone(), + note: values[1].clone(), + website_url: values[2].clone(), + base_url: values[3].clone(), + model: values[4].clone(), + }; + crate::ai_providers::apply_metadata_draft(&mut self.provider_store.providers[index], draft); + match crate::ai_providers::save(&self.provider_store) { + Ok(()) => { + self.provider_status = Some(("供应商配置已保存".to_owned(), false)); + true + }, + Err(error) => { + self.provider_status = Some((error.to_string(), true)); + false + }, + } + } + + fn add_provider(&mut self, window: &mut Window, cx: &mut Context) { + self.save_provider_metadata(cx); + let id = crate::ai_providers::next_custom_id(&self.provider_store); + self.provider_store.providers.push(crate::ai_providers::AiProvider::preset( + crate::ai_providers::ProviderKind::Custom, + &id, + )); + self.provider_store.active_id = id; + self.provider_codex_confirm = None; + match crate::ai_providers::save(&self.provider_store) { + Ok(()) => self.provider_status = Some(("已添加自定义供应商".to_owned(), false)), + Err(error) => self.provider_status = Some((error.to_string(), true)), + } + self.sync_provider_inputs(window, cx); + cx.notify(); + } + + fn delete_provider(&mut self, window: &mut Window, cx: &mut Context) { + let Some(index) = self.active_provider_index() else { return }; + if self.provider_store.providers.len() <= 1 { + self.provider_status = Some(("至少保留一个供应商".to_owned(), true)); + cx.notify(); + return; + } + let id = self.provider_store.providers[index].id.clone(); + match crate::ai_providers::remove_provider(&mut self.provider_store, &id) { + Ok(()) => { + self.provider_status = Some(("供应商及其凭据已删除".to_owned(), false)); + self.provider_codex_confirm = None; + self.sync_provider_inputs(window, cx); + }, + Err(error) => self.provider_status = Some((error.to_string(), true)), + } + cx.notify(); + } + + fn toggle_provider_flag(&mut self, flag: &'static str, value: bool, cx: &mut Context) { + let Some(index) = self.active_provider_index() else { return }; + let provider = &mut self.provider_store.providers[index]; + match flag { + "enabled" => provider.enabled = value, + "codex_goals" => provider.codex_goals = value, + "codex_remote_compaction" => provider.codex_remote_compaction = value, + _ => return, + } + self.provider_codex_confirm = None; + if let Err(error) = crate::ai_providers::save(&self.provider_store) { + self.provider_status = Some((error.to_string(), true)); + } + cx.notify(); + } + + fn prompt_provider_key(&mut self, cx: &mut Context) { + let Some(index) = self.active_provider_index() else { return }; + let provider = &mut self.provider_store.providers[index]; + match crate::ai_providers::prompt_and_store_api_key(provider) { + Ok(true) => { + self.provider_status = Some(("API Key 已保存到系统凭据管理器".to_owned(), false)); + if let Err(error) = crate::ai_providers::save(&self.provider_store) { + self.provider_status = Some((error.to_string(), true)); + } + }, + Ok(false) => {}, + Err(error) => self.provider_status = Some((error.to_string(), true)), + } + cx.notify(); + } + + fn test_provider(&mut self, cx: &mut Context) { + if self.provider_test_running || !self.save_provider_metadata(cx) { + return; + } + let Some(index) = self.active_provider_index() else { return }; + let provider = self.provider_store.providers[index].clone(); + self.provider_test_seq = self.provider_test_seq.wrapping_add(1); + let sequence = self.provider_test_seq; + self.provider_test_running = true; + self.provider_status = Some(("正在测试连接…".to_owned(), false)); + + let task = cx + .background_executor() + .spawn(async move { crate::ai_providers::test_provider(&provider) }); + cx.spawn(async move |this, cx| { + let result = task.await; + let _ = this.update(cx, |pane, cx| { + if sequence != pane.provider_test_seq + || result.provider_id != pane.provider_store.active_id + { + return; + } + pane.provider_test_running = false; + pane.provider_status = + Some((format!("{} · {} ms", result.message, result.elapsed_ms), !result.ok)); + cx.notify(); + }); + }) + .detach(); + cx.notify(); + } + + fn apply_provider_to_codex(&mut self, cx: &mut Context) { + if !self.save_provider_metadata(cx) { + return; + } + let Some(index) = self.active_provider_index() else { return }; + let provider = self.provider_store.providers[index].clone(); + if self.provider_codex_confirm.as_deref() != Some(provider.id.as_str()) { + self.provider_codex_confirm = Some(provider.id); + self.provider_status = Some(( + "再次点击确认:API Key 将明文写入 Codex auth.json(原文件会备份)".to_owned(), + false, + )); + cx.notify(); + return; + } + self.provider_codex_confirm = None; + self.provider_status = Some(match crate::codex_config::apply_provider(&provider) { + Ok(path) => (format!("已应用到 Codex:{}", path.display()), false), + Err(error) => (error, true), + }); + cx.notify(); + } + + // ---- 分区内容(归属对照旧壳各 section)---- + + fn check_for_updates(&mut self, cx: &mut Context) { + if matches!(self.about_update, AboutUpdateState::Checking) { + return; + } + self.about_update_seq = self.about_update_seq.wrapping_add(1); + let sequence = self.about_update_seq; + self.about_update = AboutUpdateState::Checking; + let task = cx.background_executor().spawn(async { crate::update_check::check_now() }); + cx.spawn(async move |this, cx| { + let result = task.await; + let _ = this.update(cx, |pane, cx| { + if pane.about_update_seq != sequence { + return; + } + pane.about_update = match result { + Ok(result) if result.update_available => { + AboutUpdateState::Available(result.latest) + }, + Ok(result) => AboutUpdateState::UpToDate(result.latest), + Err(error) => AboutUpdateState::Failed(error), + }; + cx.notify(); + }); + }) + .detach(); + cx.notify(); + } + + fn about_action_row( + id: &'static str, + icon: IconName, + title: &'static str, + subtitle: &'static str, + url: String, + cx: &Context, + ) -> gpui::AnyElement { + let muted = cx.theme().muted_foreground; + let hover = cx.theme().list_hover; + h_flex() + .id(id) + .w_full() + .min_h(px(52.0)) + .px_3() + .py_2() + .gap_3() + .items_center() + .rounded_md() + .cursor_pointer() + .hover(move |row| row.bg(hover)) + .on_click(move |_, _, cx| cx.open_url(&url)) + .child(Icon::new(icon).small().text_color(muted)) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap(px(2.0)) + .child(div().child(title)) + .child(div().text_xs().text_color(muted).truncate().child(subtitle)), + ) + .child(Icon::new(IconName::ExternalLink).xsmall().text_color(muted)) + .into_any_element() + } + + fn section_home(&mut self, cx: &mut Context) -> gpui::Div { + let theme = cx.theme(); + let muted = theme.muted_foreground; + let checking = matches!(self.about_update, AboutUpdateState::Checking); + let (status, status_color): (SharedString, Hsla) = match &self.about_update { + AboutUpdateState::Idle => ("通过 GitHub Releases 检查新版本。".into(), muted), + AboutUpdateState::Checking => ("正在检查更新…".into(), muted), + AboutUpdateState::UpToDate(latest) => { + (format!("已是最新版本(GitHub v{latest})").into(), theme.success) + }, + AboutUpdateState::Available(latest) => { + (format!("发现新版本 v{latest}").into(), theme.warning) + }, + AboutUpdateState::Failed(error) => (format!("检查失败:{error}").into(), theme.danger), + }; + let update_button = NebulaButton::new("about-check-updates") + .label(if checking { "正在检查…" } else { "检查更新" }) + .outline() + .disabled(checking) + .on_click(cx.listener(|this, _, _, cx| this.check_for_updates(cx))); + let identity = v_flex() + .w(px(300.0)) + .min_w(px(260.0)) + .flex_shrink_0() + .child( + h_flex() + .gap_4() + .items_center() + .child(img(self.about_logo.clone()).size(px(64.0)).rounded(px(12.0))) + .child( + v_flex() + .gap(px(2.0)) + .child( + div() + .text_xl() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child("Nebula"), + ) + .child( + div() + .text_sm() + .text_color(muted) + .child(format!("版本 {}", env!("CARGO_PKG_VERSION"))), + ) + .child(div().text_xs().text_color(muted).child(format!( + "{} · {}", + std::env::consts::OS, + std::env::consts::ARCH + ))), + ), + ) + .child( + div() + .mt_4() + .text_sm() + .text_color(muted) + .child("面向本地 Shell、SSH、分屏与 AI 会话的终端工作区。"), + ) + .child(h_flex().mt_4().gap_2().items_center().child(update_button)) + .child(div().mt_2().text_xs().text_color(status_color).child(status)); + let actions = v_flex() + .flex_1() + .min_w(px(320.0)) + .gap(px(2.0)) + .child(Self::about_action_row( + "about-report-issue", + IconName::TriangleAlert, + "提交 issue", + "生成包含版本、平台与构建方式的预填 GitHub issue", + issue_url(), + cx, + )) + .child(Self::about_action_row( + "about-github", + IconName::GitHub, + "GitHub", + "查看源码、开发进度与贡献指南", + REPOSITORY_URL.to_owned(), + cx, + )) + .child(Self::about_action_row( + "about-releases", + IconName::BookOpen, + "发布记录", + "查看版本说明并下载最新构建", + crate::update_check::RELEASES_PAGE.to_owned(), + cx, + )); + + self.group("关于 Nebula", cx).child( + h_flex() + .w_full() + .items_start() + .flex_wrap() + .gap(px(48.0)) + .child(identity) + .child(actions), + ) + } + + fn section_appearance(&mut self, cx: &mut Context) -> gpui::Div { + // 旧壳 spinner 只显示整数(`{:.0}`);步进也按整数走(见下)。 + let font_size: SharedString = format!("{:.0} px", self.terminal_font_size_px(cx)).into(); + let opacity: SharedString = format!("{:.0}%", self.runtime.opacity * 100.0).into(); + let wallpaper_opacity: SharedString = + format!("{:.0}%", self.runtime.background_image_opacity * 100.0).into(); + let preview = self.group("预览", cx).child(self.appearance_preview(cx)); + let themes = self.group("主题", cx).child(self.theme_previews(cx)); + let theme_mode = self.group("主题模式", cx).child(self.switch_row( + "follow_system_theme", + "跟随系统明暗模式", + self.runtime.follow_system_theme, + cx, + )); + let custom_background = self + .group("自定义背景", cx) + .child(self.background_color_row(cx)) + .child(self.background_image_row(cx)) + .child(self.select_row("background_image_fit", "背景图像拉伸模式", cx)) + .child(self.select_row("background_image_alignment", "背景图像对齐", cx)) + .child(self.slider_row( + "背景图像不透明度", + &self.wallpaper_opacity_slider, + wallpaper_opacity, + )) + .child(self.switch_row( + "background_image_cover_chrome", + "将背景图扩展到标题栏和侧边栏", + self.runtime.background_image_cover_chrome, + cx, + )); + let cursor = self + .group("光标", cx) + .child(self.select_row("cursor_shape", "光标形状", cx)) + .child(self.switch_row( + "cursor_blink", + "光标闪烁", + self.runtime.cursor_blink.unwrap_or(true), + cx, + )); + let interface = self + .group("界面", cx) + .child(self.select_row("language", "语言", cx)) + .child(self.select_row("density", "界面外观", cx)) + .child(self.slider_row("终端正文不透明度", &self.opacity_slider, opacity)) + .child(self.switch_row("blur", "背景模糊", self.runtime.blur, cx)); + let terminal = self + .group("终端外观", cx) + .child(self.stepper_row( + "终端字号(Ctrl+滚轮缩放)", + "font-size", + font_size, + // 整数步进:分数字号(滚轮缩放遗留,如 15.30)先吸附回 + // 最近的整数档,再继续 ±1——不会出现 15.3→14.3 这类漂移。 + |this, cx| { + let size = (this.terminal_font_size_px(cx).ceil() - 1.0).round(); + this.set_font_size(size, cx); + }, + |this, cx| { + let size = (this.terminal_font_size_px(cx).floor() + 1.0).round(); + this.set_font_size(size, cx); + }, + cx, + )) + .child(self.select_row("cell_width_mode", "字体间距", cx)) + .child(self.switch_row("fetch", "启动欢迎信息", self.runtime.fetch, cx)) + .child(self.switch_row("powerline", "Powerline 提示符", self.runtime.powerline, cx)); + + v_flex() + .w_full() + .child(preview) + .child(Self::group_divider(cx)) + .child(themes) + .child(Self::group_divider(cx)) + .child(theme_mode) + .child(Self::group_divider(cx)) + .child(custom_background) + .child(Self::group_divider(cx)) + .child(cursor) + .child(Self::group_divider(cx)) + .child(interface) + .child(Self::group_divider(cx)) + .child(terminal) + } + + fn section_profiles(&mut self, cx: &mut Context) -> gpui::Div { + let font_picker = self.font_picker_dropdown(cx); + let terminal = self + .group("终端", cx) + .child(self.shell_select_row(cx)) + .child(self.input_row("启动目录", "startup_directory", &self.dir_input.clone(), cx)) + .child(font_picker); + let completion = self + .group("补全", cx) + .child(self.switch_row("ghost", "启用命令补全", self.runtime.ghost, cx)) + .child(self.select_row("accept", "补全接受键", cx)) + .child(self.select_row("completion_style", "补全样式", cx)); + v_flex().w_full().child(terminal).child(Self::group_divider(cx)).child(completion) + } + + fn section_providers(&mut self, cx: &mut Context) -> gpui::Div { + let theme = cx.theme(); + let hover_bg = crate::gpui_shell::theme::settings_hover_bg(cx, false); + let active_index = self.active_provider_index().unwrap_or(0); + let active = self.provider_store.providers.get(active_index).cloned(); + let active_id = active.as_ref().map(|provider| provider.id.clone()).unwrap_or_default(); + let provider_rows = self.provider_store.providers.iter().map(|provider| { + let id = provider.id.clone(); + let selected = provider.id == active_id; + let name = provider.name.clone(); + let kind = provider.kind.label(); + h_flex() + .id(SharedString::from(format!("provider-row-{}", provider.id))) + .h(px(34.0)) + .w_full() + .px_2() + .gap_2() + .items_center() + .rounded_md() + .when(selected, |row| row.bg(theme.list_active)) + .hover(move |row| row.bg(hover_bg)) + .child(Icon::new(IconName::Bot).xsmall().text_color(theme.muted_foreground)) + .child(div().flex_1().min_w_0().truncate().child(name)) + .child( + div() + .max_w(px(78.0)) + .text_xs() + .text_color(theme.muted_foreground) + .truncate() + .child(kind), + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.select_provider(id.clone(), window, cx); + })) + }); + + let mut editor = v_flex().flex_1().min_w_0().gap_3(); + if let Some(provider) = active { + let key_status: SharedString = if provider.api_key_set { + if provider.api_key_hint.is_empty() { + "已保存在系统凭据管理器".into() + } else { + provider.api_key_hint.clone().into() + } + } else if provider.kind.requires_api_key() { + "未设置".into() + } else { + "此供应商不需要 API Key".into() + }; + let enabled = provider.enabled; + let goals = provider.codex_goals; + let remote = provider.codex_remote_compaction; + editor = editor + .child(self.row( + "启用", + crate::gpui_shell::widgets::NebulaSwitch::new("provider-enabled") + .checked(enabled) + .on_click(cx.listener(|this, value: &bool, _, cx| { + this.toggle_provider_flag("enabled", *value, cx); + })), + )) + .child(self.row( + "名称", + div().w(px(330.0)).child(Input::new(&self.provider_inputs[0])), + )) + .child(self.row( + "备注", + div().w(px(330.0)).child(Input::new(&self.provider_inputs[1])), + )) + .child(self.row( + "官方网站", + div().w(px(330.0)).child(Input::new(&self.provider_inputs[2])), + )) + .child(self.row( + "API 请求地址", + div().w(px(330.0)).child(Input::new(&self.provider_inputs[3])), + )) + .child(self.row( + "默认模型", + div().w(px(330.0)).child(Input::new(&self.provider_inputs[4])), + )) + .child(self.row( + "API Key", + h_flex() + .gap_2() + .items_center() + .child(div().text_xs().text_color(theme.muted_foreground).child(key_status)) + .child( + NebulaButton::new("provider-set-key") + .label(if provider.api_key_set { "替换…" } else { "设置…" }) + .on_click(cx.listener(|this, _, _, cx| { + this.prompt_provider_key(cx); + })), + ), + )) + .child(self.row( + "Codex Goals", + crate::gpui_shell::widgets::NebulaSwitch::new("provider-codex-goals") + .checked(goals) + .on_click(cx.listener(|this, value: &bool, _, cx| { + this.toggle_provider_flag("codex_goals", *value, cx); + })), + )) + .child(self.row( + "Codex 远程压缩", + crate::gpui_shell::widgets::NebulaSwitch::new("provider-codex-remote") + .checked(remote) + .on_click(cx.listener(|this, value: &bool, _, cx| { + this.toggle_provider_flag("codex_remote_compaction", *value, cx); + })), + )) + .child( + h_flex() + .gap_2() + .child(NebulaButton::new("provider-save").label("保存").on_click( + cx.listener(|this, _, _, cx| { + this.save_provider_metadata(cx); + cx.notify(); + }), + )) + .child( + NebulaButton::new("provider-test") + .label(if self.provider_test_running { + "测试中…" + } else { + "测试连接" + }) + .disabled(self.provider_test_running) + .on_click(cx.listener(|this, _, _, cx| { + this.test_provider(cx); + })), + ) + .child( + NebulaButton::new("provider-codex").label("应用到 Codex").on_click( + cx.listener(|this, _, _, cx| { + this.apply_provider_to_codex(cx); + }), + ), + ) + .child(NebulaButton::new("provider-delete").label("删除").danger().on_click( + cx.listener(|this, _, window, cx| { + this.delete_provider(window, cx); + }), + )), + ); + } else { + editor = editor.child(div().text_color(theme.muted_foreground).child("没有供应商配置")); + } + + self.group("供应商", cx) + .child( + h_flex() + .w_full() + .items_start() + .gap_4() + .child( + v_flex() + .w(px(210.0)) + .flex_shrink_0() + .h(px(420.0)) + .gap_1() + .overflow_y_scrollbar() + .children(provider_rows) + .child( + NebulaButton::new("provider-add") + .label("+ 自定义供应商") + .on_click(cx.listener(|this, _, window, cx| { + this.add_provider(window, cx); + })), + ), + ) + .child(editor), + ) + .when_some(self.provider_status.clone(), |group, (message, error)| { + group.child( + div() + .text_color(if error { theme.danger } else { theme.success }) + .child(message), + ) + }) + } + + + // ---- 备份(本地加密导出/恢复 + 远端同步)---- + + /// 读备份密码并预检(真正的强度校验在 `encrypted_backup` 内部再做一 + /// 次;这里只提前给出友好提示)。 + fn backup_passphrase(&mut self, cx: &mut Context) -> Option { + let pass = self.backup_pass_input.read(cx).value().to_string(); + if pass.chars().count() < 8 { + self.backup_status = Some(("备份密码至少 8 位".to_owned(), true)); + cx.notify(); + return None; + } + Some(pass) + } + + /// 后台执行一段备份计算并把结果写回状态行。`Ok` 文案由任务给出。 + fn backup_run_async( + &mut self, + task: impl std::future::Future> + Send + 'static, + cx: &mut Context, + ) { + self.backup_seq = self.backup_seq.wrapping_add(1); + let seq = self.backup_seq; + self.backup_busy = true; + self.backup_status = Some(("处理中…".to_owned(), false)); + let task = cx.background_executor().spawn(task); + cx.spawn(async move |this, cx| { + let result = task.await; + let _ = this.update(cx, |pane, cx| { + if seq != pane.backup_seq { + return; + } + pane.backup_busy = false; + pane.backup_status = Some(match result { + Ok(message) => (message, false), + Err(error) => (error, true), + }); + cx.notify(); + }); + }) + .detach(); + cx.notify(); + } + + /// 导出:先弹保存对话框(取消则零成本),再后台 collect + seal + 写盘。 + fn export_backup(&mut self, cx: &mut Context) { + if self.backup_busy { + return; + } + let Some(pass) = self.backup_passphrase(cx) else { return }; + if self.backup_selection.is_empty() { + self.backup_status = Some(("请至少勾选一个备份类别".to_owned(), true)); + cx.notify(); + return; + } + let selection = self.backup_selection; + let start_dir = std::env::var_os("USERPROFILE") + .map(std::path::PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let picked = + cx.prompt_for_new_path(&start_dir, Some(&format!("nebula-{stamp}.nebula-backup"))); + cx.spawn(async move |this, cx| { + let Ok(Ok(Some(path))) = picked.await else { return }; + let _ = this.update(cx, |pane, cx| { + pane.backup_run_async( + async move { + let archive = crate::encrypted_backup::collect(selection)?; + let packet = crate::encrypted_backup::seal(&archive, &pass)?; + std::fs::write(&path, packet) + .map_err(|err| format!("写入备份文件失败:{err}"))?; + Ok(format!("已导出加密备份:{}", path.display())) + }, + cx, + ); + }); + }) + .detach(); + } + + /// 恢复:选文件 → 后台解密落盘 → 热应用(设置/主机列表随之重载)。 + fn restore_backup(&mut self, cx: &mut Context) { + if self.backup_busy { + return; + } + let Some(pass) = self.backup_passphrase(cx) else { return }; + let picked = cx.prompt_for_paths(gpui::PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some("选择 Nebula 加密备份".into()), + }); + cx.spawn(async move |this, cx| { + let Ok(Ok(Some(paths))) = picked.await else { return }; + let Some(path) = paths.into_iter().next() else { return }; + let _ = this.update(cx, |pane, cx| { + pane.backup_run_async( + async move { + let packet = std::fs::read(&path) + .map_err(|err| format!("读取备份文件失败:{err}"))?; + crate::encrypted_backup::restore(&packet, &pass)?; + Ok("已从备份恢复(字体/托盘等部分设置重启后生效)".to_owned()) + }, + cx, + ); + // 恢复覆盖了 settings/主机列表等文件:立即重载并通知宿主 + // 热应用。后台任务完成前 UI 短暂显示旧值,可接受。 + pane.reload_after_restore(cx); + }); + }) + .detach(); + } + + /// 恢复后的单一重载入口(设置/SSH 主机/远端配置)。 + fn reload_after_restore(&mut self, cx: &mut Context) { + self.runtime = RuntimeSettings::load(); + self.ssh_hosts = crate::gpui_shell::ssh_hosts::SshHostLists::load(); + self.backup_remote = crate::backup_remote::BackupRemoteConfig::load(); + let settings = crate::gpui_shell::config::Settings::load( + crate::gpui_shell::theme::effective_theme_name(cx), + ); + cx.set_global(settings); + cx.emit(SettingsPaneEvent::Changed); + cx.notify(); + } + + /// 远端配置写盘(读当前协议的非密文槽位输入)。 + fn save_remote_config(&mut self, cx: &mut Context) { + let protocol = self.backup_remote.protocol; + let secret_slot = crate::backup_remote::secret_field(protocol); + let mut input_ix = 0usize; + for slot in 0..crate::backup_remote::field_count(protocol) { + if Some(slot) == secret_slot { + continue; + } + let Some(input) = self.backup_remote_inputs.get(input_ix) else { break }; + let value = input.read(cx).value().trim().to_string(); + self.backup_remote.set_slot(slot, value); + input_ix += 1; + } + self.backup_status = Some(match self.backup_remote.save() { + Ok(()) => ("远端配置已保存".to_owned(), false), + Err(err) => (err, true), + }); + cx.notify(); + } + + /// 密文槽写入系统凭据管理器(WebDAV 密码 / S3 Secret),随后清空输入。 + fn store_remote_secret(&mut self, window: &mut Window, cx: &mut Context) { + self.save_remote_config(cx); + let secret = self.backup_secret_input.read(cx).value().to_string(); + if secret.is_empty() { + self.backup_status = Some(("凭据不能为空".to_owned(), true)); + cx.notify(); + return; + } + let result = match self.backup_remote.protocol { + crate::backup_remote::BackupProtocol::WebDav => { + crate::backup_remote::store_webdav_password( + &self.backup_remote.webdav_username, + &secret, + ) + }, + crate::backup_remote::BackupProtocol::S3 => { + crate::backup_remote::store_s3_secret(&self.backup_remote.s3_access_key, &secret) + }, + _ => Err("当前协议不需要独立凭据".to_owned()), + }; + self.backup_status = Some(match result { + Ok(()) => ("凭据已写入系统凭据管理器".to_owned(), false), + Err(err) => (err, true), + }); + self.backup_secret_input.update(cx, |input, cx| input.set_value("", window, cx)); + cx.notify(); + } + + /// 立即推送:collect + seal + 按配置协议上传(全部后台)。 + fn push_remote(&mut self, cx: &mut Context) { + if self.backup_busy { + return; + } + let Some(pass) = self.backup_passphrase(cx) else { return }; + self.save_remote_config(cx); + if self.backup_selection.is_empty() { + self.backup_status = Some(("请至少勾选一个备份类别".to_owned(), true)); + cx.notify(); + return; + } + let selection = self.backup_selection; + self.backup_run_async( + async move { + crate::backup_remote::validate()?; + let archive = crate::encrypted_backup::collect(selection)?; + let packet = crate::encrypted_backup::seal(&archive, &pass)?; + let location = crate::backup_remote::push(&packet)?; + Ok(format!("已推送到 {location}")) + }, + cx, + ); + } + + /// 恢复最新:按配置协议拉取最新备份并解密落盘(全部后台)。 + fn pull_remote(&mut self, cx: &mut Context) { + if self.backup_busy { + return; + } + let Some(pass) = self.backup_passphrase(cx) else { return }; + self.save_remote_config(cx); + self.backup_run_async( + async move { + crate::backup_remote::validate()?; + let (name, packet) = crate::backup_remote::pull_latest()?; + crate::encrypted_backup::restore(&packet, &pass)?; + Ok(format!("已从 {name} 恢复(部分设置重启后生效)")) + }, + cx, + ); + } + + /// 协议切换:字段独立保存(来回切换不丢配置),槽位输入随协议回填。 + fn select_backup_protocol( + &mut self, + protocol: crate::backup_remote::BackupProtocol, + window: &mut Window, + cx: &mut Context, + ) { + // 先把当前协议的输入落到内存配置,再切换(不写盘,写盘归保存钮)。 + let secret_slot = crate::backup_remote::secret_field(self.backup_remote.protocol); + let mut input_ix = 0usize; + for slot in 0..crate::backup_remote::field_count(self.backup_remote.protocol) { + if Some(slot) == secret_slot { + continue; + } + if let Some(input) = self.backup_remote_inputs.get(input_ix) { + let value = input.read(cx).value().trim().to_string(); + self.backup_remote.set_slot(slot, value); + } + input_ix += 1; + } + self.backup_remote.protocol = protocol; + let secret_slot = crate::backup_remote::secret_field(protocol); + let mut input_ix = 0usize; + for slot in 0..crate::backup_remote::field_count(protocol) { + if Some(slot) == secret_slot { + continue; + } + let value = self.backup_remote.slot(slot).unwrap_or_default().to_owned(); + if let Some(input) = self.backup_remote_inputs.get(input_ix) { + input.update(cx, |input, cx| input.set_value(value, window, cx)); + } + input_ix += 1; + } + cx.notify(); + } + + fn section_backup(&mut self, cx: &mut Context) -> gpui::Div { + use crate::backup_remote::BackupProtocol; + let theme = cx.theme(); + let muted = theme.muted_foreground; + let busy = self.backup_busy; + let selection = self.backup_selection; + + // 类别开关(与共享 `BackupSelection` 一一对应)。 + let categories: [(&'static str, &'static str, bool, fn(&mut Self, bool)); 9] = [ + ("bk-appearance", "外观与主题", selection.appearance, |s, v| { + s.backup_selection.appearance = v; + }), + ("bk-config", "终端配置", selection.config, |s, v| s.backup_selection.config = v), + ("bk-ssh", "SSH 主机(脱敏)", selection.ssh, |s, v| s.backup_selection.ssh = v), + ("bk-sync", "同步配置", selection.sync, |s, v| s.backup_selection.sync = v), + ("bk-assistant", "AI 助手配置", selection.assistant, |s, v| { + s.backup_selection.assistant = v; + }), + ("bk-session", "会话与工作区", selection.session, |s, v| { + s.backup_selection.session = v; + }), + ("bk-dirhist", "目录历史", selection.directory_history, |s, v| { + s.backup_selection.directory_history = v; + }), + ("bk-cmdhist", "命令历史", selection.command_history, |s, v| { + s.backup_selection.command_history = v; + }), + ("bk-fonts", "自装字体", selection.fonts, |s, v| s.backup_selection.fonts = v), + ]; + let category_rows = categories.map(|(id, label, checked, apply)| { + self.row( + label, + crate::gpui_shell::widgets::NebulaSwitch::new(id).checked(checked).on_click( + cx.listener(move |this, value: &bool, _, cx| { + apply(this, *value); + cx.notify(); + }), + ), + ) + }); + + let protocol = self.backup_remote.protocol; + let protocol_buttons = [ + (BackupProtocol::Off, "关闭"), + (BackupProtocol::Folder, "目录"), + (BackupProtocol::WebDav, "WebDAV"), + (BackupProtocol::S3, "S3"), + (BackupProtocol::Sftp, "SFTP"), + ] + .map(|(value, label)| { + let selected = value == protocol; + Button::new(SharedString::from(format!("bk-protocol-{}", value.settings_value()))) + .label(label) + .small() + .when(selected, |b| b.primary()) + .on_click(cx.listener(move |this, _, window, cx| { + this.select_backup_protocol(value, window, cx); + })) + }); + + // 非密文槽位标签(顺序 = `BackupRemoteConfig::slot` 的下标序)。 + let slot_labels: &[&'static str] = match protocol { + BackupProtocol::Off => &[], + BackupProtocol::Folder => &["目标目录"], + BackupProtocol::WebDav => &["WebDAV 地址", "用户名"], + BackupProtocol::S3 => &["Endpoint", "Region", "桶/前缀", "Access Key"], + BackupProtocol::Sftp => &["SSH 目的地 (user@host)", "远端目录"], + }; + let slot_rows = slot_labels.iter().enumerate().map(|(ix, label)| { + let input = self.backup_remote_inputs.get(ix).cloned(); + self.row(label, div().w(px(300.0)).children(input.map(|input| Input::new(&input)))) + }); + + let secret_ready = crate::backup_remote::protocol_secret_set(protocol); + let secret_label = match protocol { + BackupProtocol::WebDav => Some("WebDAV 密码"), + BackupProtocol::S3 => Some("S3 Secret Key"), + _ => None, + }; + + let mut remote_group = self + .group("远端同步", cx) + .child(div().text_xs().text_color(muted).child( + "推送 = 当前勾选类别加密打包后上传;恢复最新 = 拉取远端最新包解密落盘。\ + SFTP 复用上方 SSH 主机的认证。", + )) + .child(h_flex().gap_2().children(protocol_buttons)) + .children(slot_rows); + if let Some(label) = secret_label { + remote_group = remote_group.child(self.row( + label, + h_flex() + .gap_2() + .items_center() + .child(div().text_xs().text_color(muted).child(if secret_ready { + "已设置" + } else { + "未设置" + })) + .child(div().w(px(220.0)).child(Input::new(&self.backup_secret_input))) + .child(NebulaButton::new("bk-store-secret").label("保存凭据").on_click( + cx.listener(|this, _, window, cx| { + this.store_remote_secret(window, cx); + }), + )), + )); + } + if protocol != BackupProtocol::Off { + remote_group = remote_group.child( + h_flex() + .gap_2() + .child(NebulaButton::new("bk-save-remote").label("保存配置").on_click( + cx.listener(|this, _, _, cx| { + this.save_remote_config(cx); + }), + )) + .child( + NebulaButton::new("bk-push") + .label(if busy { "处理中…" } else { "立即推送" }) + .disabled(busy) + .on_click(cx.listener(|this, _, _, cx| { + this.push_remote(cx); + })), + ) + .child( + NebulaButton::new("bk-pull") + .label("恢复最新备份") + .disabled(busy) + .on_click(cx.listener(|this, _, _, cx| { + this.pull_remote(cx); + })), + ), + ); + } + + let local_group = self + .group("加密备份", cx) + .child( + div() + .text_xs() + .text_color(muted) + .child("端到端加密(密码不落盘);SSH 私钥永不进包,主机列表脱敏导出。"), + ) + .children(category_rows) + .child(self.row( + "备份密码", + div().w(px(300.0)).child(Input::new(&self.backup_pass_input)), + )) + .child( + h_flex() + .gap_2() + .child( + NebulaButton::new("bk-export") + .label(if busy { "处理中…" } else { "导出到文件…" }) + .disabled(busy) + .on_click(cx.listener(|this, _, _, cx| { + this.export_backup(cx); + })), + ) + .child( + NebulaButton::new("bk-restore") + .label("从文件恢复…") + .disabled(busy) + .on_click(cx.listener(|this, _, _, cx| { + this.restore_backup(cx); + })), + ), + ); + + v_flex() + .w_full() + .child(local_group) + .child(Self::group_divider(cx)) + .child(remote_group) + .when_some(self.backup_status.clone(), |page, (message, error)| { + page.child( + div() + .pt_4() + .text_color(if error { theme.danger } else { theme.success }) + .child(message), + ) + }) + } + + fn section_network(&mut self, cx: &mut Context) -> gpui::Div { + self.group("网络(SSH 出站代理)", cx) + .child(self.select_row("ssh_proxy_mode", "代理模式", cx)) + .child(self.input_row("代理 URL", "ssh_proxy_url", &self.proxy_url_input.clone(), cx)) + .child(self.input_row( + "绕过列表", + "ssh_proxy_no_proxy", + &self.proxy_bypass_input.clone(), + cx, + )) + } + + fn section_interaction(&mut self, cx: &mut Context) -> gpui::Div { + self.group("交互", cx) + .child(self.switch_row( + "copy_on_select", + "选中即复制(关 = 右键复制/粘贴)", + self.runtime.copy_on_select, + cx, + )) + .child(self.select_row("tab_reveal", "标签展开动效", cx)) + .child(self.select_row("new_tab_position", "新标签位置", cx)) + .child(self.select_row("vcs_display", "侧栏版本控制(Git/SVN)", cx)) + .child(self.switch_row( + "panel_resize", + "拖拽调节侧栏宽度", + self.runtime.panel_resize, + cx, + )) + .child(self.switch_row( + "cjk_bold_regular", + "CJK 粗体使用常规字形(提亮不加粗)", + self.runtime.cjk_bold_regular, + cx, + )) + } + + // ---- 按键映射编辑器(模型层 `display::keymap`,两壳同读同写)---- + + /// 搜索口径与旧壳一致:动作名(中/英)+ 当前生效键文本。 + fn keymap_row_haystack(&self, flat: usize) -> String { + use crate::display::keymap; + let custom = keymap::build_bindings(&self.keymap_binds); + let combo = if flat == keymap::QUICK_TERMINAL_ROW { + keymap::display_stored_combo(&self.runtime.quick_terminal_hotkey) + } else { + keymap::EDITABLE_ACTIONS + .get(flat - 1) + .and_then(|(action, ..)| keymap::effective_combo(action, &custom)) + .map(|(combo, _)| combo) + .unwrap_or_default() + }; + let (zh, en) = if flat == keymap::QUICK_TERMINAL_ROW { + ("快速终端", "Quick terminal") + } else { + keymap::EDITABLE_ACTIONS.get(flat - 1).map(|(_, zh, en)| (*zh, *en)).unwrap_or(("", "")) + }; + format!("{zh} {en} {combo}").to_lowercase() + } + + /// 过滤后的可见行(flat 下标,升序)。空查询 = 全部。 + fn keymap_visible(&self, cx: &App) -> Vec { + use crate::display::keymap; + let query = self.keymap_search_input.read(cx).value().trim().to_lowercase(); + (0..keymap::editable_row_count()) + .filter(|flat| query.is_empty() || self.keymap_row_haystack(*flat).contains(&query)) + .collect() + } + + /// 冲突检测(旧壳 `keymap_clash_info` 同义):同一 combo 多动作 → 逐行 + /// 标记 + 只报第一组的提示条。 + fn keymap_clashes(&self) -> (Vec, Option) { + use crate::display::keymap; + let total = keymap::editable_row_count(); + let custom = keymap::build_bindings(&self.keymap_binds); + let mut combos: Vec> = Vec::with_capacity(total); + for flat in 0..total { + let combo = if flat == keymap::QUICK_TERMINAL_ROW { + Some(keymap::display_stored_combo(&self.runtime.quick_terminal_hotkey)) + } else { + keymap::EDITABLE_ACTIONS + .get(flat - 1) + .and_then(|(action, ..)| keymap::effective_combo(action, &custom)) + .map(|(combo, _)| combo) + }; + combos.push(combo.filter(|combo| !combo.is_empty())); + } + let name = |flat: usize| -> String { + if flat == keymap::QUICK_TERMINAL_ROW { + "快速终端".to_owned() + } else { + keymap::EDITABLE_ACTIONS + .get(flat - 1) + .map(|(_, zh, _)| (*zh).to_owned()) + .unwrap_or_default() + } + }; + let mut rows = vec![false; total]; + let mut note = None; + for a in 0..total { + let Some(combo_a) = combos[a].clone() else { continue }; + for b in (a + 1)..total { + let Some(combo_b) = &combos[b] else { continue }; + if !combo_a.eq_ignore_ascii_case(combo_b) { + continue; + } + rows[a] = true; + rows[b] = true; + if note.is_none() { + let (a_name, b_name) = (name(a), name(b)); + note = Some(format!( + "{combo_a} 同时绑定了「{a_name}」与「{b_name}」——只有排前面的「{a_name}」会触发" + )); + } + } + } + (rows, note) + } + + /// 捕获完成:一个动作只保留一条自定义绑定,但同一 combo 可以同时归属 + /// 多个动作。冲突不再靠静默注销旧动作来“解决”,而是由 + /// `keymap_clashes` 标记双方并显示警告条,让用户自己决定改哪一行。 + fn keymap_assign(&mut self, row: usize, combo: String, cx: &mut Context) { + use crate::display::keymap; + self.keymap_capture = None; + self.keymap_capture_preview.clear(); + if row == keymap::QUICK_TERMINAL_ROW { + self.persist(&[("quick_terminal_hotkey", combo)], cx); + return; + } + let Some((action, ..)) = keymap::EDITABLE_ACTIONS.get(row - 1) else { return }; + let name = keymap::action_storage_name(action); + self.keymap_binds.retain(|(_, a)| !a.eq_ignore_ascii_case(&name)); + self.keymap_binds.push((combo, name)); + self.persist_keybinds(cx); + } + + /// 捕获态裸 Backspace:删除自定义绑定,回落内置默认。 + fn keymap_clear_custom(&mut self, row: usize, cx: &mut Context) { + use crate::display::keymap; + self.keymap_capture = None; + self.keymap_capture_preview.clear(); + if row == keymap::QUICK_TERMINAL_ROW { + self.persist( + &[("quick_terminal_hotkey", keymap::DEFAULT_QUICK_TERMINAL_HOTKEY.to_owned())], + cx, + ); + return; + } + let Some((action, ..)) = keymap::EDITABLE_ACTIONS.get(row - 1) else { return }; + let name = keymap::action_storage_name(action); + self.keymap_binds.retain(|(_, a)| !a.eq_ignore_ascii_case(&name)); + self.persist_keybinds(cx); + } + + /// keybind= 整表落盘 → 重载镜像 → 通知宿主热应用(工作区会重注入 + /// gpui 键位表)。persist 走通用路径拿重载与事件,这里补镜像。 + fn persist_keybinds(&mut self, cx: &mut Context) { + if let Err(err) = nebula_settings::persist_keybinds(&self.keymap_binds) { + eprintln!("[nebula:gpui] failed to persist keybinds: {err}"); + } + self.keymap_binds = nebula_settings::keybind_pairs(); + self.runtime = RuntimeSettings::load(); + cx.emit(SettingsPaneEvent::Changed); + cx.notify(); + } + + /// 键帽 chip:捕获行回显预览;自定义 accent 描边;冲突 danger 底; + /// 默认 ink_dim;未绑定 ink_faint(旧壳墨色分级裁定)。 + fn keymap_keycap( + &self, + text: &str, + custom: bool, + clash: bool, + capturing: bool, + cx: &Context, + ) -> impl IntoElement { + let theme = cx.theme(); + let color = if clash { + theme.danger + } else if capturing { + theme.link + } else if custom { + theme.link + } else if text.is_empty() { + crate::gpui_shell::theme::faint_ink(cx) + } else { + theme.muted_foreground + }; + div() + .min_w(px(72.0)) + .px_2() + .h(px(24.0)) + .flex() + .items_center() + .justify_center() + .rounded(px(crate::display::UI_CORNER_RADIUS_LOGICAL * 0.75)) + .text_size(px(self.font_size_px(cx) * 0.86)) + .when(clash, |chip| chip.bg(theme.danger).text_color(theme.danger_foreground)) + .when(!clash && (custom || capturing), |chip| { + chip.border_1().border_color(theme.link).text_color(color) + }) + .when(!clash && !custom && !capturing, |chip| { + chip.border_1().border_color(theme.border).text_color(color) + }) + .child(if text.is_empty() && !capturing { + SharedString::from("未绑定") + } else { + SharedString::from(text.to_owned()) + }) + } + + /// 一行「动作 + 键帽」。点击行进入捕获态(旧壳点行即捕获)。 + fn keymap_row(&self, flat: usize, clash: bool, cx: &Context) -> impl IntoElement { + use crate::display::keymap; + let custom = keymap::build_bindings(&self.keymap_binds); + let label: SharedString = if flat == keymap::QUICK_TERMINAL_ROW { + "快速终端".into() + } else { + keymap::EDITABLE_ACTIONS + .get(flat - 1) + .map(|(_, zh, _)| (*zh).to_owned()) + .unwrap_or_default() + .into() + }; + let (text, is_custom) = if flat == keymap::QUICK_TERMINAL_ROW { + ( + keymap::display_stored_combo(&self.runtime.quick_terminal_hotkey), + self.runtime.quick_terminal_hotkey != keymap::DEFAULT_QUICK_TERMINAL_HOTKEY, + ) + } else { + keymap::EDITABLE_ACTIONS + .get(flat - 1) + .and_then(|(action, ..)| keymap::effective_combo(action, &custom)) + .map(|(combo, custom)| (combo, custom)) + .unwrap_or_else(|| (String::new(), false)) + }; + let capturing = self.keymap_capture == Some(flat); + let cap_text = if capturing { + if self.keymap_capture_preview.is_empty() { + "按新按键…".to_owned() + } else { + format!("{}…", self.keymap_capture_preview) + } + } else { + text.clone() + }; + h_flex() + .id(("keymap-row", flat)) + .w_full() + .h(px(SETTINGS_ROW_HEIGHT)) + .flex_shrink_0() + .items_center() + .pr_4() + .rounded(px(crate::display::UI_CORNER_RADIUS_LOGICAL)) + .cursor_pointer() + .hover(|style| style.bg(crate::gpui_shell::theme::settings_hover_bg(cx, false))) + // mouse_down 而非 click:容器(section 根)同捕一个 mouse_down + // 做「点击任何位置先撤销」,这里 stop_propagation 抢先处理 + // 「点别的行 = 捕获转移、点同一行 = 取消」(旧壳 input/chrome.rs + // 的 SettingsHit::KeymapRow 分支同合同)。 + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + if this.keymap_capture == Some(flat) { + this.keymap_capture = None; + this.keymap_capture_preview.clear(); + } else { + this.keymap_capture = Some(flat); + this.keymap_capture_preview.clear(); + // 焦点收到分区根:键盘事件沿根路径冒泡给捕获处理器, + // 搜索框不再分走按键。 + window.focus(&this.focus_handle); + } + cx.notify(); + }), + ) + .child(div().flex_1().min_w_0().pl_4().child(label)) + .child(self.keymap_keycap(&cap_text, is_custom, clash, capturing, cx)) + } + + fn section_keymap(&mut self, cx: &mut Context) -> gpui::Div { + use crate::display::keymap; + + let visible = self.keymap_visible(cx); + let (clash_rows, clash_note) = self.keymap_clashes(); + + // 分组渲染(旧壳无框分组裁定):组内可见行为空的组整组隐藏;组 + // 标题 0.86× 小字压在行块上方。 + let mut groups_block = v_flex().w_full().gap_1(); + let mut start = 0usize; + for (zh, _en, count) in keymap::GROUPS { + let end = start + count; + let rows: Vec<_> = visible + .iter() + .filter(|flat| (start..end).contains(*flat)) + .map(|flat| self.keymap_row(*flat, clash_rows[*flat], cx)) + .collect(); + if !rows.is_empty() { + groups_block = groups_block.child( + div() + .pt_3() + .pb_1() + .text_size(px(self.font_size_px(cx) * 0.86)) + .text_color(cx.theme().muted_foreground) + .child(*zh), + ); + for row in rows { + groups_block = groups_block.child(row); + } + } + start = end; + } + + // 只读行:数字系/AI 贴入键(表驱动,不可在图形页编辑)。随搜索过滤。 + let query = self.keymap_search_input.read(cx).value().trim().to_lowercase(); + let readonly: Vec<&(&str, &str, &str)> = keymap::READONLY_ROWS + .iter() + .filter(|(zh, en, combo)| { + query.is_empty() || format!("{zh} {en} {combo}").to_lowercase().contains(&query) + }) + .collect(); + if !readonly.is_empty() { + groups_block = groups_block.child( + div() + .pt_3() + .pb_1() + .text_size(px(self.font_size_px(cx) * 0.86)) + .text_color(cx.theme().muted_foreground) + .child("只读"), + ); + for (zh, _en, combo) in readonly { + groups_block = groups_block.child( + h_flex() + .w_full() + .h(px(SETTINGS_ROW_HEIGHT)) + .flex_shrink_0() + .items_center() + .pr_4() + .child( + div() + .flex_1() + .min_w_0() + .pl_4() + .text_color(crate::gpui_shell::theme::faint_ink(cx)) + .child(*zh), + ) + .child( + div() + .min_w(px(72.0)) + .px_2() + .h(px(24.0)) + .flex() + .items_center() + .justify_center() + .rounded(px(crate::display::UI_CORNER_RADIUS_LOGICAL * 0.75)) + .border_1() + .border_color(cx.theme().border) + .text_size(px(self.font_size_px(cx) * 0.86)) + .text_color(crate::gpui_shell::theme::faint_ink(cx)) + .child(*combo), + ), + ); + } + } + + self.group("按键映射", cx) + // 捕获态的「点击任何位置先撤销」(旧壳 input/chrome.rs 的统一撤 + // 销合同):行的 mouse_down 会 stop_propagation 自行处理转移/ + // 取消,搜索框这里显式撤销(旧壳点搜索框 = blur 捕获),其余 + // 任何落点冒泡到这里 = 纯取消。 + .when(self.keymap_capture.is_some(), |section| { + section.on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + if this.keymap_capture.take().is_some() { + this.keymap_capture_preview.clear(); + cx.notify(); + } + }), + ) + }) + // 搜索框:宽度同其它控件右列(220),旧壳搜索行几何的对应物。 + .child( + h_flex() + .w_full() + .h(px(SETTINGS_ROW_HEIGHT)) + .flex_shrink_0() + .items_center() + .pr_4() + .child(div().flex_1().min_w_0().pl_4().child("搜索")) + .child( + div().w(px(220.0)).child(Input::new(&self.keymap_search_input).small()), + ), + ) + // 冲突是允许存在的可见状态,用组件库的 Warning Alert 呈现; + // 不再用自绘 danger 色块,也不静默删掉另一个动作。 + .when_some(clash_note, |section, note| { + section.child(Alert::warning("keymap-clash-warning", note).small().mt_2()) + }) + .child(groups_block) + .child( + div() + .pt_4() + .text_size(px(self.font_size_px(cx) * 0.86)) + .text_color(cx.theme().muted_foreground) + .child("点击行改键 · Backspace 恢复默认 · Esc 取消"), + ) + } + + fn section_advanced(&mut self, cx: &mut Context) -> gpui::Div { + self.group("高级", cx) + .child(self.switch_row( + "keep_session", + "关窗后保留后台会话", + self.runtime.keep_session, + cx, + )) + .child(self.switch_row( + "restore_session", + "启动时恢复上次标签", + self.runtime.restore_session, + cx, + )) + .child(self.switch_row( + "resume_ai", + "恢复会话时自动接续 AI 对话", + self.runtime.resume_ai, + cx, + )) + .child(self.switch_row("tray", "常驻系统托盘图标(旧壳生效)", self.runtime.tray, cx)) + } + + fn section_content(&mut self, cx: &mut Context) -> gpui::AnyElement { + use gpui::IntoElement as _; + match self.active_section { + 0 => self.section_home(cx), + 1 => self.section_appearance(cx), + 2 => self.section_profiles(cx), + 3 => self.section_providers(cx), + 4 => self.section_ssh(cx), + 5 => self.section_network(cx), + 6 => self.section_interaction(cx), + 7 => self.section_keymap(cx), + 8 => self.section_advanced(cx), + _ => self.section_backup(cx), + } + .into_any_element() + } + + fn render_nav(&self, cx: &mut Context) -> gpui::AnyElement { + use gpui::IntoElement as _; + let theme = cx.theme(); + let muted = theme.muted_foreground; + let active_bg = crate::gpui_shell::theme::settings_hover_bg(cx, true); + let active_fg = theme.sidebar_accent_foreground; + let hover_bg = crate::gpui_shell::theme::settings_hover_bg(cx, false); + let row_h = px(32.0); + // 设置导航、内容和 workspace 左侧 tab 共享这一个主文字字号。 + let main_text_px = self.font_size_px(cx); + + // 旧壳顶部是品牌标题而不是搜索框;导航入口保持连续排列,避免 + // 额外分组标题把旧版的行距和可视顺序撑开。 + let mut nav = v_flex() + .w(px(SETTINGS_NAV_WIDTH)) + .h_full() + .flex_shrink_0() + .px_2() + .gap(px(2.0)) + .child( + div() + .h(px(72.0)) + .w_full() + .px_3() + .flex() + .items_center() + .text_size(px(main_text_px * 1.45)) + .text_color(theme.foreground) + .child("Nebula 设置"), + ); + for ix in NAV_ORDER { + let active = ix == self.active_section; + nav = nav.child( + div() + .id(("settings-nav", ix)) + .px_2() + .h(row_h) + .flex() + .items_center() + .gap_2() + .rounded_md() + .cursor_pointer() + .text_size(px(main_text_px)) + .font_weight(gpui::FontWeight::NORMAL) + .when(active, |item| item.bg(active_bg).text_color(active_fg)) + .when(!active, |item| item.text_color(muted).hover(|s| s.bg(hover_bg))) + .on_click(cx.listener(move |this, _, _, cx| { + this.active_section = ix; + cx.notify(); + })) + .child(Icon::new(section_icon(ix)).small().text_color(if active { + active_fg + } else { + muted + })) + .child(SECTIONS[ix]), + ); + } + nav.into_any_element() + } +} + +impl EventEmitter for SettingsPane {} + +impl Focusable for SettingsPane { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for SettingsPane { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let nav = self.render_nav(cx); + let content = self.section_content(cx); + // 旧壳设置页全部文字走终端 mono 字体(draw_chrome_text 的字形缓存), + // sans 只属于组件库默认;根上挂一次,nav/表单/下拉弹层全部继承。 + let family: SharedString = self.current_font_chain(cx).into(); + let base_px = self.font_size_px(cx); + let font_picker_open = self.font_picker_open; + let ssh_editor_modal = self.ssh_editor_modal(cx); + let show_reset = !matches!(self.active_section, 0 | 4); + let reset_button = NebulaButton::new("settings-reset") + .label("恢复默认设置") + .on_click(cx.listener(|this, _, window, cx| this.reset_appearance(window, cx))); + + div() + .size_full() + .track_focus(&self.focus_handle) + // 旧壳 `input/chrome.rs` 的合同是页面任何左键先撤销捕获。 + // 行自身会 stop_propagation 并完成「同行取消 / 他行转移」;其它 + // 控件的 mouse_down 仍先取得自己的焦点,这里只清状态,不抢焦点, + // 因而点进搜索框后键盘完整归 InputState。 + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + if this.keymap_capture.take().is_some() { + this.keymap_capture_preview.clear(); + cx.notify(); + } + }), + ) + // 按键捕获态的键盘独占:焦点在本根 div(点行时收进来),键盘 + // 事件沿焦点路径冒泡到这里;捕获未激活时不挂处理器,输入框、 + // 下拉等组件照常吃键。修饰键实时回显同址(ModifiersChanged + // 不是 KeyDown,走独立通道)。 + .when_some(self.keymap_capture, |root, _| { + root.on_key_down(cx.listener( + |this, event: &KeyDownEvent, _window, cx| { + let Some(row) = this.keymap_capture else { return }; + cx.stop_propagation(); + match crate::display::keymap::capture_gpui(&event.keystroke) { + crate::display::keymap::CaptureOutcome::Cancel => { + this.keymap_capture = None; + this.keymap_capture_preview.clear(); + cx.notify(); + }, + crate::display::keymap::CaptureOutcome::ClearCustom => { + this.keymap_clear_custom(row, cx); + }, + crate::display::keymap::CaptureOutcome::Bind(combo) => { + this.keymap_assign(row, combo, cx); + }, + crate::display::keymap::CaptureOutcome::Pending => {}, + } + }, + )) + .on_modifiers_changed(cx.listener( + |this, event: &ModifiersChangedEvent, _window, cx| { + if this.keymap_capture.is_none() { + return; + } + let prefix = + crate::display::keymap::gpui_mods_prefix(&event.modifiers); + if this.keymap_capture_preview != prefix { + this.keymap_capture_preview = prefix; + cx.notify(); + } + }, + )) + }) + // 旧壳设置页是独立的不透明 panel;终端壁纸只属于终端内容, + // 不应穿透设置文字和控件。 + // + // 这层不透明底必须自带卡圆角:外层终端卡虽然 `overflow_hidden`, + // 但 GPUI 的裁剪是矩形 content_mask、不跟圆角,方角底会直接盖掉 + // 卡的四个圆角——这就是设置页看着「四角是直角」的原因。 + .rounded(crate::gpui_shell::theme::card_radius()) + .bg(crate::gpui_shell::theme::settings_panel_bg(cx)) + .text_color(cx.theme().foreground) + .font_family(family) + // 主文字的字号/字重从设置根向两栏继承;局部仅允许说明文字、 + // 徽标等语义上的次级信息覆盖为更小字号。 + .text_size(px(base_px)) + .font_weight(gpui::FontWeight::NORMAL) + .flex() + .flex_row() + .child(nav) + .child( + v_flex() + .flex_1() + .min_w_0() + .h_full() + // 旧壳标题栏固定 72px;正文单独滚动,滚动设置时仍能知道 + // 自己在哪个分区,也不会让标题与首组的距离随内容变化。 + .child( + div() + .h(px(SETTINGS_HEADER_HEIGHT)) + .flex_shrink_0() + .px_6() + .flex() + .items_center() + .text_size(px(base_px)) + .font_weight(gpui::FontWeight::NORMAL) + .child(div().flex_1().child(SECTIONS[self.active_section])) + .when(show_reset, |header| header.child(reset_button)), + ) + .child( + v_flex() + .flex_1() + .min_h_0() + .px_6() + .pt_8() + .pb_8() + // 正文继承设置根的统一主字号;只有说明、徽标、 + // 快捷键提示等次级信息在各自元素上显式缩小。 + .overflow_y_scrollbar() + // 这层 v_flex 不能省。`overflow_y_scrollbar` 不是 + // 就地加滚动条:它把本容器的样式 clone 到自己新建 + // 的外层 div,再把这层样式清空、只补 flex_1(见 + // gpui-component scroll/scrollable.rs)。于是真正 + // 承载正文的那层退回 gpui 默认的 Display::Block, + // 各分组的 `w_full` 不再撑满,而是各自按内容取宽: + // 2026-08-17 A/B 实测,去掉这层后预览组右缘停在 + // 1356 而主题组到 1762,一组一个宽度,控件既不贴左 + // 也不贴右。补一层竖向 flex 后分组走交叉轴 stretch + // 取宽,症状消失。 + .child( + v_flex() + .w_full() + .child(content) + .child( + div() + .pt_8() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child( + "写入 nebula_settings.txt,与旧壳共享同一份设置;两边可交替修改。", + ), + ), + ), + ), + ) + .when_some(ssh_editor_modal, |root, modal| root.child(modal)) + .when(font_picker_open, |root| { + root + // 搜索框是当前焦点时 Escape 仍沿元素树冒泡到设置根; + // 这里只接管弹层生命周期,不干扰其它设置输入。 + .on_key_down(cx.listener( + |this, event: &KeyDownEvent, window, cx| { + if event.keystroke.key.eq_ignore_ascii_case("escape") { + cx.stop_propagation(); + this.close_font_picker(window, true, cx); + } + }, + )) + // 旧壳的“第一击只关闭浮层”合同:透明层覆盖设置页其余 + // 控件并吃掉 mouse-down;字体面板自身通过 deferred + // priority=2 绘制在该层之上,内部搜索/滚动/点击照常工作。 + .child( + div() + .id("font-picker-dismiss-layer") + .absolute() + .top_0() + .right_0() + .bottom_0() + .left_0() + .cursor_default() + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, window, cx| { + cx.stop_propagation(); + this.close_font_picker(window, true, cx); + }), + ), + ) + }) + } +} diff --git a/nebula_app/src/gpui_shell/ssh_settings.rs b/nebula_app/src/gpui_shell/ssh_settings.rs new file mode 100644 index 0000000..ba0a922 --- /dev/null +++ b/nebula_app/src/gpui_shell/ssh_settings.rs @@ -0,0 +1,1505 @@ +//! 设置页的 SSH 区(`SettingsPane` 的 SSH 专属 impl 拆分文件)。 +//! +//! 字段/事件仍住在 [`SettingsPane`](super::settings_pane::SettingsPane); +//! 这里承载 SSH 主机列表、添加/编辑弹窗、连接测试与删除撤销的全部行为。 +//! 数据合同与旧壳共享:列表三键走 `ssh_hosts::SshHostLists`(内部是 +//! `display::merge_ssh_hosts` 单一权威),认证配置走 `ssh_profiles`,密码 +//! 走 Windows 凭据管理器,测试连接走 `ssh_session::run_test`。 +//! +//! 与旧壳的行为对齐要点: +//! - 文案合同(占位符/状态行)跟随旧壳 `ssh_editor_render`; +//! - 删除主机先移出列表并开一个 8 秒撤销窗口,窗口结束才清理 Profile 与 +//! 凭据(旧壳 Undo 条同义); +//! - 端口输入只接受至多 5 位数字(旧壳键入即过滤)。 + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + Context, Entity, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, + ParentElement as _, SharedString, StatefulInteractiveElement as _, Styled as _, Window, + anchored, deferred, div, px, +}; + +use crate::gpui_shell::prelude::*; +use crate::gpui_shell::settings_pane::{SettingsPane, SettingsPaneEvent}; +use crate::gpui_shell::widgets::NebulaButton; + +/// 删除撤销窗口时长,旧壳 Undo 条同值。 +const SSH_DELETE_UNDO_SECS: u64 = 8; + +/// 设置页 SSH 添加/编辑面板的非文本草稿。文字实体常驻在 `SettingsPane`, +/// 这样输入事件可以统一使测试结果失效;草稿只保存认证、密钥和编辑身份。 +#[derive(Clone)] +pub(super) struct SshEditorState { + /// 每次打开递增。文件选择器等异步回调只可写回发起它的编辑会话。 + pub(super) id: u64, + pub(super) original_destination: Option, + pub(super) auth: crate::ssh_profiles::SshAuthMode, + pub(super) icon: Option, + pub(super) private_keys: Vec, + pub(super) save_password: bool, + pub(super) show_password: bool, + pub(super) revision: u64, + pub(super) test_request_id: Option, + pub(super) test_status: Option<(String, bool)>, +} + +impl SshEditorState { + pub(super) fn new(id: u64, original_destination: Option) -> Self { + Self { + id, + original_destination, + // 与旧壳新增主机一致:第一次通常有密码,默认密码模式避免让 + // 新用户先因 Auto 没有可用私钥而得到一次无意义的认证失败。 + auth: crate::ssh_profiles::SshAuthMode::Password, + icon: None, + private_keys: Vec::new(), + save_password: true, + show_password: false, + revision: 0, + test_request_id: None, + test_status: None, + } + } + + pub(super) fn testing(&self) -> bool { + self.test_request_id.is_some() + } +} + +/// 一次未决的删除:列表已改,Profile/凭据的清理延迟到撤销窗口结束。 +/// 快照持有删除前的三键列表,撤销即整体恢复。 +pub(super) struct SshDeleteUndo { + pub(super) host: String, + pub(super) lists_before: crate::gpui_shell::ssh_hosts::SshHostLists, + pub(super) from_config: bool, + pub(super) seq: u64, +} + +impl SettingsPane { + /// SSH 非破坏性列表操作的统一收尾:写盘、报状态、清确认态。 + /// Profile/凭据的最终删除走 [`Self::delete_ssh_host`],不能混进置顶/恢复。 + pub(super) fn ssh_apply( + &mut self, + mutate: impl FnOnce(&mut crate::gpui_shell::ssh_hosts::SshHostLists), + status: &str, + cx: &mut Context, + ) { + // 列表将被改写,未决删除的快照会过期:先提交它。 + self.commit_pending_ssh_delete(); + mutate(&mut self.ssh_hosts); + match self.ssh_hosts.persist() { + Ok(()) => self.ssh_status = Some((status.to_owned(), false)), + Err(err) => self.ssh_status = Some((format!("写入设置失败: {err}"), true)), + } + self.ssh_delete_confirm = None; + cx.notify(); + } + + /// 二次确认后的删除:立刻移出列表并写盘,但 Profile 与凭据留到 8 秒 + /// 撤销窗口结束(旧壳 Undo 合同——期内撤销可完整恢复)。 + pub(super) fn delete_ssh_host(&mut self, host: &str, cx: &mut Context) { + // 一次只挂一个撤销窗口:新删除先提交上一个。 + self.commit_pending_ssh_delete(); + + let lists_before = self.ssh_hosts.clone(); + let from_config = self.ssh_hosts.is_from_config(host); + let mut hosts = self.ssh_hosts.clone(); + hosts.remove(host); + if let Err(error) = hosts.persist() { + self.ssh_status = Some((format!("删除主机失败: {error}"), true)); + self.ssh_delete_confirm = None; + cx.notify(); + return; + } + self.ssh_hosts = hosts; + self.ssh_undo_seq = self.ssh_undo_seq.wrapping_add(1).max(1); + let seq = self.ssh_undo_seq; + self.ssh_delete_undo = + Some(SshDeleteUndo { host: host.to_owned(), lists_before, from_config, seq }); + self.ssh_delete_confirm = None; + self.ssh_status = None; + + cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(std::time::Duration::from_secs(SSH_DELETE_UNDO_SECS)) + .await; + let _ = this.update(cx, |pane, cx| { + if pane.ssh_delete_undo.as_ref().is_some_and(|undo| undo.seq == seq) { + pane.commit_pending_ssh_delete(); + cx.notify(); + } + }); + }) + .detach(); + cx.notify(); + } + + /// 撤销窗口结束(或被新操作顶替)时的最终提交:清 Profile 与凭据。 + /// 与旧壳撤销期结束后的提交同义。 + pub(super) fn commit_pending_ssh_delete(&mut self) { + let Some(undo) = self.ssh_delete_undo.take() else { return }; + let host = undo.host; + + let mut cleanup_errors = Vec::new(); + let profile_path = crate::display::nebula_data_dir().join("ssh_profiles.json"); + match crate::ssh_profiles::SshProfiles::load(&profile_path) { + Ok(mut profiles) => { + profiles.remove(&host); + if let Err(error) = profiles.save(&profile_path) { + cleanup_errors.push(format!("Profile: {error}")); + } + }, + Err(error) => cleanup_errors.push(format!("Profile: {error}")), + } + #[cfg(windows)] + if let Err(error) = crate::ssh_credentials::forget_password(&host) { + cleanup_errors.push(format!("凭据: {error}")); + } + + self.ssh_status = if cleanup_errors.is_empty() { + Some(( + if undo.from_config { + "已隐藏 config 别名,并清理 Nebula Profile 与凭据".to_owned() + } else { + "已删除主机、Profile 与凭据".to_owned() + }, + false, + )) + } else { + Some((format!("主机已从列表移除,但部分清理失败: {}", cleanup_errors.join(";")), true)) + }; + } + + /// 撤销未决删除:恢复删除前的三键快照(Profile/凭据从未被动过)。 + pub(super) fn undo_ssh_delete(&mut self, cx: &mut Context) { + let Some(undo) = self.ssh_delete_undo.take() else { return }; + if let Err(error) = undo.lists_before.persist() { + self.ssh_status = Some((format!("撤销失败: {error}"), true)); + cx.notify(); + return; + } + self.ssh_hosts = undo.lists_before; + self.ssh_status = Some((format!("已恢复 {}", undo.host), false)); + cx.notify(); + } + + /// 一旦草稿字段变动,之前那次测试就不再能证明当前配置。请求本身不取消 + /// (网络任务应当自行收尾),但它返回时会按 revision 丢弃过期结果。 + pub(super) fn touch_ssh_editor(&mut self, cx: &mut Context) { + if let Some(editor) = self.ssh_editor.as_mut() { + editor.revision = editor.revision.wrapping_add(1); + editor.test_request_id = None; + editor.test_status = None; + self.ssh_status = None; + cx.notify(); + } + } + + pub(super) fn set_ssh_editor_masking(&mut self, window: &mut Window, cx: &mut Context) { + let masked = self.ssh_editor.as_ref().is_none_or(|editor| !editor.show_password); + self.ssh_password_input.update(cx, |input, cx| input.set_masked(masked, window, cx)); + } + + pub(super) fn open_ssh_editor( + &mut self, + destination: Option, + window: &mut Window, + cx: &mut Context, + ) { + let profile_path = crate::display::nebula_data_dir().join("ssh_profiles.json"); + let profiles = + crate::ssh_profiles::SshProfiles::load(&profile_path).unwrap_or_else(|err| { + log::warn!("加载 SSH Profile 失败,使用默认草稿: {err}"); + crate::ssh_profiles::SshProfiles::default() + }); + let profile = destination.as_deref().map(|host| profiles.for_destination(host)); + let (address, port) = + destination.as_deref().map(crate::display::split_destination_port).unwrap_or_default(); + self.ssh_editor_seq = self.ssh_editor_seq.wrapping_add(1).max(1); + let mut editor = SshEditorState::new(self.ssh_editor_seq, destination); + if let Some(profile) = profile.as_ref() { + editor.auth = profile.auth; + editor.icon = profile.icon.clone(); + editor.private_keys = profile.private_keys.clone(); + } + let label = profile.and_then(|profile| profile.label).unwrap_or_default(); + self.ssh_destination_input.update(cx, |input, cx| input.set_value(address, window, cx)); + self.ssh_port_input.update(cx, |input, cx| input.set_value(port, window, cx)); + self.ssh_label_input.update(cx, |input, cx| input.set_value(label, window, cx)); + // 存储的秘密绝不回填文本框;空值意味着编辑已有主机时保留原凭据。 + self.ssh_password_input.update(cx, |input, cx| input.set_value("", window, cx)); + // 图标选择器每次开编辑器都从收起、无搜索词开始:上一台主机的搜索词 + // 留在框里,下一台打开时列表看着像被莫名筛过。 + self.ssh_icon_picker_open = false; + self.ssh_icon_trigger_bounds = None; + self.ssh_icon_filter_input.update(cx, |input, cx| input.set_value("", window, cx)); + // 弹层只能有一个;字体目录若还开着会用它的页面级拦截层盖住 + // SSH 编辑器,因此先把它收起。 + self.font_picker_open = false; + self.ssh_editor = Some(editor); + self.ssh_status = None; + self.set_ssh_editor_masking(window, cx); + self.ssh_destination_input.update(cx, |input, cx| input.focus(window, cx)); + cx.notify(); + } + + pub(super) fn close_ssh_editor(&mut self, window: &mut Window, cx: &mut Context) { + self.ssh_editor = None; + self.ssh_password_input.update(cx, |input, cx| input.set_value("", window, cx)); + window.focus(&self.focus_handle); + cx.notify(); + } + + pub(super) fn ssh_destination_from_draft(&self, cx: &gpui::App) -> Result { + let port = self.ssh_port_input.read(cx).value().trim().to_string(); + if !port.is_empty() && !port.parse::().is_ok_and(|value| value > 0) { + // en-dash 区间写法与旧壳一字不差。 + return Err("端口需要是 1–65535 之间的数字".to_owned()); + } + let address = self.ssh_destination_input.read(cx).value().trim().to_string(); + let destination = crate::display::join_destination_port(&address, &port); + if destination.is_empty() { + return Err("请输入 SSH 地址,例如 user@example.com".to_owned()); + } + if destination + .chars() + .any(|ch| ch.is_whitespace() || ch.is_control() || ";&|<>\"'`".contains(ch)) + { + return Err("地址不能包含空白、控制字符或 shell 分隔符".to_owned()); + } + Ok(destination) + } + + pub(super) fn add_ssh_private_key(&mut self, cx: &mut Context) { + let Some(editor_id) = self.ssh_editor.as_ref().map(|editor| editor.id) else { + return; + }; + let receiver = cx.prompt_for_paths(gpui::PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some("选择 SSH 私钥".into()), + }); + cx.spawn(async move |this, cx| { + let Ok(Ok(Some(paths))) = receiver.await else { return }; + let Some(path) = paths.into_iter().next() else { return }; + let _ = this.update(cx, |pane, cx| { + // 用户可能在原生文件选择器打开期间关闭表单并编辑另一台 + // 主机;旧选择结果绝不能落进后来打开的编辑会话。 + if pane.ssh_editor.as_ref().map(|editor| editor.id) != Some(editor_id) { + return; + } + let result = crate::display::file_dialog::validate_private_key_path(&path); + match result { + Ok(path) => { + if let Some(editor) = pane.ssh_editor.as_mut() { + if crate::display::push_private_key(&mut editor.private_keys, path) { + editor.revision = editor.revision.wrapping_add(1); + editor.test_request_id = None; + editor.test_status = None; + pane.ssh_status = None; + } + } + }, + Err(message) => pane.ssh_status = Some((message, true)), + } + cx.notify(); + }); + }) + .detach(); + } + + pub(super) fn test_ssh_editor(&mut self, cx: &mut Context) { + let destination = match self.ssh_destination_from_draft(cx) { + Ok(destination) => destination, + Err(message) => { + self.ssh_status = Some((message, true)); + cx.notify(); + return; + }, + }; + let Some(editor) = self.ssh_editor.as_mut() else { return }; + if editor.testing() { + return; + } + self.ssh_test_seq = self.ssh_test_seq.wrapping_add(1).max(1); + let request_id = self.ssh_test_seq; + let revision = editor.revision; + let request = crate::ssh_session::SshTestRequest { + request_id, + destination: destination.clone(), + auth: editor.auth, + private_keys: editor.private_keys.clone(), + password: crate::display::auth_sections(editor.auth) + .0 + .then(|| self.ssh_password_input.read(cx).value().to_string()) + .filter(|password| !password.is_empty()), + }; + let receiver = match crate::ssh_session::start_test(request) { + Ok(receiver) => receiver, + Err(error) => { + self.ssh_status = Some((format!("无法启动连接测试: {error}"), true)); + cx.notify(); + return; + }, + }; + editor.test_request_id = Some(request_id); + // 旧壳测试态文案。 + editor.test_status = Some(("正在连接…".to_owned(), false)); + self.ssh_status = None; + cx.spawn(async move |this, cx| { + let result = receiver.await; + let _ = this.update(cx, |pane, cx| { + let Some(editor) = pane.ssh_editor.as_mut() else { return }; + if editor.revision != revision || editor.test_request_id != Some(request_id) { + return; + } + editor.test_request_id = None; + editor.test_status = Some(match result { + Ok(result) if result.ok => { + (format!("连接成功 · {} ms", result.elapsed_ms), false) + }, + Ok(result) => (result.message, true), + Err(_) => ("连接测试任务意外结束,请重试".to_owned(), true), + }); + cx.notify(); + }); + }) + .detach(); + cx.notify(); + } + + pub(super) fn save_ssh_editor(&mut self, window: &mut Window, cx: &mut Context) { + let destination = match self.ssh_destination_from_draft(cx) { + Ok(destination) => destination, + Err(message) => { + self.ssh_status = Some((message, true)); + cx.notify(); + return; + }, + }; + let Some(mut editor) = self.ssh_editor.take() else { return }; + let original = editor.original_destination.clone(); + let profile_path = crate::display::nebula_data_dir().join("ssh_profiles.json"); + let mut profiles = match crate::ssh_profiles::SshProfiles::load(&profile_path) { + Ok(profiles) => profiles, + Err(error) => { + self.ssh_status = Some((format!("加载 SSH Profile 失败: {error}"), true)); + self.ssh_editor = Some(editor); + cx.notify(); + return; + }, + }; + // 保存会改列表:未决删除的快照过期,先提交。 + self.commit_pending_ssh_delete(); + // 先在副本里计算新列表,直到 Profile 与 settings 两份数据都写成功 + // 才替换页面状态;Profile 写盘失败时背景列表不能先显示新地址。 + let mut hosts = self.ssh_hosts.clone(); + if let Some(original) = original.as_deref().filter(|old| *old != destination) { + profiles.rename(original, &destination); + hosts.remove(original); + } + let label = match self.ssh_label_input.read(cx).value().trim() { + "" => profiles.next_default_label("主机"), + value => value.to_owned(), + }; + profiles.upsert(crate::ssh_profiles::SshProfileAuth { + destination: destination.clone(), + auth: editor.auth, + private_keys: editor.private_keys.clone(), + label: Some(label), + icon: editor.icon.clone(), + }); + if let Err(error) = profiles.save(&profile_path) { + self.ssh_status = Some((format!("保存 SSH Profile 失败: {error}"), true)); + self.ssh_editor = Some(editor); + cx.notify(); + return; + } + hosts.remember(&destination); + if let Err(error) = hosts.persist() { + self.ssh_status = Some((format!("保存主机列表失败: {error}"), true)); + self.ssh_editor = Some(editor); + cx.notify(); + return; + } + self.ssh_hosts = hosts; + let password = self.ssh_password_input.read(cx).value().to_string(); + if crate::display::auth_sections(editor.auth).0 + && editor.save_password + && !password.is_empty() + { + #[cfg(windows)] + if let Err(error) = + crate::ssh_credentials::store_password(&destination, password.as_bytes()) + { + self.ssh_status = + Some((format!("Profile 已保存,但密码写入凭据管理器失败: {error}"), true)); + self.ssh_editor = Some(editor); + cx.notify(); + return; + } + } + // Credential Manager 以 destination 为键。重命名后旧键既不会被新 + // 连接使用,也不应无限留下;与旧壳一致,在配置和列表都成功写入后 + // 才删除它,避免前面的落盘失败反而丢掉仍可用的凭据。 + let credential_cleanup_error = + original.as_deref().filter(|old| *old != destination).and_then(|old| { + #[cfg(windows)] + { + crate::ssh_credentials::forget_password(old).err() + } + #[cfg(not(windows))] + { + let _ = old; + None + } + }); + // 只在落盘流程结束后清掉明文;编辑已有主机且密码框为空时不触碰 + // 当前 destination 的凭据。 + self.ssh_password_input.update(cx, |input, cx| input.set_value("", window, cx)); + editor.private_keys.clear(); + self.ssh_editor = None; + self.ssh_status = credential_cleanup_error.map_or_else( + || Some((format!("已保存 {destination}"), false)), + |error| Some((format!("已保存 {destination},但旧地址凭据清理失败: {error}"), true)), + ); + window.focus(&self.focus_handle); + cx.notify(); + } + + // ---- UI:添加/编辑弹窗 ---- + + pub(super) fn ssh_editor_modal(&mut self, cx: &mut Context) -> Option { + let editor = self.ssh_editor.as_ref()?.clone(); + // 头像与它的弹层先建好:两者都要 `&mut Context`(listener / 实体 + // 弱引用),而下面的 `cx.theme()` 会把 cx 借成不可变直到函数结束。 + let icon = crate::display::ui::os_icons::resolve(editor.icon.as_deref()); + let avatar = self.ssh_avatar(icon, cx); + let icon_popup = self.ssh_icon_popup(cx); + let theme = cx.theme(); + let muted = theme.muted_foreground; + let danger = theme.danger; + let (shows_password, shows_keys) = crate::display::auth_sections(editor.auth); + let title = if editor.original_destination.is_some() { + "编辑 SSH 主机" + } else { + "添加 SSH 主机" + }; + let status = editor.test_status.clone().or_else(|| self.ssh_status.clone()); + let destination_preview: SharedString = { + let value = self.ssh_destination_input.read(cx).value(); + let value = value.trim(); + if value.is_empty() { "未填写地址".into() } else { value.to_string().into() } + }; + let mode_button = |id: &'static str, + label: &'static str, + mode: crate::ssh_profiles::SshAuthMode, + selected: bool| { + Button::new(id).label(label).ghost().small().flex_1().selected(selected).on_click( + cx.listener(move |this, _, _, cx| { + if let Some(editor) = this.ssh_editor.as_mut() { + editor.auth = mode; + editor.revision = editor.revision.wrapping_add(1); + editor.test_request_id = None; + editor.test_status = None; + } + this.ssh_status = None; + cx.notify(); + }), + ) + }; + let key_rows = editor.private_keys.iter().enumerate().map(|(index, path)| { + let shown: SharedString = path.to_string_lossy().into_owned().into(); + h_flex() + .id(SharedString::from(format!("ssh-key-{index}"))) + .w_full() + .h(px(30.0)) + .px_2() + .gap_2() + .items_center() + .rounded_md() + .bg(theme.input) + .child(div().flex_1().min_w_0().text_xs().truncate().child(shown)) + .child( + Button::new(SharedString::from(format!("ssh-key-remove-{index}"))) + .icon(IconName::Close) + .ghost() + .xsmall() + .tooltip("移除私钥") + .on_click(cx.listener(move |this, _, _, cx| { + if let Some(editor) = this.ssh_editor.as_mut() { + if index < editor.private_keys.len() { + editor.private_keys.remove(index); + editor.revision = editor.revision.wrapping_add(1); + editor.test_request_id = None; + editor.test_status = None; + } + } + this.ssh_status = None; + cx.notify(); + })), + ) + }); + + let field_row = |label: &'static str, control: gpui::AnyElement| { + h_flex() + .gap_3() + .items_center() + .child(div().w(px(72.0)).flex_shrink_0().text_sm().child(label)) + .child(div().flex_1().min_w_0().child(control)) + }; + let field_note = |text: &'static str| { + div().pl(px(84.0)).text_xs().text_color(muted).child(text) + }; + + Some( + div() + .absolute() + .inset_0() + .occlude() + .bg(theme.overlay) + .on_mouse_down(MouseButton::Left, cx.listener(|this, _, window, cx| { + cx.stop_propagation(); + // 遮罩上的点击先收图标选择器:弹层开着时,点它外面的 + // 意思是「不选了」,不是「关掉整个编辑器」。 + if this.ssh_icon_picker_open { + this.toggle_ssh_icon_picker(window, cx); + } + })) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + if event.keystroke.key.eq_ignore_ascii_case("escape") { + cx.stop_propagation(); + // Esc 一层一层退:图标选择器开着就只收它。 + if this.ssh_icon_picker_open { + this.toggle_ssh_icon_picker(window, cx); + } else { + this.close_ssh_editor(window, cx); + } + } + })) + .child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .p_6() + .child( + v_flex() + .w(px(500.0)) + .max_w(gpui::relative(1.0)) + .max_h(gpui::relative(1.0)) + .rounded(px(8.0)) + .border_1() + .border_color(theme.border) + .bg(theme.popover) + .shadow_md() + .overflow_hidden() + .child( + h_flex() + .h(px(48.0)) + .px_4() + .items_center() + .border_b_1() + .border_color(theme.border) + .child(div().flex_1().text_lg().child(title)) + .child( + Button::new("ssh-editor-close") + .icon(IconName::Close) + .ghost() + .tooltip("关闭") + .on_click(cx.listener(|this, _, window, cx| { + this.close_ssh_editor(window, cx); + })), + ), + ) + .child( + v_flex() + .flex_1() + .min_h_0() + .overflow_y_scrollbar() + .p_4() + .gap_3() + .child( + h_flex() + .relative() + .gap_3() + .items_center() + .child(avatar) + .child( + v_flex() + .flex_1() + .min_w_0() + .child( + Input::new(&self.ssh_label_input) + .appearance(false) + .bordered(false) + .focus_bordered(false), + ) + .child( + div() + .px_3() + .text_xs() + .text_color(muted) + .truncate() + .child(destination_preview), + ), + ) + .children(icon_popup), + ) + .child(div().w_full().h(px(1.0)).my_1().bg(theme.border)) + .child( + v_flex() + .w_full() + .p_3() + .gap_3() + .rounded(px(8.0)) + .border_1() + .border_color(theme.border) + .bg(theme.group_box) + .child( + div() + .text_sm() + .text_color(theme.group_box_foreground) + .child("连接"), + ) + .child(field_row( + "地址", + Input::new(&self.ssh_destination_input) + .into_any_element(), + )) + .child(field_note( + "支持 user@host,也可粘贴 ssh://user@host:2222;~/.ssh/config 别名同样可用", + )) + .child( + h_flex() + .gap_3() + .items_center() + .child( + div() + .w(px(72.0)) + .flex_shrink_0() + .text_sm() + .child("端口"), + ) + .child( + div() + .w(px(76.0)) + .child(Input::new(&self.ssh_port_input)), + ) + .child( + div() + .text_xs() + .text_color(muted) + .child("默认 22"), + ), + ), + ) + .child( + v_flex() + .w_full() + .p_3() + .gap_3() + .rounded(px(8.0)) + .border_1() + .border_color(theme.border) + .bg(theme.group_box) + .child( + div() + .text_sm() + .text_color(theme.group_box_foreground) + .child("认证"), + ) + .child(field_row( + "方式", + h_flex() + .w_full() + .p(px(2.0)) + .gap(px(2.0)) + .rounded(px(6.0)) + .border_1() + .border_color(theme.border) + .bg(theme.input) + .child(mode_button( + "ssh-auth-password", + "密码", + crate::ssh_profiles::SshAuthMode::Password, + editor.auth + == crate::ssh_profiles::SshAuthMode::Password, + )) + .child(mode_button( + "ssh-auth-key", + "私钥", + crate::ssh_profiles::SshAuthMode::PublicKey, + editor.auth + == crate::ssh_profiles::SshAuthMode::PublicKey, + )) + .child(mode_button( + "ssh-auth-auto", + "自动", + crate::ssh_profiles::SshAuthMode::Auto, + editor.auth + == crate::ssh_profiles::SshAuthMode::Auto, + )) + .child(mode_button( + "ssh-auth-interactive", + "交互式", + crate::ssh_profiles::SshAuthMode::KeyboardInteractive, + editor.auth + == crate::ssh_profiles::SshAuthMode::KeyboardInteractive, + )) + .into_any_element(), + )) + .when(shows_password, |section| { + section + .child( + h_flex() + .gap_3() + .items_center() + .child( + div() + .w(px(72.0)) + .flex_shrink_0() + .text_sm() + .child("密码"), + ) + .child( + div().flex_1().min_w_0().child( + Input::new(&self.ssh_password_input), + ), + ) + .child( + Button::new("ssh-password-visibility") + .icon(if editor.show_password { + IconName::EyeOff + } else { + IconName::Eye + }) + .ghost() + .small() + .tooltip(if editor.show_password { + "隐藏密码" + } else { + "显示密码" + }) + .on_click(cx.listener( + |this, _, window, cx| { + if let Some(editor) = + this.ssh_editor.as_mut() + { + editor.show_password = + !editor.show_password; + } + this.set_ssh_editor_masking( + window, cx, + ); + cx.notify(); + }, + )), + ), + ) + .child(field_note( + "密码不会写入配置文件,只保存到 Windows 凭据管理器。", + )) + .child( + div().pl(px(84.0)).child( + Checkbox::new("ssh-save-password") + .small() + .checked(editor.save_password) + .label("保存到 Windows 凭据管理器") + .on_click(cx.listener( + |this, value: &bool, _, cx| { + if let Some(editor) = + this.ssh_editor.as_mut() + { + editor.save_password = *value; + editor.revision = editor + .revision + .wrapping_add(1); + editor.test_request_id = None; + editor.test_status = None; + } + this.ssh_status = None; + cx.notify(); + }, + )), + ), + ) + }) + .when(shows_keys, |section| { + section + .child( + h_flex() + .gap_3() + .items_center() + .child( + div() + .w(px(72.0)) + .flex_shrink_0() + .text_sm() + .child("私钥"), + ) + .child( + div() + .flex_1() + .text_xs() + .text_color(muted) + .child(if editor.private_keys.is_empty() { + "未指定时使用 ~/.ssh/config 的 IdentityFile 和默认 id_* 私钥" + } else { + "按列出的顺序尝试私钥" + }), + ) + .child( + Button::new("ssh-add-private-key") + .icon(IconName::Plus) + .ghost() + .small() + .tooltip("添加私钥") + .on_click(cx.listener(|this, _, _, cx| { + this.add_ssh_private_key(cx); + })), + ), + ) + .child(v_flex().pl(px(84.0)).gap_2().children(key_rows)) + }) + .when(!shows_password && !shows_keys, |section| { + section.child(field_note( + "连接时在终端中按服务器提示输入密码或 MFA,不保存任何凭据。", + )) + }), + ) + .when_some(status, |content, (message, error)| { + content.child( + h_flex() + .gap_2() + .items_center() + .text_color(if error { danger } else { theme.success }) + .child(if error { + Icon::new(IconName::CircleX).xsmall() + } else { + Icon::new(IconName::CircleCheck).xsmall() + }) + .child(message), + ) + }), + ) + .child( + h_flex() + .h(px(58.0)) + .px_4() + .items_center() + .justify_between() + .border_t_1() + .border_color(theme.border) + .child( + NebulaButton::new("ssh-editor-test") + .label(if editor.testing() { + "测试中…" + } else { + "测试连接" + }) + .outline() + .disabled(editor.testing()) + .on_click(cx.listener(|this, _, _, cx| { + this.test_ssh_editor(cx) + })), + ) + .child( + h_flex() + .gap_2() + .child( + NebulaButton::new("ssh-editor-cancel") + .label("取消") + .outline() + .on_click(cx.listener(|this, _, window, cx| { + this.close_ssh_editor(window, cx); + })), + ) + .child( + NebulaButton::new("ssh-editor-save") + .label("保存") + .primary() + .on_click(cx.listener(|this, _, window, cx| { + this.save_ssh_editor(window, cx); + })), + ), + ), + ), + ), + ) + .into_any_element(), + ) + } + + // ---- UI:身份条头像与它的图标选择器 ---- + + /// 身份条头像:画当前图标,点它开合选择器(旧壳 `SshEditorHit::Avatar`)。 + /// 头像本身就是那个控件——图标是「这台机器长什么样」的一部分,不是一个 + /// 摆在右上角、和名字并列的独立字段。 + fn ssh_avatar( + &self, + icon: &'static crate::display::ui::os_icons::OsIcon, + cx: &mut Context, + ) -> gpui::AnyElement { + let theme = cx.theme(); + let open = self.ssh_icon_picker_open; + let pane = cx.entity().downgrade(); + div() + .id("ssh-icon-avatar") + .relative() + .size(px(46.0)) + .flex_shrink_0() + .rounded(px(6.0)) + .border_1() + .border_color(if open { theme.primary } else { theme.border }) + .bg(theme.input) + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .hover(|avatar| avatar.bg(theme.list_hover)) + .font_family(self.current_font_chain(cx)) + .text_size(px(22.0)) + .child(icon.glyph.to_string()) + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_ssh_icon_picker(window, cx); + })) + // 与字体目录同法:零绘制 canvas 捕获头像的真实窗口坐标,弹层 + // 据此锚定;滚动与 DPI 变化后依然贴着头像。 + .child( + gpui::canvas( + move |bounds, _, cx| { + let _ = pane.update(cx, |pane, _| { + pane.ssh_icon_trigger_bounds = Some(bounds); + }); + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) + .into_any_element() + } + + fn toggle_ssh_icon_picker(&mut self, window: &mut Window, cx: &mut Context) { + self.ssh_icon_picker_open = !self.ssh_icon_picker_open; + if self.ssh_icon_picker_open { + // 打开即聚焦搜索框:记得名字的人比记得形状的人多,二十一个剪影 + // 摊开时,能直接打字才是最快的一条路(旧壳 `IconSearch` 同义)。 + self.ssh_icon_filter_input.update(cx, |input, cx| { + input.set_value("", window, cx); + input.focus(window, cx); + }); + } else { + self.ssh_destination_input.update(cx, |input, cx| input.focus(window, cx)); + } + cx.notify(); + } + + fn select_ssh_icon(&mut self, id: Option<&str>, window: &mut Window, cx: &mut Context) { + if let Some(editor) = self.ssh_editor.as_mut() { + // 空 = 「自动识别」:存空串而不是某个具体形状,换了机器上的系统 + // 图标会自己跟着变(`os_icons::AUTO_ID` 的语义)。 + editor.icon = id.map(str::to_owned); + } + self.ssh_icon_picker_open = false; + // 图标不影响连接有效性,因此保留测试结果;只清掉上一条保存/校验 + // 提示,避免它看起来仍在描述当前草稿。 + self.ssh_status = None; + self.ssh_destination_input.update(cx, |input, cx| input.focus(window, cx)); + cx.notify(); + } + + /// 弹出的图标选择器:顶部搜索框 + 分组过的目录,锚在头像下方。 + /// 返回 `None` 表示收起态(或头像还没量到坐标)。 + fn ssh_icon_popup(&mut self, cx: &mut Context) -> Option { + use crate::display::ui::os_icons::{AUTO_ID, CATALOG, PickerRow, picker_rows}; + + if !self.ssh_icon_picker_open { + return None; + } + let trigger = self.ssh_icon_trigger_bounds?; + let theme = cx.theme(); + let muted = theme.muted_foreground; + let hover_bg = theme.list_hover; + let selected_bg = theme.list_active; + let font_chain = self.current_font_chain(cx); + let current = self + .ssh_editor + .as_ref() + .and_then(|editor| editor.icon.clone()) + .filter(|id| !id.is_empty() && id != AUTO_ID); + let query = self.ssh_icon_filter_input.read(cx).value().to_string(); + // 目录、分组标题与搜索匹配全部走共享的 `picker_rows`:两壳的选择器 + // 因此永远列出同一批图标、按同一种方式分组、对同一个词命中。 + let rows: Vec = picker_rows(&query, true) + .into_iter() + .enumerate() + .map(|(ix, row)| match row { + PickerRow::Group(title) => div() + .h(px(24.0)) + .px_2() + .flex() + .items_center() + .text_xs() + .text_color(muted) + .child(title) + .into_any_element(), + PickerRow::Option(option) => { + let (glyph, name, id) = match option.and_then(|index| CATALOG.get(index)) { + Some(icon) => (icon.glyph.to_string(), icon.zh, Some(icon.id)), + None => ("\u{ea85}".to_owned(), "自动识别", None), + }; + let selected = match id { + Some(id) => current.as_deref() == Some(id), + None => current.is_none(), + }; + let picked = id.map(str::to_owned); + h_flex() + .id(SharedString::from(format!("ssh-icon-row-{ix}"))) + .h(px(30.0)) + .w_full() + .px_2() + .gap_2() + .items_center() + .rounded_md() + .cursor_pointer() + .when(selected, |row| row.bg(selected_bg)) + .hover(|row| row.bg(hover_bg)) + .child( + div() + .w(px(22.0)) + .flex_shrink_0() + .flex() + .justify_center() + .font_family(font_chain.clone()) + .text_size(px(15.0)) + .child(glyph), + ) + .child(div().flex_1().min_w_0().truncate().text_sm().child(name)) + .on_click(cx.listener(move |this, _, window, cx| { + this.select_ssh_icon(picked.as_deref(), window, cx); + })) + .into_any_element() + }, + }) + .collect(); + + let panel = v_flex() + .w(px(240.0)) + .p_2() + .gap_2() + .rounded_lg() + .border_1() + .border_color(theme.border) + .bg(theme.popover) + .shadow_lg() + .occlude() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child(Input::new(&self.ssh_icon_filter_input).small()) + .child(if rows.is_empty() { + div() + .h(px(30.0)) + .px_2() + .flex() + .items_center() + .text_xs() + .text_color(muted) + .child("没有匹配的图标") + .into_any_element() + } else { + // gap 与行宽都要落在滚动区内层:`overflow_y_scrollbar` 会把 + // 外层样式搬到它自建的容器上(详注见 settings_pane.rs)。 + v_flex() + .max_h(px(260.0)) + .overflow_y_scrollbar() + .child(v_flex().w_full().gap(px(1.0)).children(rows)) + .into_any_element() + }); + + Some( + deferred( + anchored() + .anchor(gpui::Corner::TopLeft) + .position(trigger.bottom_left()) + .offset(gpui::point(px(0.0), px(6.0))) + .snap_to_window_with_margin(px(8.0)) + .child(panel), + ) + // 弹层必须压在模态遮罩之上,否则鼠标到不了搜索框和候选行。 + .with_priority(3) + .into_any_element(), + ) + } + + // ---- UI:设置页 SSH 分区 ---- + + /// 主机列表:一个圆角卡片,行间细分隔线,操作按钮 hover 显形(连接为 + /// 文本按钮,编辑/置顶/删除为图标按钮)。删除走 8 秒撤销窗口。 + pub(super) fn section_ssh(&mut self, cx: &mut Context) -> gpui::Div { + let theme = cx.theme(); + let hover_bg = crate::gpui_shell::theme::settings_hover_bg(cx, false); + let muted = theme.muted_foreground; + let hosts = self.ssh_hosts.merged(); + let host_count = hosts.len(); + let profiles = crate::ssh_profiles::SshProfiles::load( + &crate::display::nebula_data_dir().join("ssh_profiles.json"), + ) + .ok(); + let labels = profiles.as_ref().map(|profiles| profiles.labels()).unwrap_or_default(); + let icons = profiles.as_ref().map(|profiles| profiles.icons()).unwrap_or_default(); + let family: SharedString = cx + .try_global::() + .map(|settings| settings.font_family.clone()) + .unwrap_or_else(|| String::from("Maple Mono Normal NF CN")) + .into(); + let hidden: Vec = self.ssh_hosts.hidden_hosts().to_vec(); + let delete_confirm = self.ssh_delete_confirm.clone(); + + let host_rows = hosts.into_iter().enumerate().map(|(ix, host)| { + let pinned = self.ssh_hosts.is_pinned(&host); + let from_config = self.ssh_hosts.is_from_config(&host); + let confirm = delete_confirm.as_deref() == Some(host.as_str()); + let label = labels.get(&host).cloned().unwrap_or_else(|| host.clone()); + // 行首 OS 图标(旧壳裁定 2026-08-09):id 取自 ssh_profiles 存储, + // 未认出回落通用终端形状;mono 字体渲染 Nerd Font 字位。 + let os_icon = + crate::display::ui::os_icons::resolve(icons.get(&host).map(String::as_str)); + let connect_host = host.clone(); + let edit_host = host.clone(); + let pin_host = host.clone(); + let delete_host = host.clone(); + let row_group = SharedString::from(format!("ssh-host-actions-{ix}")); + h_flex() + .id(SharedString::from(format!("ssh-host-row-{ix}"))) + .group(row_group.clone()) + // 旧壳 `SSH_HOST_ROW_H` 固定 58px;两行文字与 OS 图标在 + // 这个高度里共用中线,不能压成普通 48px 设置行。 + .h(px(58.0)) + .w_full() + .px_3() + .items_center() + .gap_3() + .when(ix + 1 < host_count, |row| { + row.border_b_1().border_color(theme.border.opacity(0.5)) + }) + .hover(move |row| row.bg(hover_bg)) + .child( + div() + .w(px(22.0)) + .h_full() + .flex_shrink_0() + .relative() + .flex() + .items_center() + .justify_center() + .font_family(family.clone()) + .text_size(px(18.0)) + .text_color(muted) + .text_center() + .child(os_icon.glyph.to_string()) + // 旧壳把置顶记号压在图标槽右缘,不额外占一列;否则 + // 置顶行的主机标题会比其它行整体右移。 + .when(pinned, |slot| { + slot.child( + div() + .absolute() + .right(px(-2.0)) + .bottom(px(7.0)) + .text_size(px(8.0)) + .text_color(theme.primary) + .child("\u{eab4}"), + ) + }), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child(div().truncate().child(label)) + .child( + h_flex() + .gap_2() + .items_center() + // 副行与旧壳同合同:只放目的地本身;来源用 + // 小徽章表达(config 源的删除语义是隐藏)。 + .child( + div() + .text_xs() + .text_color(muted) + .truncate() + .child(host.clone()), + ) + .when(from_config, |line| { + line.child( + div() + .flex_shrink_0() + .px(px(5.0)) + .rounded_sm() + .text_xs() + .text_color(muted) + .border_1() + .border_color(theme.border) + .child("config"), + ) + }), + ), + ) + .child( + Button::new(SharedString::from(format!("ssh-connect-{ix}"))) + .label("连接") + .small() + .primary() + .invisible() + .group_hover(row_group.clone(), |button| button.visible()) + .on_click(cx.listener(move |this, _, _, cx| { + cx.emit(SettingsPaneEvent::LaunchSsh(connect_host.clone())); + this.ssh_status = Some((format!("正在打开 {connect_host}…"), false)); + cx.notify(); + })), + ) + .child( + Button::new(SharedString::from(format!("ssh-edit-{ix}"))) + .icon(IconName::Settings2) + .ghost() + .small() + .tooltip("编辑主机") + .invisible() + .group_hover(row_group.clone(), |button| button.visible()) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_ssh_editor(Some(edit_host.clone()), window, cx); + })), + ) + .child( + Button::new(SharedString::from(format!("ssh-pin-{ix}"))) + .icon(if pinned { IconName::StarOff } else { IconName::Star }) + .ghost() + .small() + .tooltip(if pinned { "取消置顶" } else { "置顶" }) + .invisible() + .group_hover(row_group.clone(), |button| button.visible()) + .on_click(cx.listener(move |this, _, _, cx| { + this.ssh_apply( + |lists| lists.toggle_pin(&pin_host), + "置顶状态已更新", + cx, + ); + })), + ) + .child( + Button::new(SharedString::from(format!("ssh-delete-{ix}"))) + .map(|button| { + if confirm { + button.label("确认删除").danger().small() + } else { + button + .icon(IconName::Delete) + .ghost() + .small() + .tooltip(if from_config { + "隐藏(config 源不可删除)" + } else { + "删除" + }) + } + }) + // 进了确认态就常显:指针移开还让它隐形,等于把「再点 + // 一次才真删」这个状态藏起来。 + .when(!confirm, |button| { + button + .invisible() + .group_hover(row_group.clone(), |button| button.visible()) + }) + .on_click(cx.listener(move |this, _, _, cx| { + if this.ssh_delete_confirm.as_deref() == Some(delete_host.as_str()) { + this.delete_ssh_host(&delete_host, cx); + } else { + this.ssh_delete_confirm = Some(delete_host.clone()); + cx.notify(); + } + })), + ) + }); + + let hidden_rows = self.ssh_show_hidden.then(|| { + hidden + .iter() + .enumerate() + .map(|(ix, host)| { + let restore_host = host.clone(); + h_flex() + .h(px(32.0)) + .w_full() + .px_3() + .items_center() + .gap_2() + .when(ix + 1 < hidden.len(), |row| { + row.border_b_1().border_color(theme.border.opacity(0.5)) + }) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .text_color(muted) + .truncate() + .child(host.clone()), + ) + .child( + NebulaButton::new(SharedString::from(format!("ssh-restore-{ix}"))) + .label("恢复") + .on_click(cx.listener(move |this, _, _, cx| { + this.ssh_apply( + |lists| lists.restore_hidden(&restore_host), + "已恢复到主机列表", + cx, + ); + })), + ) + }) + .collect::>() + }); + + let hidden_count = self.ssh_hosts.hidden_hosts().len(); + let undo_bar = self.ssh_delete_undo.as_ref().map(|undo| (undo.host.clone(), undo.seq)); + + self.group("SSH 主机", cx) + .child( + h_flex() + .h(px(32.0)) + .items_center() + .gap_2() + .child(div().text_color(theme.foreground).child("已保存主机")) + .when(host_count > 0, |header| { + header.child( + div() + .px(px(6.0)) + .rounded_sm() + .text_xs() + .text_color(muted) + .bg(theme.muted) + .child(SharedString::from(host_count.to_string())), + ) + }) + .child(div().flex_1()) + .child( + NebulaButton::new("ssh-add-host").label("+ 添加主机").primary().on_click( + cx.listener(|this, _, window, cx| { + this.open_ssh_editor(None, window, cx); + }), + ), + ), + ) + .child(div().h(px(8.0))) + .child( + v_flex() + .w_full() + .rounded(px(8.0)) + .border_1() + .border_color(theme.border) + .overflow_hidden() + .children(host_rows) + .when(host_count == 0, |card| { + card.child( + v_flex() + .py_6() + .gap_1() + .items_center() + .child(div().text_color(muted).child("还没有保存的主机")) + .child( + div() + .text_xs() + .text_color(muted) + .child("「+ 添加主机」或在 ~/.ssh/config 里维护别名"), + ), + ) + }), + ) + .child(div().h(px(8.0))) + .child( + h_flex() + .gap_2() + .items_center() + .child( + NebulaButton::new("ssh-import").label("导入 ~/.ssh/config").on_click( + cx.listener(|this, _, _, cx| { + this.ssh_hosts = crate::gpui_shell::ssh_hosts::SshHostLists::load(); + let count = crate::ssh::ssh_config_hosts().len(); + this.ssh_status = + Some((format!("已导入,config 源共 {count} 个别名"), false)); + cx.notify(); + }), + ), + ) + .when(hidden_count > 0, |row| { + let show = self.ssh_show_hidden; + row.child( + NebulaButton::new("ssh-toggle-hidden") + .label(if show { + SharedString::from("收起已隐藏") + } else { + SharedString::from(format!("已隐藏 {hidden_count} 项")) + }) + .on_click(cx.listener(|this, _, _, cx| { + this.ssh_show_hidden = !this.ssh_show_hidden; + cx.notify(); + })), + ) + }) + .child(div().flex_1()) + .child( + div() + .text_xs() + .text_color(muted) + .child("与旧壳共用同一份数据;config 别名删除即隐藏,可恢复"), + ), + ) + .when_some(hidden_rows, |group, rows| { + group.child(div().h(px(8.0))).child( + v_flex() + .w_full() + .rounded(px(8.0)) + .border_1() + .border_color(theme.border) + .overflow_hidden() + .children(rows), + ) + }) + .when_some(undo_bar, |group, (host, _)| { + group.child(div().h(px(8.0))).child( + h_flex() + .h(px(36.0)) + .px_3() + .items_center() + .gap_2() + .rounded(px(6.0)) + .bg(theme.muted) + .child(Icon::new(IconName::Undo2).xsmall().text_color(muted)) + .child( + div() + .flex_1() + .text_sm() + .child(SharedString::from(format!( + "已删除 {host},{SSH_DELETE_UNDO_SECS} 秒内可撤销" + ))), + ) + .child(NebulaButton::new("ssh-undo-delete").label("撤销").outline().on_click( + cx.listener(|this, _, _, cx| this.undo_ssh_delete(cx)), + )), + ) + }) + .when_some(self.ssh_status.clone(), |group, (message, error)| { + group.child( + div() + .pt(px(6.0)) + .text_sm() + .text_color(if error { theme.danger } else { theme.success }) + .child(message), + ) + }) + } +} diff --git a/nebula_app/src/gpui_shell/terminal/element.rs b/nebula_app/src/gpui_shell/terminal/element.rs index 4e444b7..2b6c50c 100644 --- a/nebula_app/src/gpui_shell/terminal/element.rs +++ b/nebula_app/src/gpui_shell/terminal/element.rs @@ -22,7 +22,7 @@ use nebula_terminal::grid::Dimensions as _; use nebula_terminal::render::{RenderSnapshot, SnapshotConfig, boxdraw}; #[cfg(windows)] use nebula_terminal::term::TermMode; -use nebula_terminal::vte::ansi::CursorShape; +use nebula_terminal::vte::ansi::{Color, CursorShape}; use super::view::TerminalView; @@ -187,6 +187,71 @@ impl Element for TerminalElement { view.refresh_suggestion_from_snapshot(prompt_line, suggest_anchor); }); let overrides = snap.color_overrides; + let (theme_anchor, theme_is_light) = themed_anchor(&theme, cx); + let host_cursor_follows_theme = is_default_host_cursor(&theme); + let app_cursor = snap.cursor.as_ref().filter(|cursor| { + cursor.shape == CursorShape::Hidden + && crate::display::content::is_application_cursor_glyph( + cursor.cell_ch, + cursor.cell_flags, + crate::display::content::cell_background_is_fixed(cursor.cell_bg, &overrides), + ) + }); + let themed_block = host_cursor_follows_theme + && snap + .cursor + .as_ref() + .is_some_and(|cursor| matches!(cursor.shape, CursorShape::Block)); + + // 公式覆盖层:与本帧快照同一份网格状态(term 锁内扫描 + 计划), + // 探测/fit/几何合同全部由共享的 display::terminal_math 裁定。 + let scale_factor = window.scale_factor(); + let math_frame = { + let cell_w = layout.cell_width.as_f32(); + let line_h = layout.line_height.as_f32(); + let font_px = f32::from(self.view.read(cx).font_size); + let fg = self.view.read(cx).palette.foreground; + let foreground = crate::display::color::Rgb::new( + (fg.r * 255.0).round() as u8, + (fg.g * 255.0).round() as u8, + (fg.b * 255.0).round() as u8, + ); + let cursor = snap.cursor.as_ref().map(|c| (c.row as usize, c.col as usize)); + let has_selection = !snap.selection_runs.is_empty(); + let bg_runs = &snap.bg_runs; + self.view.update(cx, |view, _| { + let Some(term) = view.session.as_ref().map(|session| session.term.clone()) else { + return super::math_overlay::MathFrame::default(); + }; + view.math.observe_program(view.running_program.as_deref()); + let size_info = crate::display::SizeInfo::new( + cell_w * layout.cols as f32, + line_h * layout.rows as f32, + cell_w, + line_h, + 0.0, + 0.0, + false, + ); + let term = term.lock(); + view.math.plan_frame( + &term, + &size_info, + has_selection, + cursor, + foreground, + font_px, + scale_factor, + |row, start, end| { + bg_runs.iter().any(|run| { + run.row as usize == row + && (run.start as usize) < end + && start < (run.end as usize) + }) + }, + ) + }) + }; let cell_rect = |row: usize, start: usize, count: usize| -> Bounds { Bounds::new( @@ -199,29 +264,94 @@ impl Element for TerminalElement { }; for run in &snap.bg_runs { - let color = theme.resolve(run.color, &overrides, false); - window.paint_quad(fill( - cell_rect(run.row as usize, run.start as usize, (run.end - run.start) as usize), - color, - )); + let mut paint = |start: u16, end: u16, color: Color| { + if start >= end { + return; + } + let color = theme.resolve(color, &overrides, false); + window.paint_quad(fill( + cell_rect(run.row as usize, start as usize, (end - start) as usize), + color, + )); + }; + if let Some(cursor) = app_cursor { + if run.row == cursor.row { + let skip_end = cursor.col.saturating_add(if cursor.wide { 2 } else { 1 }); + if run.start < skip_end && cursor.col < run.end { + paint(run.start, cursor.col, run.color); + paint(skip_end, run.end, run.color); + continue; + } + } + } + paint(run.start, run.end, run.color); } + let selection_fill = if is_default_selection(&theme) { + let alpha = if theme_is_light { + crate::display::ui::tokens::terminal_feedback::SELECTION_ALPHA_LIGHT + } else { + crate::display::ui::tokens::terminal_feedback::SELECTION_ALPHA_DARK + }; + rgba_rgb(theme_anchor, alpha) + } else { + theme.selection + }; for run in &snap.selection_runs { window.paint_quad(fill( cell_rect(run.row as usize, run.start as usize, (run.end - run.start) as usize), - theme.selection, + selection_fill, )); } - // 聚焦时的块状光标垫在文字下面,让字形以反色盖在上面。 + // 聚焦时的块状光标:旧壳默认主题走半透明 theme_anchor(浅色 0.20), + // 叠在格子/壁纸上,不反色文字;用户显式配置光标才用实心色。 if let Some(cursor) = &snap.cursor { if focused && cursor_visible && matches!(cursor.shape, CursorShape::Block) { let width = if cursor.wide { 2 } else { 1 }; + let fill_color = if host_cursor_follows_theme { + let alpha = if theme_is_light { + crate::display::ui::tokens::terminal_feedback::BLOCK_CURSOR_ALPHA_LIGHT + } else { + crate::display::ui::tokens::terminal_feedback::BLOCK_CURSOR_ALPHA_DARK + }; + rgba_rgb(theme_anchor, alpha) + } else { + theme.cursor + }; window.paint_quad(fill( cell_rect(cursor.row as usize, cursor.col as usize, width), - theme.cursor, + fill_color, )); } } + // CC/Codex:DECSCUSR Hidden 后自己画反色空格。旧壳把那格从反色黑 + // 改成叠在主题底上的 theme_anchor;块元素则只换前景,保留字形。 + let app_cursor_color = app_cursor.map(|cursor| { + let alpha = if theme_is_light { + crate::display::ui::tokens::terminal_feedback::BLOCK_CURSOR_ALPHA_LIGHT + } else { + crate::display::ui::tokens::terminal_feedback::BLOCK_CURSOR_ALPHA_DARK + }; + let base = if cursor.cell_ch == ' ' { + rgb_from_rgba(theme.background) + } else { + rgb_from_rgba(theme.resolve(cursor.cell_bg, &overrides, false)) + }; + let (color, _) = + crate::display::content::composite_overlay(theme_anchor, alpha, base, 1.0); + rgba_rgb(color, 1.0) + }); + if let Some(cursor) = app_cursor { + if cursor.cell_ch == ' ' { + if let Some(color) = app_cursor_color { + let width = if cursor.wide { 2 } else { 1 }; + window.paint_quad(fill( + cell_rect(cursor.row as usize, cursor.col as usize, width), + color, + )); + } + } + } let (font, bold_font, italic_font, bold_italic_font, font_size) = { let view = self.view.read(cx); @@ -233,16 +363,28 @@ impl Element for TerminalElement { view.font_size, ) }; - let pick_font = |bold: bool, italic: bool| match (bold, italic) { - (false, false) => font.clone(), - (true, false) => bold_font.clone(), - (false, true) => italic_font.clone(), - (true, true) => bold_italic_font.clone(), + // 全宽(CJK 等)bold run 的字形策略(设置 `cjk_bold_regular`,默认 + // 开):bold 只提亮颜色(resolve 已做),字形保持 Regular——旧壳 + // `wide_bold_use_regular` 的同义移植。CJK 假粗体在小字号糊成一团, + // 这是旧壳早已裁定过的取舍。 + let cjk_bold_regular = cx + .try_global::() + .map(|settings| settings.cjk_bold_regular) + .unwrap_or(true); + let pick_font = |bold: bool, italic: bool, wide: bool| { + let bold = bold && !(wide && cjk_bold_regular); + match (bold, italic) { + (false, false) => font.clone(), + (true, false) => bold_font.clone(), + (false, true) => italic_font.clone(), + (true, true) => bold_italic_font.clone(), + } }; // 聚焦块光标下的字形反色(合同保证该格是独立段)。 let cursor_inverts = |row: u16, col: u16| { focused && cursor_visible + && !themed_block && snap.cursor.as_ref().is_some_and(|c| { matches!(c.shape, CursorShape::Block) && c.row == row && c.col == col }) @@ -253,7 +395,11 @@ impl Element for TerminalElement { // —— CJK 字体下的框线错位就此根治。 let scale = window.scale_factor(); for glyph in &snap.box_glyphs { - let fg = if cursor_inverts(glyph.row, glyph.col) { + let fg = if app_cursor + .is_some_and(|cursor| cursor.row == glyph.row && cursor.col == glyph.col) + { + app_cursor_color.unwrap_or_else(|| theme.resolve(glyph.fg, &overrides, glyph.bold)) + } else if cursor_inverts(glyph.row, glyph.col) { theme.background } else { theme.resolve(glyph.fg, &overrides, glyph.bold) @@ -302,6 +448,11 @@ impl Element for TerminalElement { // 光标反色在这里只是换色,不影响任何字形位置。 for seg in &snap.segments { for cell in &seg.cells { + // 被公式覆盖的源格不画原文(公式直接落在卡底上,与旧壳 + // CoverageMask 合同一致;计划失败的公式不进掩码、原文保留)。 + if math_frame.covers(seg.row as usize, cell.col as usize) { + continue; + } let fg: Hsla = if cursor_inverts(seg.row, cell.col) { theme.background.into() } else { @@ -317,7 +468,7 @@ impl Element for TerminalElement { .then(|| gpui::StrikethroughStyle { thickness: px(1.0), color: Some(fg) }); let run = TextRun { len: cell.text.len(), - font: pick_font(cell.bold, cell.italic), + font: pick_font(cell.bold, cell.italic, seg.wide), color: fg, background_color: None, underline, @@ -339,6 +490,19 @@ impl Element for TerminalElement { } } + // 公式位图画在格子文本之后、装饰(ghost/光标/滚动条)之前, + // 与旧壳 draw_rects → draw_overlays 的次序一致。 + if !math_frame.is_empty() { + super::math_overlay::paint_frame( + &math_frame, + bounds.origin, + (layout.cell_width.as_f32(), layout.line_height.as_f32()), + scale_factor, + window, + cx, + ); + } + if let Some(cursor) = &snap.cursor { // 内联 ghost 余量与弹窗补全:结果由 view 在本帧 render 时算好 // (引擎与旧壳共享),元素只负责画。IME 组合中让位给 preedit, @@ -401,6 +565,16 @@ impl Element for TerminalElement { // ghost 从光标单元格起笔;GPUI 的字形抗锯齿可能盖住同一位置的 // beam/underline 边缘,所以非块状光标的前景必须在 ghost 后补画。 // 聚焦块状光标仍在文字层下方填充,保持旧壳的反色合同。 + let stroke = if host_cursor_follows_theme { + let alpha = if theme_is_light { + crate::display::ui::tokens::terminal_feedback::STROKE_CURSOR_ALPHA_LIGHT + } else { + crate::display::ui::tokens::terminal_feedback::STROKE_CURSOR_ALPHA_DARK + }; + rgba_rgb(theme_anchor, alpha) + } else { + theme.cursor + }; let width = if cursor.wide { 2 } else { 1 }; let rect = cell_rect(cursor.row as usize, cursor.col as usize, width); if !focused || cursor_visible { @@ -408,12 +582,12 @@ impl Element for TerminalElement { (true, CursorShape::Block) => {}, // 已在文字层下方填充 (false, CursorShape::Block | CursorShape::HollowBlock) | (true, CursorShape::HollowBlock) => { - window.paint_quad(outline(rect, theme.cursor, gpui::BorderStyle::Solid)); + window.paint_quad(outline(rect, stroke, gpui::BorderStyle::Solid)); }, (_, CursorShape::Beam) => { window.paint_quad(fill( Bounds::new(rect.origin, size(px(2.0), layout.line_height)), - theme.cursor, + stroke, )); }, (_, CursorShape::Underline) => { @@ -422,7 +596,7 @@ impl Element for TerminalElement { point(rect.origin.x, rect.origin.y + layout.line_height - px(2.0)), size(rect.size.width, px(2.0)), ), - theme.cursor, + stroke, )); }, (_, CursorShape::Hidden) => {}, @@ -761,6 +935,56 @@ fn paint_completion_popup( } } +fn rgba_channels(color: Rgba) -> (u8, u8, u8) { + ( + (color.r * 255.0).round() as u8, + (color.g * 255.0).round() as u8, + (color.b * 255.0).round() as u8, + ) +} + +fn is_default_host_cursor(palette: &super::colors::Palette) -> bool { + rgba_channels(palette.cursor) == default_cursor_rgb() +} + +fn is_default_selection(palette: &super::colors::Palette) -> bool { + rgba_channels(palette.selection) == default_cursor_rgb() + && (palette.selection.a - 0.60).abs() < 0.05 +} + +fn default_cursor_rgb() -> (u8, u8, u8) { + match crate::config::color::NEBULA_DEFAULT_CURSOR.background { + crate::display::color::CellRgb::Rgb(rgb) => (rgb.r, rgb.g, rgb.b), + _ => (0x49, 0x4d, 0x72), + } +} + +fn rgb_from_rgba(color: Rgba) -> crate::display::color::Rgb { + let (r, g, b) = rgba_channels(color); + crate::display::color::Rgb::new(r, g, b) +} + +fn rgba_rgb(color: crate::display::color::Rgb, alpha: f32) -> Rgba { + Rgba { + r: f32::from(color.r) / 255.0, + g: f32::from(color.g) / 255.0, + b: f32::from(color.b) / 255.0, + a: alpha, + } +} + +fn themed_anchor(palette: &super::colors::Palette, cx: &App) -> (crate::display::color::Rgb, bool) { + let sk = crate::gpui_shell::theme::chrome_theme_resolved(cx).skin(); + // ANSI magenta = index 5;旧壳 `display.colors[NamedColor::Magenta]`。 + let magenta = rgb_from_rgba(palette.ansi[5]); + let mix = if sk.is_light { + crate::display::ui::tokens::terminal_feedback::ANCHOR_NEUTRAL_MIX_LIGHT + } else { + crate::display::ui::tokens::terminal_feedback::ANCHOR_NEUTRAL_MIX_DARK + }; + (crate::display::content::mix_rgb(magenta, sk.ink_dim, mix), sk.is_light) +} + #[cfg(test)] mod tests { use super::completion_popup_layout; diff --git a/nebula_app/src/gpui_shell/terminal/keymap.rs b/nebula_app/src/gpui_shell/terminal/keymap.rs index 354df42..b5a02e2 100644 --- a/nebula_app/src/gpui_shell/terminal/keymap.rs +++ b/nebula_app/src/gpui_shell/terminal/keymap.rs @@ -81,10 +81,179 @@ fn ctrl_char(c: char) -> Option { } } +/// ConPTY Win32 input mode 是否当家。 +/// +/// 子进程请求的 Win32 记录是**回落**协议,不是与 kitty 并列的第二编码器: +/// 一旦子进程要过 kitty 键盘标志,那份标志就是线上合同,压过 DECSET 9001 +/// (口径逐字同旧壳 `input::terminal_input::use_win32_input_mode`)。 +fn use_win32_input_mode(mode: &TermMode) -> bool { + mode.contains(TermMode::WIN32_INPUT_MODE) + && !mode.intersects(TermMode::KITTY_KEYBOARD_PROTOCOL) +} + +/// GPUI 键名 → Win32 虚拟键码。 +/// +/// 旧壳从 winit fork 的 `RawKeyEventInfo` 直接拿到系统报的 VK;GPUI 的 +/// `Keystroke` 只有键名,所以这里按名字反查。表只覆盖**编码器会处理的键** +/// (控制键、方向、功能键)——可打印字符在 GPUI 走 IME 管道,不经这里。 +#[cfg(windows)] +fn virtual_key_of(key: &str) -> Option { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + VK_BACK, VK_DELETE, VK_DOWN, VK_END, VK_ESCAPE, VK_F1, VK_HOME, VK_INSERT, VK_LEFT, + VK_NEXT, VK_PRIOR, VK_RETURN, VK_RIGHT, VK_SPACE, VK_TAB, VK_UP, + }; + + let vk = match key { + "escape" => VK_ESCAPE, + "enter" => VK_RETURN, + "tab" => VK_TAB, + "backspace" => VK_BACK, + "space" => VK_SPACE, + "up" => VK_UP, + "down" => VK_DOWN, + "left" => VK_LEFT, + "right" => VK_RIGHT, + "home" => VK_HOME, + "end" => VK_END, + "insert" => VK_INSERT, + "delete" => VK_DELETE, + "pageup" => VK_PRIOR, + "pagedown" => VK_NEXT, + // F1..F24 在 VK 表里连号。 + key if key.starts_with('f') => { + let index: u16 = key[1..].parse().ok()?; + if !(1..=24).contains(&index) { + return None; + } + VK_F1 + index - 1 + }, + // 单个字符(Ctrl+C 一族):ASCII 字母数字的 VK 就是它的大写码点。 + key => { + let mut chars = key.chars(); + let (Some(c), None) = (chars.next(), chars.next()) else { return None }; + if !c.is_ascii_alphanumeric() { + return None; + } + c.to_ascii_uppercase() as u16 + }, + }; + Some(vk) +} + +/// 控制键必须携带真实 `KEY_EVENT_RECORD` 的字符值(Esc=0x1B、Enter=0x0D、 +/// Tab=0x09、Backspace=0x08):OpenConsole 1.22 的 VT 翻译层会丢弃 uChar=0 +/// 的 VK_ESCAPE,于是读字节流的那类应用(Claude Code)收不到 Esc。修饰键与 +/// 功能键保持 0,与真实键盘一致。逐条同旧壳 `control_char_fallback`。 +#[cfg(windows)] +fn unicode_char_of(ks: &Keystroke) -> u16 { + // 平台已经判出文本的(含 Ctrl 变体)以它为准,与 WM_CHAR 语义一致。 + // `key_char` 若是 NUL,当作没文本:真实键盘的 Esc 不会写出 U+0000。 + if let Some(text) = ks.key_char.as_deref() { + let mut units = text.encode_utf16(); + if let (Some(first), None) = (units.next(), units.next()) { + if first != 0 { + return first; + } + } + } + match ks.key.as_str() { + "escape" => 0x1b, + "enter" => b'\r' as u16, + "tab" => b'\t' as u16, + "backspace" => 0x08, + "space" => b' ' as u16, + _ => 0, + } +} + +/// 一条 ConPTY Win32 input 记录:`CSI Vk;Sc;Uc;Kd;Cs;Rc_`。 +/// +/// 认不出 VK 的键返回 `None`,调用方回落到传统 VT 编码——宁可少一条记录, +/// 也不要编一个 Vk=0 的假记录,那会让子进程读到一个不存在的键。 +#[cfg(windows)] +fn win32_input_record(ks: &Keystroke, key_down: bool) -> Option> { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{MAPVK_VK_TO_VSC, MapVirtualKeyW}; + + const SHIFT_PRESSED: u32 = 0x0010; + const LEFT_ALT_PRESSED: u32 = 0x0002; + const LEFT_CTRL_PRESSED: u32 = 0x0008; + + let vk = virtual_key_of(ks.key.as_str())?; + // 扫描码问系统要,不硬编码:非 US 布局与笔记本键盘上这张表并不通用。 + let scan_code = unsafe { MapVirtualKeyW(u32::from(vk), MAPVK_VK_TO_VSC) }; + let mut control_key_state = 0u32; + if ks.modifiers.shift { + control_key_state |= SHIFT_PRESSED; + } + if ks.modifiers.alt { + control_key_state |= LEFT_ALT_PRESSED; + } + if ks.modifiers.control { + control_key_state |= LEFT_CTRL_PRESSED; + } + let key_down = u8::from(key_down); + Some( + format!( + "\x1b[{};{};{};{key_down};{};1_", + vk, + scan_code, + unicode_char_of(ks), + control_key_state + ) + .into_bytes(), + ) +} + +/// GPUI 的终端视图只接到 key-down。旧壳对 9001 会再写一条 Kd=0 的抬起 +/// (`keyboard.rs` 的 `key_release` + `escape_carries_its_control_character_both_directions`)。 +/// 真实键盘也是 down+up:Codex 按 VK 看按下就够了,Claude Code / Ink 吃的是 +/// OpenConsole 翻译出的字节流,缺抬起时 Esc 常常一个字节都到不了。 +#[cfg(windows)] +fn win32_press_and_release(ks: &Keystroke) -> Option> { + let mut sequence = win32_input_record(ks, true)?; + sequence.extend(win32_input_record(ks, false)?); + Some(sequence) +} + +/// 子进程要过 kitty 键盘标志时,Esc 必须是 `CSI 27u`,不是裸 `\x1b`。 +/// 口径同旧壳 `kitty_disambiguate_escape_is_csi_27_u`。 +fn kitty_escape(ks: &Keystroke, mode: &TermMode) -> Option> { + let kitty = mode.intersects( + TermMode::REPORT_ALL_KEYS_AS_ESC + | TermMode::DISAMBIGUATE_ESC_CODES + | TermMode::REPORT_EVENT_TYPES, + ); + if !kitty || ks.key.as_str() != "escape" { + return None; + } + let param = modifier_param(ks); + Some(if param == 1 { + b"\x1b[27u".to_vec() + } else { + format!("\x1b[27;{param}u").into_bytes() + }) +} + /// 返回 `None` 表示这次按键不由编码器处理(交给 IME/文本输入路径)。 pub fn encode(ks: &Keystroke, mode: &TermMode) -> Option> { let mods = &ks.modifiers; + // ConPTY 是带 Win32 input mode 标志创建的(见 `tty::windows::conpty`), + // 子进程一发 DECSET 9001 就切到这份记录格式。旧壳在 `build_sequence` + // 开头做同样的分流;GPUI 此前一直只发传统 VT,于是裸 `\x1b` 要由 + // OpenConsole 的翻译层反译成 KEY_EVENT_RECORD,而它会丢掉 uChar=0 的 + // VK_ESCAPE——读字节流的应用(Claude Code)因此收不到 Esc。 + #[cfg(windows)] + if use_win32_input_mode(mode) { + if let Some(record) = win32_press_and_release(ks) { + return Some(record); + } + } + + if let Some(bytes) = kitty_escape(ks, mode) { + return Some(bytes); + } + match ks.key.as_str() { "enter" => { return Some(if mods.alt { b"\x1b\r".to_vec() } else { b"\r".to_vec() }); @@ -159,3 +328,74 @@ pub fn encode(ks: &Keystroke, mode: &TermMode) -> Option> { None } + +#[cfg(test)] +mod tests { + use super::*; + + fn keystroke(key: &str) -> Keystroke { + Keystroke { modifiers: gpui::Modifiers::default(), key: key.to_owned(), key_char: None } + } + + /// 传统 VT 路径(子进程没要过 DECSET 9001):Esc 就是裸 `\x1b`。 + #[test] + fn escape_stays_a_bare_byte_on_the_legacy_vt_path() { + let mode = TermMode::default(); + assert_eq!(encode(&keystroke("escape"), &mode), Some(b"\x1b".to_vec())); + } + + /// Win32 input mode 下 Esc 必须走 `CSI Vk;Sc;Uc;Kd;Cs;Rc_`,且 Uc 是 + /// 真实的 0x1B——填 0 会被 OpenConsole 1.22 的翻译层丢掉,读字节流的 + /// 应用(Claude Code)就永远收不到 Esc。GPUI 没有 key-up 通道,所以 + /// 一次按键要成对写出 down+up,形状对齐旧壳真实键盘。 + #[cfg(windows)] + #[test] + fn escape_becomes_a_win32_record_carrying_its_real_char() { + let bytes = encode(&keystroke("escape"), &TermMode::WIN32_INPUT_MODE) + .expect("win32 模式下 Esc 必须编码"); + let text = String::from_utf8(bytes).expect("记录是 ASCII"); + let records: Vec<&str> = text.split_inclusive('_').filter(|s| !s.is_empty()).collect(); + assert_eq!(records.len(), 2, "一次 Esc 必须是 down+up 两条记录:{text:?}"); + + let parse = |record: &str| -> Vec { + let body = record + .strip_prefix("\x1b[") + .and_then(|s| s.strip_suffix('_')) + .expect("CSI … _"); + body.split(';').map(str::to_owned).collect() + }; + let down = parse(records[0]); + let up = parse(records[1]); + assert_eq!(down.len(), 6, "字段数固定为 Vk;Sc;Uc;Kd;Cs;Rc:{text:?}"); + assert_eq!(down[0], "27", "VK_ESCAPE"); + // 扫描码问系统要(布局相关),只断言它不是 0——0 意味着查询失败。 + assert_ne!(down[1], "0", "扫描码不能为 0:{text:?}"); + assert_eq!(down[2], "27", "uChar 必须是真实的 0x1B,不是 0"); + assert_eq!(down[3], "1", "按下"); + assert_eq!(down[4], "0", "无修饰"); + assert_eq!(down[5], "1", "重复次数"); + assert_eq!(&up[..3], &down[..3], "抬起与按下的 Vk/Sc/Uc 必须一致"); + assert_eq!(up[3], "0", "抬起"); + assert_eq!(up[4], down[4]); + assert_eq!(up[5], "1"); + } + + /// kitty 键盘标志是子进程明确要过的线上合同,压过 DECSET 9001 + /// (口径同旧壳 `use_win32_input_mode` / `kitty_disambiguate_escape_is_csi_27_u`)。 + #[test] + fn kitty_flags_take_precedence_over_win32_records() { + let mode = TermMode::WIN32_INPUT_MODE | TermMode::DISAMBIGUATE_ESC_CODES; + assert!(!use_win32_input_mode(&mode)); + assert_eq!(encode(&keystroke("escape"), &mode), Some(b"\x1b[27u".to_vec())); + } + + /// 认不出 VK 的键回落传统 VT,而不是编一条 Vk=0 的假记录。 + #[cfg(windows)] + #[test] + fn unknown_keys_fall_back_instead_of_forging_a_record() { + assert_eq!(virtual_key_of("f99"), None); + assert_eq!(virtual_key_of("capslock"), None); + // 方向键在 win32 模式下仍要出记录(它们有确定的 VK)。 + assert!(win32_input_record(&keystroke("up"), true).is_some()); + } +} diff --git a/nebula_app/src/gpui_shell/terminal/math_overlay.rs b/nebula_app/src/gpui_shell/terminal/math_overlay.rs new file mode 100644 index 0000000..6a503f5 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/math_overlay.rs @@ -0,0 +1,222 @@ +//! GPUI 壳终端的公式覆盖层。 +//! +//! 探测/持久化/fit/几何全部复用旧壳 `display::terminal_math`(单一权威, +//! 两壳像素级同源);本文件只做三件事:在 term 锁内组织一帧的扫描与 +//! 绘制计划、把覆盖掩码交给格子绘制循环跳过源格、按计划用 +//! `math_view` 的位图管线上屏。 +//! +//! 与旧壳 `draw_pane` 相同的门控:alt screen / vi 模式 / 存在选区时整帧 +//! 不覆盖(源码可正常选择复制);光标所在逻辑行的排除在 `scan_visible` +//! 内部完成。旧壳按 `RenderableCell` 背景 alpha 过滤围栏代码块,本壳的 +//! 等价数据源是快照的 `bg_runs`(默认背景不产生 run)。 + +use std::sync::Arc; + +use gpui::{App, Bounds, Pixels, Rgba, SharedString, Window, point, px, size}; +use nebula_terminal::Term; +use nebula_terminal::event::EventListener; +use nebula_terminal::index::{Column, Point}; +use nebula_terminal::term::TermMode; + +use crate::display::SizeInfo; +use crate::display::color::Rgb; +use crate::display::terminal_math::{ + self, CoverageMask, OverlayDrawPlan, TerminalMathState, +}; +use crate::gpui_shell::math_view; + +/// 每 pane 一份的覆盖层状态(`TerminalView` 持有)。内部的持久锚点/缓存 +/// 语义(滚动、reflow、清屏失效)全部由共享的 [`TerminalMathState`] 决定。 +#[derive(Default)] +pub struct MathOverlay { + state: TerminalMathState, +} + +/// 一条通过几何计划的公式:源码 + 绘制计划(坐标系 = 网格局部逻辑 px)。 +pub struct PlannedFormula { + source: Arc, + plan: OverlayDrawPlan, +} + +/// 一帧的覆盖产物:绘制清单 + 源格跳过掩码。 +#[derive(Default)] +pub struct MathFrame { + formulas: Vec, + coverage: CoverageMask, +} + +impl MathFrame { + /// 该格是否被某条公式覆盖(覆盖格不画原文)。 + pub fn covers(&self, row: usize, column: usize) -> bool { + !self.coverage.is_empty() && self.coverage.covers(Point::new(row, Column(column))) + } + + pub fn is_empty(&self) -> bool { + self.formulas.is_empty() + } +} + +impl MathOverlay { + /// shell 集成上报的前台程序(AI CLI 名单解锁行内 `$…$`,与旧壳同门)。 + pub fn observe_program(&mut self, program: Option<&str>) { + self.state.observe_program(program); + } + + /// 每帧的扫描 + 预编 + 几何计划。必须在 term 锁内调用。 + /// + /// `size` 以网格左上角为原点(padding = 0);`bg_covered` 回答"该行的 + /// 列区间内是否存在非默认背景"(围栏代码过滤)。 + #[allow(clippy::too_many_arguments)] + pub fn plan_frame( + &mut self, + term: &Term, + size: &SizeInfo, + has_selection: bool, + cursor: Option<(usize, usize)>, + default_foreground: Rgb, + font_pixel_size: f32, + pixels_per_point: f32, + bg_covered: impl Fn(usize, usize, usize) -> bool, + ) -> MathFrame { + // 旧壳 draw_pane 的整帧门控(搜索态本壳尚不存在,故不在此列)。 + if term.mode().intersects(TermMode::ALT_SCREEN | TermMode::VI) || has_selection { + return MathFrame::default(); + } + + let allow_inline_dollar = self.state.inline_dollar_enabled(); + let cursor = cursor.map(|(row, column)| Point::new(row, Column(column))); + let mut overlays = terminal_math::scan_visible( + &mut self.state, + term, + size, + // 本壳不走 RenderableCell 管线:fallback 重画与围栏过滤各有 + // 等价实现(覆盖失败不跳格 / bg_runs 过滤)。 + &[], + allow_inline_dollar, + cursor, + default_foreground, + ); + // 围栏代码里的公式源码保持原文(旧壳按单元格背景 alpha 判定)。 + overlays.retain(|overlay| { + !overlay.covered_ranges().any(|(row, start, end)| { + usize::try_from(row).is_ok_and(|row| bg_covered(row, start, end)) + }) + }); + + let prepared = terminal_math::prepare_overlays( + &mut self.state, + &overlays, + size, + font_pixel_size, + pixels_per_point, + ); + let mut plans: Vec> = Vec::with_capacity(overlays.len()); + for (overlay, prepared) in overlays.iter().zip(&prepared) { + plans.push(prepared.as_ref().and_then(|prepared| { + terminal_math::plan_overlay_draw( + &mut self.state, + overlay, + prepared, + size, + pixels_per_point, + ) + })); + } + // 掩码按"真的会画"的公式构建:计划失败的公式保留原文,不留洞。 + let coverage = CoverageMask::build(&overlays, &plans); + let formulas = overlays + .iter() + .zip(&plans) + .filter_map(|(overlay, plan)| { + plan.map(|plan| PlannedFormula { source: overlay.source_arc(), plan }) + }) + .collect(); + MathFrame { formulas, coverage } + } +} + +/// 按计划绘制一帧的公式(在格子文本之后调用)。`origin` 是网格左上角的 +/// 窗口坐标;`cell` 是 (宽, 高),text ops 的"整字符可见才画"合同用它。 +pub fn paint_frame( + frame: &MathFrame, + origin: gpui::Point, + cell: (f32, f32), + pixels_per_point: f32, + window: &mut Window, + cx: &mut App, +) { + for formula in &frame.formulas { + let plan = &formula.plan; + let color = Rgba { + r: plan.foreground.r as f32 / 255.0, + g: plan.foreground.g as f32 / 255.0, + b: plan.foreground.b as f32 / 255.0, + a: 1.0, + }; + let source: SharedString = formula.source.to_string().into(); + + // 裁剪跟随 bleed 预算(与旧壳 MathClip 同一矩形),交给 GPUI 的 + // content mask 执行像素级裁剪。 + let clip = Bounds::new( + point(origin.x + px(plan.clip_left), origin.y + px(plan.clip_top)), + size( + px((plan.clip_right - plan.clip_left).max(0.0)), + px((plan.clip_bottom - plan.clip_top).max(0.0)), + ), + ); + let left = origin.x + px(plan.origin_x); + let baseline = origin.y + px(plan.baseline_y); + window.with_content_mask(Some(gpui::ContentMask { bounds: clip }), |window| { + math_view::paint_formula_image( + &source, + plan.display_style, + plan.fitted_pixel_size, + pixels_per_point, + left, + baseline, + color, + window, + cx, + ); + + // 数学字体缺字的字符(公式里的中文等):旧壳合同是整字符落在 + // 裁剪窗内才画,避免半个汉字。 + let layout = cx.global_mut::().layout( + &source, + plan.display_style, + plan.fitted_pixel_size, + pixels_per_point, + ); + let Some(layout) = layout else { return }; + let text_style = window.text_style(); + for op in &layout.text { + let scale = op.pixel_size / plan.fitted_pixel_size.max(f32::EPSILON); + let x = plan.origin_x + op.x; + let width = cell.0 * scale; + let height = cell.1 * scale; + let top = plan.baseline_y + op.baseline_y - height * 0.8; + if x < plan.clip_left + || x + width > plan.clip_right + || top < plan.clip_top + || top + height > plan.clip_bottom + { + continue; + } + let mut buffer = [0u8; 4]; + let text: SharedString = op.character.encode_utf8(&mut buffer).to_string().into(); + let run = text_style.to_run(text.len()); + let line = window.text_system().shape_line( + text, + px(op.pixel_size), + std::slice::from_ref(&run), + None, + ); + let target = point( + origin.x + px(x), + origin.y + px(plan.baseline_y + op.baseline_y) - line.ascent, + ); + let _ = line.paint(target, line.ascent + line.descent, window, cx); + } + }); + } +} diff --git a/nebula_app/src/gpui_shell/terminal/mod.rs b/nebula_app/src/gpui_shell/terminal/mod.rs index 9b4bd06..f606d78 100644 --- a/nebula_app/src/gpui_shell/terminal/mod.rs +++ b/nebula_app/src/gpui_shell/terminal/mod.rs @@ -6,6 +6,7 @@ pub mod colors; pub mod element; pub mod keymap; +pub mod math_overlay; pub mod mouse_protocol; pub mod session; pub mod suggest; diff --git a/nebula_app/src/gpui_shell/terminal/ssh_connect_overlay.rs b/nebula_app/src/gpui_shell/terminal/ssh_connect_overlay.rs index cc8e413..b8c7c74 100644 --- a/nebula_app/src/gpui_shell/terminal/ssh_connect_overlay.rs +++ b/nebula_app/src/gpui_shell/terminal/ssh_connect_overlay.rs @@ -9,13 +9,12 @@ //! - 等宽 cell 度量(`SizeInfo`)→ UI 基准字号(同一设置源) use gpui::{ - AnyElement, Bounds, Context, Hsla, InteractiveElement as _, StatefulInteractiveElement as _, - IntoElement, MouseButton, ParentElement as _, SharedString, - Styled as _, canvas, div, fill, hsla, point, - px, size, + canvas, div, fill, hsla, point, px, size, AnyElement, Bounds, Context, Hsla, + InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, SharedString, + StatefulInteractiveElement as _, Styled as _, }; -use crate::display::ssh_connect::{self, SshConnectState, stage_labels, stage_message}; +use crate::display::ssh_connect::{self, stage_labels, stage_message, SshConnectState}; use crate::gpui_shell::prelude::*; /// UiLanguage 由设置折算(两壳同源)。 @@ -48,12 +47,7 @@ fn hsla_from_rgba(r: u8, g: u8, b: u8) -> Hsla { fn lerp_hsla(a: Hsla, b: Hsla, k: f32) -> Hsla { let k = k.clamp(0.0, 1.0); - hsla( - a.h + (b.h - a.h) * k, - a.s + (b.s - a.s) * k, - a.l + (b.l - a.l) * k, - 1.0, - ) + hsla(a.h + (b.h - a.h) * k, a.s + (b.s - a.s) * k, a.l + (b.l - a.l) * k, 1.0) } /// 品牌紫→青(旧壳 palette.edge_l/edge_r;浅色主题压暗 0.28)。 @@ -243,9 +237,10 @@ pub(super) fn overlay( let theme = cx.theme(); let lang = language(); + // chrome 文本锚定配置字号(旧壳 ui_font 合同),不跟终端缩放。 let ui_px = cx .try_global::() - .map(|settings| settings.font_size_px) + .map(|settings| settings.base_font_size_px) .unwrap_or(15.0); let family: SharedString = cx .try_global::() @@ -261,12 +256,7 @@ pub(super) fn overlay( let card_bg = theme.group_box; let hairline = theme.border; let dark = theme.is_dark(); - let elevation = gpui::hsla( - 0.0, - 0.0, - 0.0, - if dark { 56.0 / 255.0 } else { 26.0 / 255.0 }, - ); + let elevation = gpui::hsla(0.0, 0.0, 0.0, if dark { 56.0 / 255.0 } else { 26.0 / 255.0 }); // 遮罩沿用终端 pane 的真实底色;直接铺 popover 会把空网格区域 // 换成另一层面板色,尤其在浅色主题下会显得整块发白。 let backdrop = crate::gpui_shell::theme::card_content_bg(cx); @@ -304,14 +294,7 @@ pub(super) fn overlay( rack_panel, ); let d = 2.4; - quad( - bx + stroke + 2.2, - by + bh * 0.5 - d * 0.5, - d, - d, - d * 0.5, - rack_ink, - ); + quad(bx + stroke + 2.2, by + bh * 0.5 - d * 0.5, d, d, d * 0.5, rack_ink); } }, ) @@ -323,13 +306,10 @@ pub(super) fn overlay( let logs_target = cx.entity().downgrade(); let logs_open = state.is_logs_open(); let logs_hover = theme.list_hover; - let logs_chevron = Icon::new(if logs_open { - IconName::ChevronUp - } else { - IconName::ChevronDown - }) - .xsmall() - .text_color(ink_dim); + let logs_chevron = + Icon::new(if logs_open { IconName::ChevronUp } else { IconName::ChevronDown }) + .xsmall() + .text_color(ink_dim); let logs_button = h_flex() .id("ssh-connect-logs") .h(px(30.0)) @@ -410,12 +390,8 @@ pub(super) fn overlay( let labels = stage_labels(lang); let active = state.stage_index(); let caption_h = ui_px * 0.75; - let rail_block = v_flex() - .w_full() - .gap(px(8.0)) - .mt(px(24.0)) - .child(rail_canvas(state, cx, &theme)) - .child( + let rail_block = + v_flex().w_full().gap(px(8.0)).mt(px(24.0)).child(rail_canvas(state, cx, &theme)).child( div().relative().h(px(caption_h)).children( labels .iter() @@ -472,11 +448,7 @@ pub(super) fn overlay( .mt(px(24.0)) .justify_between() .child( - div() - .font_family(family.clone()) - .text_size(px(ui_px)) - .text_color(msg_ink) - .child(msg), + div().font_family(family.clone()).text_size(px(ui_px)).text_color(msg_ink).child(msg), ) .when(!state.failed(), |row| { row.child( @@ -491,22 +463,19 @@ pub(super) fn overlay( // ── 失败详情(两行,超出收省略号)── let per_line = 44usize; let detail = state.failure().map(|reason| { - v_flex() - .mt(px(8.0)) - .gap(px(ui_px * 0.28)) - .children( - ssh_connect::wrap(reason, per_line) - .into_iter() - .take(2) - .map(|line| { - div() - .font_family(family.clone()) - .text_size(px(ui_px * 0.80)) - .text_color(ink_dim) - .child(ssh_connect::truncate_cols(&line, per_line)) - }) - .collect::>(), - ) + v_flex().mt(px(8.0)).gap(px(ui_px * 0.28)).children( + ssh_connect::wrap(reason, per_line) + .into_iter() + .take(2) + .map(|line| { + div() + .font_family(family.clone()) + .text_size(px(ui_px * 0.80)) + .text_color(ink_dim) + .child(ssh_connect::truncate_cols(&line, per_line)) + }) + .collect::>(), + ) }); // ── Logs 区(末尾 6 行,最新在最下)── @@ -546,27 +515,22 @@ pub(super) fn overlay( }; let failed = state.failed(); let action_target = cx.entity().downgrade(); - let buttons = h_flex() - .w_full() - .mt(px(24.0)) - .justify_end() - .child( - Button::new("ssh-connect-action") - .label(action_label) - .small() - .map(|button| if failed { button.primary() } else { button.outline() }) - .on_click(move |_, _, cx| { - if let Some(view) = action_target.upgrade() { - view.update(cx, |this, cx| { - // 取消与关闭是同一个动作(旧裁定):这个 pane - // 除了这条连接没有别的内容。 - this.ssh_connect = None; - cx.emit(super::view::TerminalViewEvent::RequestClose); - cx.notify(); - }); - } - }), - ); + let buttons = h_flex().w_full().mt(px(24.0)).justify_end().child({ + let button = + crate::gpui_shell::widgets::NebulaButton::new("ssh-connect-action").label(action_label); + let button = if failed { button.primary() } else { button.outline() }; + button.on_click(move |_, _, cx| { + if let Some(view) = action_target.upgrade() { + view.update(cx, |this, cx| { + // 取消与关闭是同一个动作(旧裁定):这个 pane + // 除了这条连接没有别的内容。 + this.ssh_connect = None; + cx.emit(super::view::TerminalViewEvent::RequestClose); + cx.notify(); + }); + } + }) + }); // ── 组装 ── div() diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index 650a735..018fc22 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -27,7 +27,7 @@ use super::session::{self, TerminalSession}; use super::suggest; use super::{KEY_CONTEXT, TerminalBackTab, TerminalTab}; use crate::gpui_shell::config::Settings; -use crate::gpui_shell::prelude::DialogButtonProps; +use crate::gpui_shell::prelude::{DialogButtonProps, center_confirm_dialog}; use futures::StreamExt as _; @@ -111,11 +111,17 @@ pub enum TerminalLaunch { Ssh { destination: String }, } -/// 侧边栏只消费稳定的三态,不直接窥探终端/SSH 的内部错误字段。 +/// 侧边栏只消费稳定的枚举态,不直接窥探终端/SSH 的内部错误字段。 +/// `Done`/`Attention` 是旧壳 `AgentStatus::Done`/`Blocked` 的呈现位: +/// 回合结束≠会话结束,转圈必须停,但要留下「有结果没看」的痕迹。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SidebarActivity { Idle, Running, + /// Agent 回合完成,等待下一条指令(旧壳蓝点语义)。 + Done, + /// Agent 停在授权/提问上,需要用户表态(旧壳手掌语义)。 + Attention, Failed, } @@ -123,6 +129,8 @@ pub struct TerminalView { pub pane_id: u64, pub session: Option, pub focus_handle: FocusHandle, + /// 公式覆盖层(探测/持久化状态复用旧壳 `terminal_math`,每 pane 一份)。 + pub math: super::math_overlay::MathOverlay, pub font: Font, pub font_bold: Font, pub font_italic: Font, @@ -148,6 +156,16 @@ pub struct TerminalView { /// `NEBULA|` 标题报 `running_program`,装了 OSC133 集成的 bash/zsh/nu 只 /// 发这一对语义标记——两条路都要能点亮侧栏的「运行中」。 command_running: bool, + /// Hook 事件驱动的 agent 回合状态(旧壳 `nebula_state.agent_status` 的 + /// 边沿触发版)。`Unknown` = 本 pane 没有 agent 参与,spinner 完全由 + /// `command_running`/`running_program` 决定。 + agent_status: crate::ai_agents::AgentStatus, + /// 本次前台 agent 会话是否收到过 hook(旧壳同名字段同语义):屏幕 + /// 检测的空闲提示符不得降级 hook 报出的 Done/Blocked 精确终态。 + agent_hook_seen: bool, + /// 屏幕检测连续看到空闲提示符的拍数;Working 连续两拍空闲才降级 + /// (单拍可能是重绘间隙),非 idle 检测与任何 hook 边沿都清零。 + idle_screen_streak: u8, /// OSC 9 程序通知,攒到 render(那里才有 `Window` 判聚焦)再呈现。 pending_notify: Vec, /// SSH 直连目的地(`user@host[:port]`);本地会话为 None。 @@ -558,6 +576,7 @@ impl TerminalView { pane_id, session, focus_handle: cx.focus_handle(), + math: super::math_overlay::MathOverlay::default(), font: mono_font(&families[0], FontWeight::NORMAL, FontStyle::Normal), font_bold: mono_font(&families[1], FontWeight::BOLD, FontStyle::Normal), font_italic: mono_font(&families[2], FontWeight::NORMAL, FontStyle::Italic), @@ -574,6 +593,9 @@ impl TerminalView { branch: String::new(), running_program: None, command_running: false, + agent_status: crate::ai_agents::AgentStatus::Unknown, + agent_hook_seen: false, + idle_screen_streak: 0, pending_notify: Vec::new(), ssh_destination, ssh_stage: None, @@ -713,6 +735,9 @@ impl TerminalView { cx.emit(TerminalViewEvent::TitleChanged); } self.command_running = false; + self.agent_status = crate::ai_agents::AgentStatus::Unknown; + self.agent_hook_seen = false; + self.idle_screen_streak = 0; cx.notify(); }, TermEvent::Notify(body) => { @@ -775,7 +800,15 @@ impl TerminalView { } /// Apply one lifecycle event already routed to this pane by the workspace. + /// + /// 旧壳 `WindowContext::handle_ai_hook` 状态机的忠实移植:此前这里把 + /// 所有非 SessionEnd 事件压平成「点亮 running_program」,于是回合结束 + /// (TurnDone)后 spinner 也永远转下去——「CLI 都答完了侧栏还在转圈」 + /// 的病根就是这次压平。 pub fn handle_ai_hook(&mut self, event: &crate::ai_hook::AiHookEvent, cx: &mut Context) { + use crate::ai_agents::AgentStatus; + use crate::ai_hook::AiHookKind; + // SessionEnd 可能是第一次携带最终权威 id 的事件;必须先落盘再清理 // pane 现场,否则 CLI 正常退出后反而无法冷恢复。 if let Some(id) = event.session_id.as_deref() { @@ -785,28 +818,149 @@ impl TerminalView { log::warn!("agent session index: could not record {} {id}: {error}", event.source); } } - if event.kind == crate::ai_hook::AiHookKind::SessionEnd { - self.ai_session = None; - self.running_program = None; - } else { - self.running_program = Some(event.source.clone()); - if let Some(id) = event.session_id.as_deref() { - self.ai_session = Some(crate::display::AiSessionIdentity { - source: event.source.clone(), - session_id: id.to_owned(), - }); - } + match event.kind { + AiHookKind::SessionEnd => { + self.ai_session = None; + self.running_program = None; + self.agent_status = AgentStatus::Unknown; + self.agent_hook_seen = false; + self.idle_screen_streak = 0; + }, + kind => { + self.running_program = Some(event.source.clone()); + self.agent_hook_seen = true; + // 精确边沿抵达 = 屏幕检测的空闲计数作废(上一回合攒下的 + // 拍数不能把新回合的第一个空闲闪现立即降级)。 + self.idle_screen_streak = 0; + if let Some(id) = event.session_id.as_deref() { + self.ai_session = Some(crate::display::AiSessionIdentity { + source: event.source.clone(), + session_id: id.to_owned(), + }); + } + match kind { + // 会话起来了但还没干活:亮图标,不转圈。 + AiHookKind::SessionStart => self.agent_status = AgentStatus::Idle, + AiHookKind::PromptSubmit => self.agent_status = AgentStatus::Working, + // 一个工具刚跑完 = agent 正在干活,这是无条件事实(回合 + // 可能经不发 PromptSubmit 的路径继续——授权点头、队列 + // 消息——状态却还挂在 Done:「还在执行却显示完成」)。 + AiHookKind::ToolComplete => self.agent_status = AgentStatus::Working, + AiHookKind::TurnDone | AiHookKind::NeedsAttention => { + // codex 的 notify 只有「回合完成」一种事件:弹交互式 + // 提问时发的也是 turn-complete。回合结束的瞬间看一眼 + // 屏幕尾部——还挂着选择框/确认提示,就按「等你批准」 + // 处理(旧壳同款嗅探)。 + let screen_asks = + event.kind == AiHookKind::TurnDone && self.screen_tail_asks(); + self.agent_status = + if event.kind == AiHookKind::NeedsAttention || screen_asks { + AgentStatus::Blocked + } else { + AgentStatus::Done + }; + }, + AiHookKind::SessionEnd => unreachable!("handled above"), + } + }, } cx.emit(TerminalViewEvent::TitleChanged); cx.notify(); } + /// 旧壳 TurnDone 分支的屏尾提问嗅探:取可视区最后 ≤15 行喂 + /// `tail_looks_like_question`。 + fn screen_tail_asks(&self) -> bool { + let Some(session) = &self.session else { return false }; + let term = session.term.lock(); + let lines = term.screen_lines(); + let take = lines.min(15); + let start = TermPoint::new(Line((lines - take) as i32), Column(0)); + let end = TermPoint::new(Line(lines as i32 - 1), Column(term.columns().saturating_sub(1))); + crate::ai_hook::tail_looks_like_question(&term.bounds_to_string(start, end)) + } + + /// 1 Hz 声明式屏幕看门狗(旧壳 `refresh_agent_screen_states` 的单 pane + /// 版)。Hook 是精确边沿,但边会丢:打断的回合没有 Stop 事件、codex + /// 只有 turn-complete 一种事件。屏幕负责两件事——给无 hook 客户端补位, + /// 以及纠正与可见现场矛盾的僵尸状态: + /// - 可见的 working/blocked chrome 永远可以纠正(现场比旧事件可信); + /// - 空闲提示符降级分档:hook 报过的 Done/Blocked 精确终态不动; + /// Working 连续两拍空闲才按「完成未读」收场(单拍可能是重绘间隙)。 + pub fn refresh_agent_screen_state(&mut self, cx: &mut Context) { + use crate::ai_agents::AgentStatus; + let Some(program) = self.running_program.clone() else { + self.idle_screen_streak = 0; + return; + }; + if crate::ai_agents::AgentKind::parse(&program).is_none() { + return; + } + let Some(session) = &self.session else { return }; + let screen = { + let term = session.term.lock(); + let lines = term.screen_lines(); + if lines == 0 || term.columns() == 0 { + return; + } + let take = lines.min(24); + let start = TermPoint::new(Line((lines - take) as i32), Column(0)); + let end = + TermPoint::new(Line(lines as i32 - 1), Column(term.columns().saturating_sub(1))); + term.bounds_to_string(start, end) + }; + let Some(detection) = crate::ai_agents::detect(&program, &screen) else { return }; + + let next = match detection.status { + AgentStatus::Idle => { + if self.agent_hook_seen + && matches!(self.agent_status, AgentStatus::Done | AgentStatus::Blocked) + { + return; + } + if self.agent_status == AgentStatus::Working { + self.idle_screen_streak = self.idle_screen_streak.saturating_add(1); + if self.idle_screen_streak < 2 { + return; + } + // 连续空闲 = TurnDone 丢了:按「回合完成未读」收场 + // (蓝点),而不是无痕回 Idle——用户没盯着屏幕时这个 + // 结果仍然值得一个标记。 + AgentStatus::Done + } else { + AgentStatus::Idle + } + }, + status @ (AgentStatus::Working | AgentStatus::Blocked) => { + self.idle_screen_streak = 0; + status + }, + AgentStatus::Done | AgentStatus::Unknown => { + self.idle_screen_streak = 0; + return; + }, + }; + if next != self.agent_status { + log::debug!( + "agent screen state: pane={} program={} {:?}->{next:?} rule={}", + self.pane_id, + program, + self.agent_status, + detection.rule_id + ); + self.agent_status = next; + cx.emit(TerminalViewEvent::TitleChanged); + cx.notify(); + } + } + pub fn ai_fork_command(&self) -> Option { let identity = self.ai_session.as_ref()?; crate::ai_agents::AgentKind::parse(&identity.source)?.fork_command(&identity.session_id) } pub fn sidebar_activity(&self) -> SidebarActivity { + use crate::ai_agents::AgentStatus; if self.error.is_some() || self.exited.is_some() || matches!( @@ -814,8 +968,18 @@ impl TerminalView { Some(crate::ssh_session::SshStage::Failed(_)) ) { - SidebarActivity::Failed - } else if self.command_running + return SidebarActivity::Failed; + } + // Agent 会话在场时回合状态是权威:TurnDone 之后 spinner 必须停, + // 不能因为 `running_program` 还亮着(图标要继续显示)就一直转。 + match self.agent_status { + AgentStatus::Working => return SidebarActivity::Running, + AgentStatus::Blocked => return SidebarActivity::Attention, + AgentStatus::Done => return SidebarActivity::Done, + AgentStatus::Idle => return SidebarActivity::Idle, + AgentStatus::Unknown => {}, + } + if self.command_running || self.running_program.is_some() || self.ssh_stage.as_ref().is_some_and(|stage| { !matches!(stage, crate::ssh_session::SshStage::Ready) @@ -1209,10 +1373,10 @@ impl TerminalView { // pane」的 view 释放不掉。 let text = Arc::new(text); let view = cx.entity().downgrade(); - window.open_dialog(cx, move |dialog, _window, _cx| { + window.open_dialog(cx, move |dialog, window, _cx| { let text = text.clone(); let view = view.clone(); - dialog + center_confirm_dialog(dialog, window) .title(title.clone()) .confirm() .button_props( diff --git a/nebula_app/src/gpui_shell/theme.rs b/nebula_app/src/gpui_shell/theme.rs index 11caff7..bae921a 100644 --- a/nebula_app/src/gpui_shell/theme.rs +++ b/nebula_app/src/gpui_shell/theme.rs @@ -41,19 +41,50 @@ pub(crate) fn chrome_theme_resolved(cx: &App) -> NebulaTheme { chrome_theme(effective_theme_name(cx)) } +/// 当前 OS 外观是否为浅色。跟随系统时与旧壳 `WinitTheme::Light` 同一判定。 +pub(crate) fn system_is_light(cx: &App) -> bool { + matches!( + cx.window_appearance(), + gpui::WindowAppearance::Light | gpui::WindowAppearance::VibrantLight + ) +} + +/// 把用户点选的主题家族折算成此刻该显示的成员。 +/// `follow_system` 关闭时原样返回 preference;开启时走 +/// [`NebulaTheme::for_system_appearance`]。 +pub(crate) fn resolve_theme_name( + preference: ThemeName, + follow_system: bool, + system_is_light: bool, +) -> ThemeName { + if !follow_system { + return preference; + } + settings_theme_name(chrome_theme(preference).for_system_appearance(system_is_light)) +} + /// 生效主题:`follow_system_theme` 开启时按系统外观折算到用户主题家族的 /// 亮/暗成员(规则与旧壳 `NebulaTheme::for_system_appearance` 同一来源)。 /// chrome 令牌与终端 palette 都必须走这里,两层才不会分家。 pub fn effective_theme_name(cx: &App) -> ThemeName { let rt = nebula_settings::RuntimeSettings::load(); - if !rt.follow_system_theme { - return rt.theme; - } - let is_light = matches!( - cx.window_appearance(), - gpui::WindowAppearance::Light | gpui::WindowAppearance::VibrantLight - ); - settings_theme_name(chrome_theme(rt.theme).for_system_appearance(is_light)) + resolve_theme_name(rt.theme, rt.follow_system_theme, system_is_light(cx)) +} + +/// 点选一张主题卡时要写盘的键:对齐旧壳 `select_nebula_theme` +/// (关掉跟随系统)+ `apply_nebula_theme`(底色换成该主题 `term_bg`)。 +pub(crate) fn theme_card_persist_updates(name: ThemeName) -> [(&'static str, String); 3] { + [ + ("theme", name.prompt_name().to_owned()), + ("follow_system_theme", "0".to_owned()), + ("background", nebula_settings::format_hex_rgb(name.term_theme().background)), + ] +} + +/// 当前生效主题的终端底色(给「跟随主题」取色器同步用,不是壳色)。 +pub(crate) fn theme_term_background(cx: &App) -> Hsla { + let c = chrome_theme(effective_theme_name(cx)).palette().term_bg; + to_hsla(c.r, c.g, c.b) } fn to_hsla(r: u8, g: u8, b: u8) -> Hsla { @@ -425,3 +456,36 @@ fn apply_skin_tokens(chrome: NebulaTheme, cx: &mut App) { theme.radius = px(crate::display::UI_CORNER_RADIUS_LOGICAL); theme.radius_lg = px(12.0); } + +#[cfg(test)] +mod tests { + use super::{resolve_theme_name, theme_card_persist_updates}; + use nebula_settings::ThemeName; + + #[test] + fn follow_system_remaps_theme_family_and_manual_mode_keeps_preference() { + assert_eq!( + resolve_theme_name(ThemeName::SilverLight, true, false), + ThemeName::SteelDark + ); + assert_eq!( + resolve_theme_name(ThemeName::Nebula, true, true), + ThemeName::SilverLight + ); + assert_eq!( + resolve_theme_name(ThemeName::SilverLight, false, false), + ThemeName::SilverLight + ); + } + + #[test] + fn picking_a_theme_card_disables_follow_system_and_writes_that_theme_background() { + let updates = theme_card_persist_updates(ThemeName::LinenLight); + assert_eq!(updates[0], ("theme", "LinenLight".to_owned())); + assert_eq!(updates[1], ("follow_system_theme", "0".to_owned())); + assert_eq!( + updates[2], + ("background", nebula_settings::format_hex_rgb(ThemeName::LinenLight.term_theme().background)) + ); + } +} diff --git a/nebula_app/src/gpui_shell/widgets.rs b/nebula_app/src/gpui_shell/widgets.rs new file mode 100644 index 0000000..c8d20df --- /dev/null +++ b/nebula_app/src/gpui_shell/widgets.rs @@ -0,0 +1,448 @@ +//! 旧壳设置控件的 GPUI 形态。 +//! +//! - [`NebulaSwitch`]:液态胶囊开关。几何、配色与动画通道逐项对照旧壳 +//! `display/ui/widgets.rs::push_toggle`(HTML 参考的 +//! `transition: all 0.3s ease`)。组件库 `Switch` 是 0.15s 线性、蓝底白点 +//! 的另一套语言,与旧壳中性胶囊在同一页面上会打架。 +//! - [`NebulaButton`]:设置行动作按钮。几何对照 `row_action_rect` / HTML +//! `.bt`,hover 色过渡对照 `.bt { transition: all .13s }`。组件库 `Button` +//! 的 `.hover()` / `.active()` 是瞬时换色,没有这段动效。 + +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + Animation, AnimationExt as _, App, ClickEvent, ElementId, InteractiveElement as _, + IntoElement, ParentElement as _, RenderImage, RenderOnce, SharedString, + StatefulInteractiveElement as _, Styled as _, Window, div, ease_in_out, px, +}; +use image::Frame; + +use crate::renderer::ui::Rgba; + +/// Shell 的彩色品牌图标(`extra/shell-icons` 的 PNG)预缩放成与物理像素 +/// 一一对应的纹理。设置页的默认 Shell 下拉与新建终端的 Shell 选择弹窗 +/// 共用同一份资产和同一条缩放路径——把 128px 原图交给 GPUI 每帧缩小会糊 +/// 边,两处各写一遍缩放又迟早缩出两种观感。 +/// +/// `logical_size` 是控件里图标的逻辑边长,`scale_factor` 是窗口 DPI 缩放。 +/// 返回 `None` 表示该 id 没有品牌资产,调用方保留自己的字形回落。 +pub fn shell_brand_image( + id: &str, + logical_size: f32, + scale_factor: f32, +) -> Option> { + let bytes = crate::shell_detect::color_icon_png(id)?; + let source = image::load_from_memory(bytes).ok()?.into_rgba8(); + let (width, height) = source.dimensions(); + let target_size = (logical_size * scale_factor).round().max(1.0) as u32; + let (mut rgba, width, height) = + crate::display::prepare_ai_logo_texture(source.as_raw(), width, height, target_size); + // GPUI RenderImage 使用 BGRA;预缩放必须在 RGBA 上完成,换序放在最后, + // 否则 Lanczos 会把红蓝通道按错误语义混合。 + for pixel in rgba.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + let rgba = image::RgbaImage::from_raw(width, height, rgba)?; + Some(Arc::new(RenderImage::new([Frame::new(rgba)]))) +} + +const TRACK_W: f32 = 48.0; +const TRACK_H: f32 = 26.0; +const KNOB: f32 = 20.0; +const INSET: f32 = 2.0; +const TRAVEL: f32 = 24.0; +const STRETCH: f32 = 8.0; +const DURATION: Duration = Duration::from_millis(300); + +/// 旧壳设置行的动画开关(液态胶囊)。 +/// +/// - 胶囊 48×26(圆角 13),knob 20×20、inset 2,行程 24px; +/// - 按住时 knob 拉伸 +8px(开态从右缘回弹,旧壳「liquid recoil」); +/// - 状态切换 = 0.3s ease 同步过渡位置与颜色(border/track/knob 三通道)。 +#[derive(IntoElement)] +pub struct NebulaSwitch { + key: SharedString, + checked: bool, + disabled: bool, + on_click: Option>, +} + +impl NebulaSwitch { + pub fn new(key: impl Into) -> Self { + Self { key: key.into(), checked: false, disabled: false, on_click: None } + } + + pub fn checked(mut self, checked: bool) -> Self { + self.checked = checked; + self + } + + #[allow(dead_code)] + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn on_click(mut self, handler: F) -> Self + where + F: Fn(&bool, &mut Window, &mut App) + 'static, + { + self.on_click = Some(Rc::new(handler)); + self + } +} + +/// u8 通道混色(旧壳 `widgets::mix_color` 的语义),返回 gpui 颜色。 +fn mix(start: Rgba, end: Rgba, t: f32) -> gpui::Rgba { + let t = t.clamp(0.0, 1.0); + let channel = |a: u8, b: u8| (f32::from(a) + (f32::from(b) - f32::from(a)) * t) / 255.0; + gpui::Rgba { + r: channel(start.r, end.r), + g: channel(start.g, end.g), + b: channel(start.b, end.b), + a: channel(start.a, end.a), + } +} + +/// `overlay`(带 alpha)盖到 `base`(不透明)上(旧壳 `surface::over` 语义)。 +fn over(overlay: Rgba, base: gpui::Rgba) -> gpui::Rgba { + let alpha = f32::from(overlay.a) / 255.0; + let channel = |o: u8, b: f32| b + (f32::from(o) / 255.0 - b) * alpha; + gpui::Rgba { + r: channel(overlay.r, base.r), + g: channel(overlay.g, base.g), + b: channel(overlay.b, base.b), + a: base.a, + } +} + +fn over_rgba(overlay: Rgba, base: Rgba) -> Rgba { + let alpha = f32::from(overlay.a) / 255.0; + let channel = |o: u8, b: u8| { + (f32::from(b) + (f32::from(o) - f32::from(b)) * alpha).round().clamp(0.0, 255.0) as u8 + }; + Rgba::new(channel(overlay.r, base.r), channel(overlay.g, base.g), channel(overlay.b, base.b), base.a) +} + +impl RenderOnce for NebulaSwitch { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let sk = crate::gpui_shell::theme::chrome_theme_resolved(cx).skin(); + let checked = self.checked; + let disabled = self.disabled; + + // 动画驱动(组件库 Switch 同款结构):keyed state 记录「上一次画的 + // 状态」,与本次 checked 不同才播 0.3s 过渡,结束后把状态落下来。 + let settled_id = ElementId::Name(format!("nebula-switch-settled-{}", self.key).into()); + let settled = window.use_keyed_state(settled_id, cx, |_, _| checked); + let animate = !disabled && *settled.read(cx) != checked; + if animate { + let settled = settled.clone(); + cx.spawn(async move |cx| { + cx.background_executor().timer(DURATION).await; + let _ = settled.update(cx, |state, _| *state = checked); + }) + .detach(); + } + + // 按住拉伸:knob +8px;开态按住时从右缘回弹(x 左移补偿)。 + let pressed_id = ElementId::Name(format!("nebula-switch-pressed-{}", self.key).into()); + let pressed = window.use_keyed_state(pressed_id, cx, |_, _| false); + let is_pressed = !disabled && *pressed.read(cx); + + let hover_ink = Rgba::new(sk.icon_hover.r, sk.icon_hover.g, sk.icon_hover.b, 12); + let track_of = move |t: f32| mix(sk.toggle_track_off, sk.toggle_track_on, t); + let border_of = move |t: f32| mix(sk.toggle_border_off, sk.toggle_border_on, t); + let knob_color_of = move |t: f32| mix(sk.knob_off, sk.knob_on, t); + let knob_w = if is_pressed { KNOB + STRETCH } else { KNOB }; + let knob_x_of = move |t: f32| { + let x = INSET + TRAVEL * t; + // 开态按住:宽度向左生长,右缘保持贴住(旧壳 recoil 语义)。 + if is_pressed && t > 0.5 { x - STRETCH } else { x } + }; + + let knob = div() + .absolute() + .top(px((TRACK_H - 2.0 * INSET - KNOB) / 2.0)) + .w(px(knob_w)) + .h(px(KNOB)) + .rounded(px(KNOB / 2.0)); + let knob = if animate { + knob.with_animation( + ElementId::NamedInteger( + format!("nebula-switch-knob-{}", self.key).into(), + checked as u64, + ), + Animation::new(DURATION).with_easing(ease_in_out), + move |knob, delta| { + let t = if checked { delta } else { 1.0 - delta }; + knob.left(px(knob_x_of(t))).bg(knob_color_of(t)) + }, + ) + .into_any_element() + } else { + let t = if checked { 1.0 } else { 0.0 }; + knob.left(px(knob_x_of(t))).bg(knob_color_of(t)).into_any_element() + }; + + // 胶囊本体:动画帧由 with_animation 重染 bg/border;静止帧直接给 + // settled 色。命中与手势挂在外层 stateful div 上,动画期间照常可点。 + let track = div() + .relative() + .w(px(TRACK_W)) + .h(px(TRACK_H)) + .rounded(px(TRACK_H / 2.0)) + .border_1() + .child(knob); + let track = if animate { + track + .with_animation( + ElementId::NamedInteger( + format!("nebula-switch-track-{}", self.key).into(), + checked as u64, + ), + Animation::new(DURATION).with_easing(ease_in_out), + move |track, delta| { + let t = if checked { delta } else { 1.0 - delta }; + track.bg(track_of(t)).border_color(border_of(t)) + }, + ) + .into_any_element() + } else { + let t = if checked { 1.0 } else { 0.0 }; + track + .bg(track_of(t)) + .border_color(border_of(t)) + // hover 水洗(旧壳 border 提亮 + track 12/255 水洗的简化)。 + .hover(move |style| style.bg(over(hover_ink, track_of(t)))) + .into_any_element() + }; + + div() + .id(ElementId::Name(format!("nebula-switch-{}", self.key).into())) + .flex_shrink_0() + .rounded(px(TRACK_H / 2.0)) + .cursor_pointer() + .child(track) + .when(!disabled, |wrapper| { + let pressed_down = pressed.clone(); + let pressed_up = pressed; + wrapper + .on_mouse_down(gpui::MouseButton::Left, move |_, _, cx| { + cx.stop_propagation(); + let _ = pressed_down.update(cx, |state, _| *state = true); + }) + .on_mouse_up(gpui::MouseButton::Left, move |_, _, cx| { + let _ = pressed_up.update(cx, |state, _| *state = false); + }) + .when_some(self.on_click, |wrapper, on_click| { + wrapper.on_click(move |_, window, cx| { + cx.stop_propagation(); + on_click(&!checked, window, cx); + }) + }) + }) + } +} + +const BUTTON_H: f32 = 30.0; +const BUTTON_PX: f32 = 13.0; +const BUTTON_DURATION: Duration = Duration::from_millis(130); + +/// 旧壳设置行的文字按钮。 +/// +/// 对照两处权威,不对照组件库 `Button`: +/// - 几何:旧壳 `row_action_rect`(高 30)+ HTML `.bt`(`padding: 0 13px`, +/// 圆角走 chrome `UI_CORNER_RADIUS_LOGICAL`); +/// - 动效:HTML `.bt { transition: all .13s }`,hover 只插值底/边/字色, +/// 不改尺寸(旧壳 `push_button_frame`:「hover 只改亮度,不改尺寸」); +/// - 配色:默认态 = `action_button` 的 `sk.surface` → `sk.hover`;描边态 = +/// `push_outline_button` 的发丝 + panel;主按钮 = accent 实心再按亮度抬。 +#[derive(IntoElement)] +pub struct NebulaButton { + key: SharedString, + label: SharedString, + kind: NebulaButtonKind, + disabled: bool, + on_click: Option>, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum NebulaButtonKind { + Default, + Primary, + Outline, + Danger, +} + +impl NebulaButton { + pub fn new(key: impl Into) -> Self { + Self { + key: key.into(), + label: SharedString::default(), + kind: NebulaButtonKind::Default, + disabled: false, + on_click: None, + } + } + + pub fn label(mut self, label: impl Into) -> Self { + self.label = label.into(); + self + } + + pub fn primary(mut self) -> Self { + self.kind = NebulaButtonKind::Primary; + self + } + + pub fn outline(mut self) -> Self { + self.kind = NebulaButtonKind::Outline; + self + } + + pub fn danger(mut self) -> Self { + self.kind = NebulaButtonKind::Danger; + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn on_click(mut self, handler: F) -> Self + where + F: Fn(&ClickEvent, &mut Window, &mut App) + 'static, + { + self.on_click = Some(Rc::new(handler)); + self + } +} + +fn rgb_rgba(c: crate::display::color::Rgb) -> Rgba { + Rgba::new(c.r, c.g, c.b, 255) +} + +fn mix_rgba(start: Rgba, end: Rgba, t: f32) -> Rgba { + let t = t.clamp(0.0, 1.0); + let channel = |a: u8, b: u8| (f32::from(a) + (f32::from(b) - f32::from(a)) * t).round() as u8; + Rgba::new( + channel(start.r, end.r), + channel(start.g, end.g), + channel(start.b, end.b), + channel(start.a, end.a), + ) +} + +fn button_palette( + kind: NebulaButtonKind, + sk: &crate::display::ui::theme::Skin, +) -> (Rgba, Rgba, Rgba, Rgba, Rgba, Rgba) { + // (bg0, bg1, border0, border1, fg0, fg1) + let ink = rgb_rgba(sk.ink); + let ink_dim = rgb_rgba(sk.ink_dim); + let transparent = Rgba::new(0, 0, 0, 0); + let hover_line = mix_rgba(sk.hairline, rgb_rgba(sk.icon_hover), 0.14); + match kind { + NebulaButtonKind::Default => (sk.surface, sk.hover, sk.hairline, hover_line, ink_dim, ink), + NebulaButtonKind::Outline => (sk.panel, sk.hover, sk.hairline, hover_line, ink_dim, ink), + NebulaButtonKind::Primary => { + let rest = Rgba::opaque(sk.accent); + let wash = + if sk.is_light { Rgba::new(0, 0, 0, 20) } else { Rgba::new(255, 255, 255, 26) }; + let hover = over_rgba(wash, rest); + let on_accent = rgb_rgba(sk.ink_on_accent); + (rest, hover, transparent, transparent, on_accent, on_accent) + } + NebulaButtonKind::Danger => { + let wash = Rgba::new(sk.danger.r, sk.danger.g, sk.danger.b, 28); + let line = Rgba::new(sk.danger.r, sk.danger.g, sk.danger.b, 90); + (transparent, wash, transparent, line, sk.danger, sk.danger) + } + } +} + +impl RenderOnce for NebulaButton { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let sk = crate::gpui_shell::theme::chrome_theme_resolved(cx).skin(); + let disabled = self.disabled; + let kind = self.kind; + let (bg0, bg1, border0, border1, fg0, fg1) = button_palette(kind, &sk); + let stroked = !matches!(kind, NebulaButtonKind::Primary); + + let hover_id = ElementId::Name(format!("nebula-btn-hover-{}", self.key).into()); + let hover = window.use_keyed_state(hover_id, cx, |_, _| false); + let hovered = !disabled && *hover.read(cx); + + let settled_id = ElementId::Name(format!("nebula-btn-settled-{}", self.key).into()); + let settled = window.use_keyed_state(settled_id, cx, |_, _| false); + let animate = !disabled && *settled.read(cx) != hovered; + if animate { + let settled = settled.clone(); + cx.spawn(async move |cx| { + cx.background_executor().timer(BUTTON_DURATION).await; + let _ = settled.update(cx, |state, _| *state = hovered); + }) + .detach(); + } + + let bg_of = move |t: f32| mix(bg0, bg1, t); + let border_of = move |t: f32| mix(border0, border1, t); + let fg_of = move |t: f32| mix(fg0, fg1, t); + + let face = div() + .h(px(BUTTON_H)) + .px(px(BUTTON_PX)) + .flex() + .items_center() + .justify_center() + .flex_shrink_0() + .rounded(px(crate::display::UI_CORNER_RADIUS_LOGICAL)) + .when(stroked, |face| face.border_1()) + .child(self.label); + let face = if animate { + face.with_animation( + ElementId::NamedInteger( + format!("nebula-btn-face-{}", self.key).into(), + hovered as u64, + ), + Animation::new(BUTTON_DURATION).with_easing(ease_in_out), + move |face, delta| { + let t = if hovered { delta } else { 1.0 - delta }; + face.bg(bg_of(t)).border_color(border_of(t)).text_color(fg_of(t)) + }, + ) + .into_any_element() + } else { + let t = if hovered { 1.0 } else { 0.0 }; + face.bg(bg_of(t)) + .border_color(border_of(t)) + .text_color(fg_of(t)) + .into_any_element() + }; + + div() + .id(ElementId::Name(format!("nebula-btn-{}", self.key).into())) + .flex_shrink_0() + .rounded(px(crate::display::UI_CORNER_RADIUS_LOGICAL)) + .when(disabled, |wrapper| wrapper.opacity(0.45).cursor_default()) + .when(!disabled, |wrapper| wrapper.cursor_pointer()) + .child(face) + .when(!disabled, |wrapper| { + let hover_enter = hover.clone(); + wrapper + .on_hover(move |hovered, _, cx| { + let _ = hover_enter.update(cx, |state, _| *state = *hovered); + }) + .when_some(self.on_click, |wrapper, on_click| { + wrapper.on_click(move |event, window, cx| { + cx.stop_propagation(); + on_click(event, window, cx); + }) + }) + }) + } +} diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index 86cfbca..3664ccc 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -52,6 +52,7 @@ gpui::actions!( OpenSettings, ToggleCommandPalette, CloseCommandPalette, + ToggleShellPicker, CommandPaletteUp, CommandPaletteDown, ToggleFileTree, @@ -66,6 +67,10 @@ gpui::actions!( ] ); +/// 命令面板 / Shell 选择器罩层的 keymap context。Esc 必须挂在这里, +/// 不能挂全局:否则 CC/Codex 的终止对话键到不了 PTY。 +const PALETTE_KEY_CONTEXT: &str = "NebulaCommandPalette"; + /// 工作区静态默认绑定的 combo 集([`init`] 的镜像)。撤销已失效的自定义 /// 注入时要排除:gpui 的 NoAction 打在静态默认键上会误杀基础功能。 const STATIC_DEFAULT_COMBOS: &[&str] = &[ @@ -74,6 +79,7 @@ const STATIC_DEFAULT_COMBOS: &[&str] = &[ "ctrl-shift-b", "ctrl-,", "ctrl-shift-p", + "ctrl-k", "ctrl-shift-f", "escape", "ctrl-shift-d", @@ -96,6 +102,7 @@ fn custom_workspace_binding(combo: &str, action: &crate::config::Action) -> Opti Action::ToggleCommandPalette => { Some(KeyBinding::new(&combo, ToggleCommandPalette, None)) }, + Action::ToggleShellPicker => Some(KeyBinding::new(&combo, ToggleShellPicker, None)), Action::CreateNewTab => Some(KeyBinding::new(&combo, NewTerminal, None)), Action::CloseTab => Some(KeyBinding::new(&combo, CloseActiveTerminal, None)), Action::ToggleFilesPanel => Some(KeyBinding::new(&combo, ToggleFileTree, None)), @@ -128,8 +135,11 @@ pub fn init(cx: &mut App) { KeyBinding::new("ctrl-shift-b", ToggleSidebar, None), KeyBinding::new("ctrl-,", OpenSettings, None), KeyBinding::new("ctrl-shift-p", ToggleCommandPalette, None), + KeyBinding::new("ctrl-k", ToggleShellPicker, None), KeyBinding::new("ctrl-shift-f", ToggleFileTree, None), - KeyBinding::new("escape", CloseCommandPalette, None), + // Esc 只在命令/Shell 面板打开时关面板。绑成 `None` 会在终端聚焦时 + // 抢走按键,CC/Codex 收不到 0x1b(旧壳无 overlay 时 Esc 一定进 PTY)。 + KeyBinding::new("escape", CloseCommandPalette, Some(PALETTE_KEY_CONTEXT)), // 分屏(旧壳 nebula_key_bindings 同键位):ctrl+shift+d 左右、 // ctrl+shift+s 上下、ctrl+shift+enter 缩放、ctrl+alt+方向切聚焦。 KeyBinding::new("ctrl-shift-d", SplitRight, None), @@ -176,6 +186,10 @@ enum WorkspaceTab { Document { view: Entity, }, + /// 源码查看 tab(tree-sitter 高亮 + 行级虚拟化,只读)。 + Code { + view: Entity, + }, } impl WorkspaceTab { @@ -340,6 +354,15 @@ const SIDE_PANEL_SLOT_W: f32 = 328.0; /// "删除按钮偏移了")。 const TAB_CLOSE_SIZE: f32 = 20.0; +/// TABS 标题行右侧 `+` / `⋯`:命中区对照旧壳 `chrome.rs` 的 `plus_sz = 20`, +/// 中间 `s(2)`。旧壳三点只占 15 宽,是因为它画的是瘦 chevron;竖三点要 +/// 方格才不被裁。Lucide viewBox 有内边距,图标铺满 20px 命中区,笔画才 +/// 接近旧壳 `push_add` 的 6px 臂 / `push_more` 的 2.8px 点;`Button::xsmall()` +/// 里图标只有 12px,看起来会小一圈。 +const SIDEBAR_PLUS_SIZE: f32 = 20.0; +const SIDEBAR_MENU_W: f32 = 20.0; +const SIDEBAR_HEADER_ICON: f32 = 18.0; + /// 侧栏文字的字号档位,**乘在终端字号上**(旧壳 chrome 同源):标签行走 /// BODY(1.0)——旧壳 `draw_ui_text_tracked(.., 1.0, ..)`,分组标题与右侧 /// shell 短标各压一档。硬编码 `text_sm`(14px) 时侧栏比旧壳整体小一号, @@ -386,6 +409,13 @@ enum WorkspacePaletteAction { }, /// 启动器混排的 SSH 主机行(数据源 = 共享主机列表权威)。 LaunchSshHost(String), + /// 新建终端弹窗里的一台已检测 shell(旧壳 `ProfileRow::Shell`)。 + /// + /// 旧壳这份菜单还会混入 `nebula.toml` 的 config profiles,GPUI 壳目前 + /// 整体没有消费 profiles(设置页的「配置文件」分区也只有终端与补全 + /// 设置),所以这里只列检测到的 shell——旧壳注释同样承认「没有 profile + /// 时检测结果本身就能填满菜单」。 + LaunchShell(crate::shell_detect::DetectedShell), } #[derive(Clone)] @@ -396,6 +426,9 @@ struct WorkspacePaletteRow { hint: String, search: String, action: WorkspacePaletteAction, + /// 行首的彩色品牌图标(只有 shell/配置档行有)。旧壳的 shell 菜单同样 + /// 是「图标 + 名字 + 灰色命令行」三段式,光靠文字分不清 pwsh 与 5.1。 + icon: Option>, } /// 精确 pane id 必须严格路由:已关闭 pane 的迟到事件不能污染当前活跃 @@ -430,6 +463,15 @@ fn open_in_file_manager(path: &Path) { let _ = std::process::Command::new("xdg-open").arg(path).spawn(); } +fn workspace_ui_language() -> crate::display::UiLanguage { + match nebula_settings::RuntimeSettings::load().language { + nebula_settings::LanguagePref::System => crate::display::LanguagePreference::System, + nebula_settings::LanguagePref::ZhCn => crate::display::LanguagePreference::ZhCn, + nebula_settings::LanguagePref::EnUs => crate::display::LanguagePreference::EnUs, + } + .resolved() +} + fn new_tab_insert_index( position: nebula_settings::NewTabPositionName, active: usize, @@ -469,6 +511,7 @@ fn ai_session_palette_rows( hint: format!("恢复 · {source} · {location}"), search: format!("恢复 resume {search}"), action: WorkspacePaletteAction::RunAiSession { command, cwd: cwd.clone() }, + icon: None, }); } if let Some(command) = session.fork_command() { @@ -479,12 +522,66 @@ fn ai_session_palette_rows( hint: format!("{source} · {location}"), search: format!("分叉 fork {search}"), action: WorkspacePaletteAction::RunAiSession { command, cwd: cwd.clone() }, + icon: None, }); } } rows } +/// 新建终端弹窗的行:已检测 shell + SSH 主机,分组对照旧壳 +/// `CommandPalette::open_profiles`(推荐 / 所有 Shell / SSH 主机)。 +/// 三点菜单与 Ctrl+K 打开的是这份列表,不是通用命令面板。 +fn shell_palette_rows( + shells: Vec, + ssh_hosts: impl IntoIterator, + default_shell_id: &str, + language: crate::display::UiLanguage, + scale_factor: f32, +) -> Vec { + const SHELL_ICON_PX: f32 = 22.0; + let recommended = language.pick("推荐", "Recommended"); + let all_shells = language.pick("所有 Shell", "All shells"); + let ssh_group = language.pick("SSH 主机", "SSH hosts"); + let mut rows: Vec = shells + .into_iter() + .map(|shell| { + let is_default = shell.id == default_shell_id; + WorkspacePaletteRow { + group_order: if is_default { 0 } else { 1 }, + group: if is_default { + recommended.to_owned() + } else { + all_shells.to_owned() + }, + label: shell.name.clone(), + hint: shell.program.clone(), + search: format!("{} {} shell profile", shell.name, shell.id).to_lowercase(), + icon: crate::gpui_shell::widgets::shell_brand_image( + &shell.id, + SHELL_ICON_PX, + scale_factor, + ), + action: WorkspacePaletteAction::LaunchShell(shell), + } + }) + .collect(); + if let Some(position) = rows.iter().position(|row| row.group_order == 0) { + let default_row = rows.remove(position); + rows.insert(0, default_row); + } + rows.extend(ssh_hosts.into_iter().map(|host| WorkspacePaletteRow { + group_order: 2, + group: ssh_group.to_owned(), + label: host.clone(), + hint: "SSH".to_owned(), + search: format!("{host} ssh host remote lianjie 连接").to_lowercase(), + action: WorkspacePaletteAction::LaunchSshHost(host), + icon: None, + })); + rows +} + /// 标签的用户可编辑元数据:重命名与色标(旧壳 `TabEntry::custom_name` / /// `custom_color` 的对应物,字段与共享 session v4 的 `TabSession` 同名同义, /// 所以导出/恢复不需要转换层)。 @@ -541,6 +638,9 @@ pub struct NebulaWorkspace { tab_drag: Option, /// 进行中的分隔条拖拽;预览比例写进树节点,松手提交(吸附整格)。 split_drag: Option, + /// 进行中的侧栏拖宽(设置「面板拖拽调节」开启时才有入口);宽度实时 + /// 生效,松手写盘 `sidebar_w`(旧壳同合同)。 + sidebar_resizing: bool, /// Split 节点视口的帧记录(拖拽换算与提交吸附用)。 split_bounds: SplitBoundsStore, /// pane 矩形的帧记录(ctrl+alt+方向 的最近邻导航用)。 @@ -552,7 +652,16 @@ pub struct NebulaWorkspace { /// Git/SVN 提交信息输入(GPUI 输入组件);提交动作直达共享模型 /// `vcs_commit_message`,不经旧壳的内部输入状态机。 git_commit_input: Entity, - ai_session_palette: Option>, + /// Git 树"丢弃改动"的二次确认(路径);任何其他 VCS 操作都清掉它。 + vcs_discard_confirm: Option, + /// 命令面板的行覆盖:`None` = 常规命令目录,`Some` = 某个专用列表 + /// (AI 会话恢复、新建终端的 Shell 选择)。关闭面板时必须清掉, + /// 否则下一次 Ctrl+Shift+P 会打开上一次那份专用列表。 + palette_override: Option>, + /// 三点 / Ctrl+K 打开的是旧壳 `PaletteMode::Profiles`,要画 + /// 全部/SSH/Shell 芯片;Ctrl+Shift+P 的命令目录不走这条。 + shell_picker_open: bool, + launcher_filter: crate::display::command_palette::LauncherFilter, _command_palette_subscription: Subscription, /// Shared old-shell drawer model. GPUI owns only presentation and polling; /// filesystem traversal, expansion state, ignore marking and throttling @@ -668,13 +777,17 @@ impl NebulaWorkspace { initial_grid, tab_drag: None, split_drag: None, + sidebar_resizing: false, split_bounds: Rc::new(RefCell::new(HashMap::new())), pane_bounds: Rc::new(RefCell::new(HashMap::new())), command_palette_open: false, command_palette_input, command_palette_selected: 0, git_commit_input, - ai_session_palette: None, + vcs_discard_confirm: None, + palette_override: None, + shell_picker_open: false, + launcher_filter: crate::display::command_palette::LauncherFilter::All, _command_palette_subscription: command_palette_subscription, side_panel: crate::display::side_panel::SidePanel::new(), side_panel_polling: false, @@ -709,6 +822,7 @@ impl NebulaWorkspace { this.add_terminal(window, cx); } Self::start_ai_hook_pump(ai_events, cx); + Self::start_agent_screen_watchdog(cx); this.start_session_autosave(cx); this.apply_custom_keybinds(cx); let workspace = cx.entity().downgrade(); @@ -899,7 +1013,27 @@ impl NebulaWorkspace { } fn add_terminal(&mut self, window: &mut Window, cx: &mut Context) { - self.add_terminal_at(None, None, window, cx); + // 旧壳合同(window_context `spawn_tab` 一族):新 tab 的 cwd 先取 + // 设置页的「启动目录」(存在且是目录才算数),否则继承聚焦 pane + // 的本地 cwd——`startup_directory=` 因此在两壳有同一效果。 + let cwd = Self::startup_directory().or_else(|| { + self.tabs + .get(self.active) + .and_then(WorkspaceTab::focused_view) + .and_then(|view| view.read(cx).local_cwd()) + }); + self.add_terminal_at(cwd, None, window, cx); + } + + /// 设置页「启动目录」:非空且确实存在的目录才生效(旧壳同判定)。 + fn startup_directory() -> Option { + let dir = nebula_settings::RuntimeSettings::load().startup_directory?; + let dir = dir.trim(); + if dir.is_empty() { + return None; + } + let path = std::path::PathBuf::from(dir); + path.is_dir().then_some(path) } /// 创建一个 pane 实体(分配 id、spawn 会话、挂宿主订阅);调用方决定 @@ -947,6 +1081,19 @@ impl NebulaWorkspace { // 一起冻结进 Tab launch。设置页随后改默认值只影响下一次创建;冷 // 恢复也按本 Tab 的 launch 重建,不会把混合工作区抹成同一种 shell。 let launch_session = Self::configured_local_launch(cx); + self.add_terminal_with(launch_session, cwd, command, window, cx); + } + + /// 用一份指定的 launch 身份建 Tab。默认路径(`add_terminal_at`)冻结 + /// 设置里的默认 shell;Shell 选择弹窗则把用户挑中的那台传进来。 + fn add_terminal_with( + &mut self, + launch_session: crate::session::LaunchSession, + cwd: Option, + command: Option, + window: &mut Window, + cx: &mut Context, + ) { let shell_tag = Self::launch_shell_tag(&launch_session); let grid = self.inherited_grid(cx); let launch = Self::terminal_launch_from_session(&launch_session, cwd); @@ -1146,10 +1293,10 @@ impl NebulaWorkspace { let body: SharedString = format!("{process} 仍在运行,关闭窗口会中止它。").into(); let confirm_workspace = cx.entity().downgrade(); let close_workspace = confirm_workspace.clone(); - window.open_dialog(cx, move |dialog, _window, _cx| { + window.open_dialog(cx, move |dialog, window, _cx| { let confirm_workspace = confirm_workspace.clone(); let close_workspace = close_workspace.clone(); - dialog + center_confirm_dialog(dialog, window) .title("关闭窗口?") .confirm() .button_props( @@ -1192,9 +1339,9 @@ impl NebulaWorkspace { }; let body: SharedString = format!("{process} 仍在运行,关闭会中止它。").into(); let workspace = cx.entity().downgrade(); - window.open_dialog(cx, move |dialog, _window, _cx| { + window.open_dialog(cx, move |dialog, window, _cx| { let workspace = workspace.clone(); - dialog + center_confirm_dialog(dialog, window) .title("关闭此分栏?") .confirm() .button_props( @@ -1225,9 +1372,9 @@ impl NebulaWorkspace { }; let body: SharedString = format!("{process} 仍在运行,关闭会中止它。").into(); let workspace = cx.entity().downgrade(); - window.open_dialog(cx, move |dialog, _window, _cx| { + window.open_dialog(cx, move |dialog, window, _cx| { let workspace = workspace.clone(); - dialog + center_confirm_dialog(dialog, window) .title("关闭此标签页?") .confirm() .button_props( @@ -1548,6 +1695,37 @@ impl NebulaWorkspace { .detach(); } + /// 1 Hz 遍历所有终端 pane 跑屏幕看门狗(旧壳 `refresh_agent_screen_states` + /// 的调度对应物):纠正丢边的 hook 状态、给无 hook 客户端补位。非 + /// agent pane 在 view 侧一行判断就退出,代价可忽略。 + fn start_agent_screen_watchdog(cx: &mut Context) { + let executor = cx.background_executor().clone(); + cx.spawn(async move |this, cx| { + loop { + executor.timer(Duration::from_millis(1000)).await; + let alive = this.update(cx, |workspace, cx| { + let views: Vec<_> = workspace + .tabs + .iter() + .filter_map(|tab| match tab { + WorkspaceTab::Terminal { panes, .. } => Some(panes.iter()), + _ => None, + }) + .flatten() + .map(|pane| pane.view.clone()) + .collect(); + for view in views { + view.update(cx, |view, cx| view.refresh_agent_screen_state(cx)); + } + }); + if alive.is_err() { + return; + } + } + }) + .detach(); + } + fn handle_ai_hook(&mut self, event: crate::ai_hook::AiHookEvent, cx: &mut Context) { let pane_ids: Vec = self .tabs @@ -1577,30 +1755,93 @@ impl NebulaWorkspace { } } + /// 在新 tab 里继续一段进行中的 AI 会话(旧壳 `fork_ai_session` 合同): + /// 不克隆 PTY,而是按源 tab 的 shell 重开一个终端,把官方 fork 命令敲进 + /// 去。SSH tab 不分叉——往认证提示里注入命令会打错协议层;新 tab 继承 + /// 源 tab 的 cwd 与色标,命名「{Agent} 分叉」。 fn fork_ai_session(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { let Some(view) = self.tabs.get(ix).and_then(WorkspaceTab::focused_view) else { return }; - let (command, cwd) = { + let (command, cwd, agent) = { let view = view.read(cx); let Some(command) = view.ai_fork_command() else { return }; - (command, view.local_cwd()) + let agent = view + .ai_session + .as_ref() + .and_then(|identity| crate::ai_agents::AgentKind::parse(&identity.source)); + (command, view.local_cwd(), agent) }; + let launch_session = match self.meta(ix).launch { + // Default 与旧壳一致取「分叉这一刻」的默认 shell,不是源 tab + // 创建时的快照。 + None | Some(crate::session::LaunchSession::Default) => { + Self::configured_local_launch(cx) + }, + Some(shell @ crate::session::LaunchSession::Shell { .. }) => shell, + // Profile 可能直接把 agent 当启动命令,SSH 会把命令注入认证 + // 提示——两者都不分叉(旧壳同合同)。 + Some( + crate::session::LaunchSession::Profile { .. } + | crate::session::LaunchSession::Ssh { .. }, + ) => return, + }; + let color = self.meta(ix).color; self.activate_tab(ix, window, cx); - self.add_terminal_at(cwd, Some(command), window, cx); + + let shell_tag = Self::launch_shell_tag(&launch_session); + let grid = self.inherited_grid(cx); + let launch = Self::terminal_launch_from_session(&launch_session, cwd); + let pane = self.new_pane(grid, launch, Some(command), window, cx); + let focused = pane.id; + let tab = WorkspaceTab::Terminal { + tree: SplitTree::leaf(pane.id), + panes: vec![pane], + focused, + zoomed: false, + }; + let position = nebula_settings::RuntimeSettings::load().new_tab_position; + let at = new_tab_insert_index(position, self.active, self.tabs.len()); + self.insert_tab_at( + at, + tab, + TabMeta { + custom_name: agent.map(|agent| format!("{} 分叉", agent.display_name())), + color, + shell_tag, + launch: Some(launch_session), + }, + ); + self.active = at; + self.focus_active(window, cx); + cx.notify(); } /// 设置页是单例 tab(旧壳同形态):已开则激活,未开则新建。 fn open_settings(&mut self, window: &mut Window, cx: &mut Context) { + // TODO(debug-layout): 临时诊断打点,定位设置页打不开的回归后删除。 + eprintln!("[nebula:gpui] open_settings invoked"); if let Some(ix) = self.tabs.iter().position(WorkspaceTab::is_settings) { self.activate_tab(ix, window, cx); return; } let view = cx.new(|cx| SettingsPane::new(window, cx)); + eprintln!("[nebula:gpui] SettingsPane constructed"); let subscription = cx.subscribe_in(&view, window, Self::on_settings_event); self.insert_new_tab(WorkspaceTab::Settings { view, _subscription: subscription }); self.focus_active(window, cx); cx.notify(); } + /// 调试/验收后门:`NEBULA_GPUI_OPEN_DOC=路径` 时启动即打开该文档, + /// 与文件树双击同一条路由(公式渲染等文档 UI 的免点击验收)。 + pub fn open_document_at_startup( + &mut self, + path: std::path::PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + self.open_document_path(path, window, cx); + } + /// 文件路由(旧壳 `input/chrome.rs` 双击合同):图片 → 图片 tab; /// 可读文本 → 文档 tab;其余交系统处理器。 fn open_document_path( @@ -1613,6 +1854,8 @@ impl NebulaWorkspace { self.open_image_tab(path, window, cx); } else if crate::display::markdown_view::viewable_file(&path) { self.open_doc_tab(path, window, cx); + } else if crate::gpui_shell::code_tab::viewable_file(&path) { + self.open_code_tab(path, window, cx); } else { open_in_file_manager(&path); } @@ -1664,6 +1907,27 @@ impl NebulaWorkspace { cx.notify(); } + fn open_code_tab( + &mut self, + path: std::path::PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(ix) = self.tabs.iter().position(|tab| { + matches!(tab, WorkspaceTab::Code { view } if view.read(cx).path == path) + }) { + if let Some(WorkspaceTab::Code { view }) = self.tabs.get(ix) { + view.clone().update(cx, |view, cx| view.reload(window, cx)); + } + self.activate_tab(ix, window, cx); + return; + } + let view = cx.new(|cx| crate::gpui_shell::code_tab::CodeTabView::new(path, window, cx)); + self.insert_new_tab(WorkspaceTab::Code { view }); + self.focus_active(window, cx); + cx.notify(); + } + /// 按视图实体反查 (tab 下标, pane id)。 fn locate_pane(&self, entity_id: gpui::EntityId) -> Option<(usize, u64)> { self.tabs.iter().enumerate().find_map(|(ix, tab)| match tab { @@ -1965,8 +2229,13 @@ impl NebulaWorkspace { None => return, }, Some(WorkspaceTab::Settings { view, .. }) => view.read(cx).focus_handle(cx), - // 图片/文档查看 tab 没有键盘焦点语义(滚轮/拖拽直达元素)。 - Some(WorkspaceTab::Image { .. } | WorkspaceTab::Document { .. }) | None => return, + // 图片/文档/代码查看 tab 没有键盘焦点语义(滚轮/拖拽直达元素)。 + Some( + WorkspaceTab::Image { .. } + | WorkspaceTab::Document { .. } + | WorkspaceTab::Code { .. }, + ) + | None => return, }; window.defer(cx, move |window, _| window.focus(&focus)); } @@ -2087,13 +2356,8 @@ impl NebulaWorkspace { let query = self.command_palette_input.read(cx).value().to_ascii_lowercase(); let words: Vec<_> = query.split_whitespace().collect(); let has_local_cwd = self.active_local_cwd(cx).is_some(); - let language = match nebula_settings::RuntimeSettings::load().language { - nebula_settings::LanguagePref::System => crate::display::LanguagePreference::System, - nebula_settings::LanguagePref::ZhCn => crate::display::LanguagePreference::ZhCn, - nebula_settings::LanguagePref::EnUs => crate::display::LanguagePreference::EnUs, - } - .resolved(); - let rows = self.ai_session_palette.clone().unwrap_or_else(|| { + let language = workspace_ui_language(); + let rows = self.palette_override.clone().unwrap_or_else(|| { let mut rows: Vec = crate::display::command_palette::catalog() .iter() .filter(|item| Self::palette_action_available(&item.action, has_local_cwd)) @@ -2112,6 +2376,7 @@ impl NebulaWorkspace { hint: item.hint.to_owned(), search: item.search.to_owned(), action: WorkspacePaletteAction::Shared(item.action.clone()), + icon: None, } }) .collect(); @@ -2126,6 +2391,7 @@ impl NebulaWorkspace { hint: "SSH".to_owned(), search: format!("{host} ssh host remote lianjie 连接").to_lowercase(), action: WorkspacePaletteAction::LaunchSshHost(host), + icon: None, }, ), ); @@ -2134,6 +2400,20 @@ impl NebulaWorkspace { let mut rows: Vec<_> = rows .into_iter() .filter(|row| { + if self.shell_picker_open { + let keep = match self.launcher_filter { + crate::display::command_palette::LauncherFilter::All => true, + crate::display::command_palette::LauncherFilter::Ssh => { + matches!(row.action, WorkspacePaletteAction::LaunchSshHost(_)) + }, + crate::display::command_palette::LauncherFilter::Shell => { + matches!(row.action, WorkspacePaletteAction::LaunchShell(_)) + }, + }; + if !keep { + return false; + } + } words.is_empty() || words.iter().all(|word| row.search.to_ascii_lowercase().contains(word)) }) @@ -2149,7 +2429,9 @@ impl NebulaWorkspace { } self.command_palette_open = true; self.command_palette_selected = 0; - self.ai_session_palette = None; + self.palette_override = None; + self.shell_picker_open = false; + self.launcher_filter = crate::display::command_palette::LauncherFilter::All; self.command_palette_input.update(cx, |input, cx| { input.set_value("", window, cx); input.focus(window, cx); @@ -2157,12 +2439,18 @@ impl NebulaWorkspace { cx.notify(); } + fn dismiss_palette_state(&mut self) { + self.command_palette_open = false; + self.palette_override = None; + self.shell_picker_open = false; + self.launcher_filter = crate::display::command_palette::LauncherFilter::All; + } + fn close_command_palette(&mut self, window: &mut Window, cx: &mut Context) { if !self.command_palette_open { return; } - self.command_palette_open = false; - self.ai_session_palette = None; + self.dismiss_palette_state(); self.focus_active(window, cx); cx.notify(); } @@ -2190,20 +2478,23 @@ impl NebulaWorkspace { match action { WorkspacePaletteAction::Shared(action) => self.run_palette_action(action, window, cx), WorkspacePaletteAction::RunAiSession { command, cwd } => { - self.command_palette_open = false; - self.ai_session_palette = None; + self.dismiss_palette_state(); self.add_terminal_at(cwd, Some(command), window, cx); }, WorkspacePaletteAction::LaunchSshHost(host) => { - self.command_palette_open = false; - self.ai_session_palette = None; + self.dismiss_palette_state(); self.add_ssh_terminal(host, window, cx); }, + WorkspacePaletteAction::LaunchShell(detected) => { + self.launch_palette_shell(detected, window, cx); + }, } } fn open_ai_session_palette(&mut self, window: &mut Window, cx: &mut Context) { - self.ai_session_palette = Some(ai_session_palette_rows(crate::ai_sessions::scan(30))); + self.shell_picker_open = false; + self.launcher_filter = crate::display::command_palette::LauncherFilter::All; + self.palette_override = Some(ai_session_palette_rows(crate::ai_sessions::scan(30))); self.command_palette_selected = 0; self.command_palette_input.update(cx, |input, cx| { input.set_value("", window, cx); @@ -2212,6 +2503,91 @@ impl NebulaWorkspace { cx.notify(); } + /// 三点 / Ctrl+K:旧壳 `NewTabMenu` → `open_shell_menu` → `PaletteMode::Profiles`。 + fn open_shell_palette(&mut self, window: &mut Window, cx: &mut Context) { + let default_shell_id = cx + .try_global::() + .and_then(|settings| settings.shell_id.clone()) + .unwrap_or_else(|| "powershell".to_owned()); + let language = workspace_ui_language(); + let rows = shell_palette_rows( + crate::shell_detect::detect_shells(), + crate::gpui_shell::ssh_hosts::SshHostLists::load().merged(), + &default_shell_id, + language, + window.scale_factor().max(0.5), + ); + self.palette_override = Some(rows); + self.shell_picker_open = true; + self.launcher_filter = crate::display::command_palette::LauncherFilter::All; + self.command_palette_open = true; + self.command_palette_selected = 0; + self.command_palette_input.update(cx, |input, cx| { + input.set_value("", window, cx); + input.focus(window, cx); + }); + cx.notify(); + } + + fn toggle_shell_palette(&mut self, window: &mut Window, cx: &mut Context) { + if self.command_palette_open && self.shell_picker_open { + self.close_command_palette(window, cx); + return; + } + self.open_shell_palette(window, cx); + } + + fn set_launcher_filter( + &mut self, + filter: crate::display::command_palette::LauncherFilter, + cx: &mut Context, + ) { + if !self.shell_picker_open || self.launcher_filter == filter { + return; + } + self.launcher_filter = filter; + self.command_palette_selected = 0; + cx.notify(); + } + + fn launcher_chip_counts(&self) -> [(crate::display::command_palette::LauncherFilter, usize); 3] { + use crate::display::command_palette::LauncherFilter; + let rows = self.palette_override.as_deref().unwrap_or(&[]); + let shell = rows + .iter() + .filter(|row| matches!(row.action, WorkspacePaletteAction::LaunchShell(_))) + .count(); + let ssh = rows + .iter() + .filter(|row| matches!(row.action, WorkspacePaletteAction::LaunchSshHost(_))) + .count(); + [(LauncherFilter::All, shell + ssh), (LauncherFilter::Ssh, ssh), (LauncherFilter::Shell, shell)] + } + + /// 从弹窗选中的 shell 起一个新终端。走共享 v4 launch 身份,因此冷恢复 + /// 拿得回真正的启动命令,侧栏短标也跟着对。 + fn launch_palette_shell( + &mut self, + detected: crate::shell_detect::DetectedShell, + window: &mut Window, + cx: &mut Context, + ) { + self.dismiss_palette_state(); + let shell = detected.shell(); + let launch = crate::session::LaunchSession::Shell { + name: detected.name.clone(), + program: shell.program().to_owned(), + args: shell.args().to_vec(), + }; + let cwd = Self::startup_directory().or_else(|| { + self.tabs + .get(self.active) + .and_then(WorkspaceTab::focused_view) + .and_then(|view| view.read(cx).local_cwd()) + }); + self.add_terminal_with(launch, cwd, None, window, cx); + } + fn run_palette_action( &mut self, action: crate::display::command_palette::PaletteAction, @@ -2224,8 +2600,7 @@ impl NebulaWorkspace { self.open_ai_session_palette(window, cx); return; } - self.command_palette_open = false; - self.ai_session_palette = None; + self.dismiss_palette_state(); match action { PaletteAction::NewTab => self.add_terminal(window, cx), PaletteAction::CopyCwd => { @@ -2301,10 +2676,11 @@ impl NebulaWorkspace { self.focus_active(window, cx); }, PaletteAction::SelectTheme(theme) => { - let _ = nebula_settings::persist_keys(&[ - ("theme", theme.prompt_name().to_owned()), - ("follow_system_theme", "0".to_owned()), - ]); + let name = nebula_settings::ThemeName::from_prompt_name(theme.prompt_name()) + .unwrap_or_default(); + let _ = nebula_settings::persist_keys( + &crate::gpui_shell::theme::theme_card_persist_updates(name), + ); self.apply_runtime_settings(cx); self.focus_active(window, cx); }, @@ -2324,6 +2700,7 @@ impl NebulaWorkspace { WorkspaceTab::Settings { .. } => "设置".into(), WorkspaceTab::Image { view } => view.read(cx).title.clone().into(), WorkspaceTab::Document { view } => view.read(cx).title.clone().into(), + WorkspaceTab::Code { view } => view.read(cx).title.clone().into(), tab @ WorkspaceTab::Terminal { panes, .. } => { // 标签 = 聚焦 pane 的 cwd 末级目录名(旧壳 chrome_tab_label // 规则);分屏时缀 pane 计数,一眼可见这行是一组。 @@ -2374,10 +2751,19 @@ impl NebulaWorkspace { .h(px(36.0)) .w_full() .px_3() + .gap_2() .items_center() .rounded_md() .when(selected, |row| row.bg(selected_bg)) .hover(|row| row.bg(hover_bg)) + // 品牌图标只有 shell 行有;命令行不留空槽,否则整份 + // 命令目录会平白多出一列缩进。 + .when_some(item.icon.clone(), |row, image| { + row.child(gpui::StyledImage::object_fit( + img(image).size(px(22.0)).flex_shrink_0(), + gpui::ObjectFit::Contain, + )) + }) .child(div().flex_1().text_sm().child(item.label)) .when(!item.hint.is_empty(), |row| { row.child(div().text_xs().text_color(muted).child(item.hint)) @@ -2389,15 +2775,16 @@ impl NebulaWorkspace { this.run_palette_action(action, window, cx); }, WorkspacePaletteAction::RunAiSession { command, cwd } => { - this.command_palette_open = false; - this.ai_session_palette = None; + this.dismiss_palette_state(); this.add_terminal_at(cwd, Some(command), window, cx); }, WorkspacePaletteAction::LaunchSshHost(host) => { - this.command_palette_open = false; - this.ai_session_palette = None; + this.dismiss_palette_state(); this.add_ssh_terminal(host, window, cx); }, + WorkspacePaletteAction::LaunchShell(detected) => { + this.launch_palette_shell(detected, window, cx); + }, } })) .into_any_element(), @@ -2408,6 +2795,7 @@ impl NebulaWorkspace { .absolute() .inset_0() .occlude() + .key_context(PALETTE_KEY_CONTEXT) .bg(theme.overlay) .on_mouse_down( MouseButton::Left, @@ -2415,7 +2803,7 @@ impl NebulaWorkspace { this.close_command_palette(window, cx); }), ) - .on_key_down(cx.listener(|this: &mut Self, event: &KeyDownEvent, _, cx| { + .on_key_down(cx.listener(|this: &mut Self, event: &KeyDownEvent, window, cx| { match event.keystroke.key.as_str() { "up" => { this.move_command_palette_selection(-1, cx); @@ -2425,6 +2813,10 @@ impl NebulaWorkspace { this.move_command_palette_selection(1, cx); cx.stop_propagation(); }, + "escape" => { + this.close_command_palette(window, cx); + cx.stop_propagation(); + }, _ => {}, } })) @@ -2451,7 +2843,44 @@ impl NebulaWorkspace { }), ) .child(Input::new(&self.command_palette_input)) - .child(v_flex().max_h(px(430.0)).overflow_y_scrollbar().gap_1().children(rows)), + .when(self.shell_picker_open, |panel| { + let language = workspace_ui_language(); + let selected_filter = self.launcher_filter; + panel.child( + h_flex().w_full().gap_1().px_1().children( + self.launcher_chip_counts().into_iter().map(|(filter, count)| { + let selected = selected_filter == filter; + let label: SharedString = + format!("{} {count}", filter.label(language)).into(); + h_flex() + .id(SharedString::from(format!( + "launcher-chip-{filter:?}" + ))) + .h(px(26.0)) + .px_2() + .items_center() + .rounded_md() + .cursor_pointer() + .when(selected, |chip| chip.bg(selected_bg)) + .hover(|chip| chip.bg(hover_bg)) + .child(div().text_xs().child(label)) + .on_click(cx.listener(move |this, _, _, cx| { + this.set_launcher_filter(filter, cx); + })) + }), + ), + ) + }) + // gap 与行宽都要落在滚动区内层:`overflow_y_scrollbar` 把 + // 外层样式搬到它自建的容器上(详注见 settings_pane.rs 的 + // 设置正文),写在它之前的 gap_1 对行间距无效,行上的 + // w_full 也会回落 max-content 使每行宽度参差。 + .child( + v_flex() + .max_h(px(430.0)) + .overflow_y_scrollbar() + .child(v_flex().w_full().gap_1().children(rows)), + ), ) .into_any_element() } @@ -2674,7 +3103,11 @@ impl NebulaWorkspace { panel.child(div().text_xs().text_color(theme.warning).child(notice.to_owned())) }) .child( - v_flex().flex_1().min_h_0().gap_1().overflow_y_scrollbar().children(row_elements), + v_flex() + .flex_1() + .min_h_0() + .overflow_y_scrollbar() + .child(v_flex().w_full().gap_1().children(row_elements)), ) .into_any_element() } @@ -2701,12 +3134,63 @@ impl NebulaWorkspace { if let Some(info) = git.as_ref() { use crate::display::side_panel::VcsKind; - // SVN 没有暂存区:单区「修改」;Git 保持未暂存/已暂存两区。 - let sections: Vec<(&str, &Vec<(char, String)>)> = match info.vcs { - VcsKind::Git => vec![("未暂存", &info.unstaged), ("已暂存", &info.staged)], - VcsKind::Svn => vec![("修改", &info.unstaged)], - }; - for (section, entries) in sections { + /// 分组决定行内操作(VS Code 的 SCM 行合同)。 + #[derive(Clone, Copy, PartialEq)] + enum RowOps { + /// 变更组:暂存 + 丢弃(untracked 不给丢弃——restore 不删新文件)。 + Unstaged, + /// 已暂存组:取消暂存。 + Staged, + /// 冲突组 / SVN(无暂存区):无行内操作。 + None, + } + let is_git = info.vcs == VcsKind::Git; + let conflict_paths: std::collections::HashSet<&str> = + info.conflicts.iter().map(|(_, path)| path.as_str()).collect(); + // VS Code 三组模型:合并冲突 → 已暂存 → 变更。冲突路径从后两组 + // 过滤(数据层为旧壳兼容把它们同时留在原列表里)。 + let mut sections: Vec<(&str, Vec<&(char, String)>, RowOps)> = Vec::new(); + if !info.conflicts.is_empty() { + sections.push(("合并冲突", info.conflicts.iter().collect(), RowOps::None)); + } + let not_conflicted = + |(_, path): &&(char, String)| !conflict_paths.contains(path.as_str()); + match info.vcs { + VcsKind::Git => { + sections.push(( + "已暂存", + info.staged.iter().filter(not_conflicted).collect(), + RowOps::Staged, + )); + sections.push(( + "变更", + info.unstaged.iter().filter(not_conflicted).collect(), + RowOps::Unstaged, + )); + }, + VcsKind::Svn => sections.push(( + "修改", + info.unstaged.iter().filter(not_conflicted).collect(), + RowOps::None, + )), + } + let clean = sections.iter().all(|(_, entries, _)| entries.is_empty()); + if clean { + rows.push( + div() + .py_2() + .px_2() + .text_sm() + .text_color(muted) + .child("没有更改") + .into_any_element(), + ); + } + let discard_confirm = self.vcs_discard_confirm.clone(); + for (section, entries, ops) in sections { + if entries.is_empty() { + continue; + } rows.push( h_flex() .h(px(26.0)) @@ -2718,7 +3202,7 @@ impl NebulaWorkspace { .child(div().ml_2().child(entries.len().to_string())) .into_any_element(), ); - for (index, (status, relative_path)) in entries.iter().enumerate() { + for (index, (status, relative_path)) in entries.into_iter().enumerate() { let path = root .as_ref() .map(|root| root.join(relative_path)) @@ -2726,14 +3210,31 @@ impl NebulaWorkspace { let selected_row = selected.as_ref() == Some(&path); let status_color = match status { 'A' | '?' => theme.success, - 'D' => theme.danger, + 'D' | '!' => theme.danger, + 'C' | 'U' => theme.danger, _ => theme.warning, }; + // VS Code 式路径拆分:文件名主体 + 灰色父目录。 + let (file_name, parent) = match relative_path.rfind('/') { + Some(pos) => { + (relative_path[pos + 1..].to_owned(), relative_path[..pos].to_owned()) + }, + None => (relative_path.clone(), String::new()), + }; + let row_group = + SharedString::from(format!("vcs-row-actions-{section}-{index}")); + let open_path = path.clone(); + let stage_path = relative_path.clone(); + let unstage_path = relative_path.clone(); + let discard_path = relative_path.clone(); + let discard_armed = discard_confirm.as_deref() == Some(relative_path.as_str()); + let can_discard = ops == RowOps::Unstaged && *status != '?'; rows.push( h_flex() .id(SharedString::from(format!( "git-tree-row-{section}-{index}-{relative_path}" ))) + .group(row_group.clone()) .h(px(30.0)) .w_full() .px_2() @@ -2744,24 +3245,115 @@ impl NebulaWorkspace { .hover(|row| row.bg(hover)) .child( div() - .w(px(18.0)) + .w(px(14.0)) + .flex_shrink_0() .font_family(mono_family.clone()) .text_sm() .text_color(status_color) .child(status.to_string()), ) .child( - div() + h_flex() .flex_1() .min_w_0() - .text_sm() - .truncate() - .child(relative_path.clone()), + .gap_1() + .items_center() + .child( + div().flex_shrink_0().text_sm().child(file_name.clone()), + ) + .when(!parent.is_empty(), |line| { + line.child( + div() + .min_w_0() + .truncate() + .text_xs() + .text_color(muted) + .child(parent.clone()), + ) + }), ) + .when(can_discard, |row| { + row.child( + Button::new(SharedString::from(format!( + "vcs-discard-{section}-{index}" + ))) + .map(|button| { + if discard_armed { + button.label("确认丢弃").danger().xsmall() + } else { + button + .icon(IconName::Undo2) + .ghost() + .xsmall() + .tooltip("丢弃改动") + } + }) + .when(!discard_armed, |button| { + button + .invisible() + .group_hover(row_group.clone(), |button| { + button.visible() + }) + }) + .on_click(cx.listener(move |this, _, _, cx| { + if this.vcs_discard_confirm.as_deref() + == Some(discard_path.as_str()) + { + this.vcs_discard_confirm = None; + this.side_panel.git_discard_path(&discard_path); + } else { + this.vcs_discard_confirm = + Some(discard_path.clone()); + } + cx.notify(); + })), + ) + }) + .when(ops == RowOps::Unstaged && is_git, |row| { + row.child( + Button::new(SharedString::from(format!( + "vcs-stage-{section}-{index}" + ))) + .icon(IconName::Plus) + .ghost() + .xsmall() + .tooltip("暂存") + .invisible() + .group_hover(row_group.clone(), |button| button.visible()) + .on_click(cx.listener(move |this, _, _, cx| { + this.vcs_discard_confirm = None; + this.side_panel.git_stage_path(&stage_path); + cx.notify(); + })), + ) + }) + .when(ops == RowOps::Staged, |row| { + row.child( + Button::new(SharedString::from(format!( + "vcs-unstage-{section}-{index}" + ))) + .icon(IconName::Minus) + .ghost() + .xsmall() + .tooltip("取消暂存") + .invisible() + .group_hover(row_group.clone(), |button| button.visible()) + .on_click(cx.listener(move |this, _, _, cx| { + this.vcs_discard_confirm = None; + this.side_panel.git_unstage_path(&unstage_path); + cx.notify(); + })), + ) + }) .on_click(cx.listener(move |this, _, _, cx| { this.side_panel.selected = Some(path.clone()); cx.notify(); })) + .on_double_click(cx.listener(move |this, _, window, cx| { + // VS Code 点变更行看内容;diff 视图未落地前 + // 先开文件本体(源码 tab / 文档 tab 自动路由)。 + this.open_document_path(open_path.clone(), window, cx); + })) .into_any_element(), ); } @@ -2897,7 +3489,13 @@ impl NebulaWorkspace { .when_some(self.side_panel.root_notice(), |panel, notice| { panel.child(div().text_xs().text_color(theme.warning).child(notice.to_owned())) }) - .child(v_flex().flex_1().min_h_0().gap_1().overflow_y_scrollbar().children(rows)) + .child( + v_flex() + .flex_1() + .min_h_0() + .overflow_y_scrollbar() + .child(v_flex().w_full().gap_1().children(rows)), + ) .child( h_flex() .justify_end() @@ -3257,48 +3855,6 @@ impl NebulaWorkspace { )) } - fn sidebar_popup_menu(menu: PopupMenu, workspace: gpui::WeakEntity) -> PopupMenu { - let new_tab = workspace.clone(); - let settings = workspace.clone(); - let files = workspace.clone(); - let git = workspace; - menu.item(PopupMenuItem::new("新建终端").icon(IconName::Plus).on_click( - move |_, window, cx| { - if let Some(workspace) = new_tab.upgrade() { - workspace.update(cx, |workspace, cx| { - workspace.add_terminal(window, cx); - }); - } - }, - )) - .item(PopupMenuItem::new("设置").icon(IconName::Settings).on_click(move |_, window, cx| { - if let Some(workspace) = settings.upgrade() { - workspace.update(cx, |workspace, cx| { - workspace.open_settings(window, cx); - }); - } - })) - .separator() - .item(PopupMenuItem::new("文件树").icon(IconName::PanelRightOpen).on_click( - move |_, _, cx| { - if let Some(workspace) = files.upgrade() { - workspace.update(cx, |workspace, cx| { - workspace.toggle_file_tree(cx); - }); - } - }, - )) - .item(PopupMenuItem::new("Git 状态").icon(IconName::GitHub).on_click( - move |_, _, cx| { - if let Some(workspace) = git.upgrade() { - workspace.update(cx, |workspace, cx| { - workspace.toggle_git_tree(cx); - }); - } - }, - )) - } - /// 侧栏等宽标签的 cell 宽:与终端元素同一套度量法(塑形一个 "M" 取 /// advance),列数换算与省略号都建立在它上面。字体缺失时回落 0.6em, /// 只影响截断位置、不会画错。 @@ -3399,9 +3955,11 @@ impl NebulaWorkspace { .map(|settings| settings.font_family.clone()) .unwrap_or_else(|| String::from("Cascadia Mono")) .into(); - // 标签字号跟着终端字号走(旧壳 chrome 只有这一套度量),所以设置页 - // 调字号、Ctrl+滚轮缩放之后侧栏一起变,不再固定 14px。 - let label_px = settings.map(|settings| settings.font_size_px).unwrap_or(15.0); + // 旧壳合同(display/mod.rs `ui_font_px`):chrome 锚定**配置字号** + // (nebula.toml `font.size` 默认 11.25pt = 15px)。终端的持久化缩放 + // (`font_size=` 键)只影响终端网格,侧栏不得跟着变粗/变大; + // 固定 14px 的旧毛病(比旧壳小一号)也不能回潮。 + let label_px = settings.map(|settings| settings.base_font_size_px).unwrap_or(15.0); // 等宽 advance 的唯一事实源:与终端元素同款——塑形一个 "M" 量出来, // 而不是按 0.6em 猜。列数换算和省略号都建立在这个数上。 let cell_w = self.sidebar_cell_width(window, &mono_family, label_px); @@ -3451,9 +4009,12 @@ impl NebulaWorkspace { .filter(|_| logo_image.is_none()) .map(crate::display::program_icon) .or_else(|| match &self.tabs[ix] { - // 文档/图片 tab 的行首图标与文件树同一套 codicon(旧壳 - // custom_name 前缀的对应物;字形靠 mono 字体渲染)。 + // 文档/图片/代码 tab 的行首图标与文件树同一套 codicon + // (旧壳 custom_name 前缀的对应物;字形靠 mono 字体渲染)。 WorkspaceTab::Document { .. } => Some("\u{eb1d}"), + WorkspaceTab::Code { view } => Some( + crate::display::side_panel::file_type_icon(&view.read(cx).title), + ), WorkspaceTab::Image { view } => Some( crate::display::side_panel::file_type_icon(&view.read(cx).title), ), @@ -3501,6 +4062,23 @@ impl NebulaWorkspace { crate::gpui_shell::theme::sidebar_spinner_colors(cx, active); Some(Self::spinner(self.spinner_phase, track, head).into_any_element()) }, + // 回合完成、等下一条指令:旧壳蓝点语义——不转圈,留一个 + // 「有结果没看」的痕迹。 + SidebarActivity::Done => Some( + div() + .size(px(6.0)) + .rounded_full() + .bg(theme.primary) + .into_any_element(), + ), + // 停在授权/提问上:比「完成」更强,必须换形状而不是换色 + // (旧壳教训:两态共用圆点在界面上根本分不出来)。 + SidebarActivity::Attention => Some( + Icon::new(IconName::TriangleAlert) + .xsmall() + .text_color(theme.warning) + .into_any_element(), + ), SidebarActivity::Failed => Some( Icon::new(IconName::CircleX) .xsmall() @@ -3697,20 +4275,26 @@ impl NebulaWorkspace { .into_any_element() } else if shift != 0.0 { // 让位滑动:进位方向 ease-out 滑入(旧壳是双向弹簧;回位 - // 这里先直落,违和再补逐帧插值)。 - row.with_animation( - ("tab-make-way", ix), - Animation::new(Duration::from_millis(120)).with_easing(ease_out_quint()), - move |row, t| row.top(px(shift * t)), - ) - .into_any_element() + // 这里先直落,违和再补逐帧插值)。设置「标签动画=立即」时 + // 直接落位(旧壳 TabRevealMotion::Instant 的 Snap 语义)。 + if nebula_settings::RuntimeSettings::load().tab_reveal + == nebula_settings::TabRevealName::Instant + { + row.top(px(shift)).into_any_element() + } else { + row.with_animation( + ("tab-make-way", ix), + Animation::new(Duration::from_millis(120)).with_easing(ease_out_quint()), + move |row, t| row.top(px(shift * t)), + ) + .into_any_element() + } } else { row.into_any_element() } }); let header_group: SharedString = "sidebar-tabs-header-hover".into(); - let header_workspace = cx.entity().downgrade(); let count: SharedString = self.tabs.len().to_string().into(); let sidebar = v_flex() @@ -3798,33 +4382,63 @@ impl NebulaWorkspace { ) .child(div().flex_1()) .child( - Button::new("sidebar-new-tab") - .icon(IconName::Plus) - .ghost() - .xsmall() - .invisible() - .group_hover(header_group, |button| button.visible()) - .tooltip("新建终端 (Ctrl+Shift+T)") - .on_click(cx.listener(|this, _, window, cx| { - cx.stop_propagation(); - this.add_terminal(window, cx); - })), - ) - .child( - div() - .id("sidebar-tabs-menu-stop") - // DropdownMenu 自己管理 click;外壳截住冒泡,避免 - // 打开菜单时顺手折叠 TABS。 - .on_click(|_, _, cx| cx.stop_propagation()) + h_flex() + .flex_shrink_0() + .items_center() + .gap(px(2.0)) .child( - Button::new("sidebar-tabs-menu") - .icon(IconName::EllipsisVertical) - .ghost() - .xsmall() - .tooltip("标签与面板") - .dropdown_menu(move |menu, _, _| { - Self::sidebar_popup_menu(menu, header_workspace.clone()) - }), + // 旧壳 `ChromeHit::NewTab`:直接开设置里的默认 + // shell,不经过选择器。三点才是 NewTabMenu。 + h_flex() + .id("sidebar-new-tab") + .size(px(SIDEBAR_PLUS_SIZE)) + .flex_shrink_0() + .items_center() + .justify_center() + .rounded_md() + .cursor_pointer() + .text_color(muted) + .invisible() + .group_hover(header_group, |button| button.visible()) + .hover(|button| button.bg(hover_bg).text_color(theme.foreground)) + .tooltip(|window, cx| { + gpui_component::tooltip::Tooltip::new( + "新建终端 (Ctrl+Shift+T)", + ) + .build(window, cx) + }) + .on_click(cx.listener(|this, _, window, cx| { + cx.stop_propagation(); + this.add_terminal(window, cx); + })) + .child( + Icon::new(IconName::Plus).with_size(px(SIDEBAR_HEADER_ICON)), + ), + ) + .child( + h_flex() + .id("sidebar-tabs-menu") + .w(px(SIDEBAR_MENU_W)) + .h(px(SIDEBAR_PLUS_SIZE)) + .flex_shrink_0() + .items_center() + .justify_center() + .rounded_md() + .cursor_pointer() + .text_color(muted) + .hover(|button| button.bg(hover_bg).text_color(theme.foreground)) + .tooltip(|window, cx| { + gpui_component::tooltip::Tooltip::new("新建终端 (Ctrl+K)") + .build(window, cx) + }) + .on_click(cx.listener(|this, _, window, cx| { + cx.stop_propagation(); + this.open_shell_palette(window, cx); + })) + .child( + Icon::new(IconName::EllipsisVertical) + .with_size(px(SIDEBAR_HEADER_ICON)), + ), ), ), ) @@ -4210,6 +4824,9 @@ impl Render for NebulaWorkspace { Some(WorkspaceTab::Document { view }) => { Some(gpui::IntoElement::into_any_element(view.clone())) }, + Some(WorkspaceTab::Code { view }) => { + Some(gpui::IntoElement::into_any_element(view.clone())) + }, None => None, }; let files_active = self.side_panel.open @@ -4271,6 +4888,9 @@ impl Render for NebulaWorkspace { .on_action(cx.listener(|this, _: &CloseCommandPalette, window, cx| { this.close_command_palette(window, cx); })) + .on_action(cx.listener(|this, _: &ToggleShellPicker, window, cx| { + this.toggle_shell_palette(window, cx); + })) .on_action(cx.listener(|this, _: &CommandPaletteUp, _, cx| { this.move_command_palette_selection(-1, cx); })) @@ -4357,6 +4977,8 @@ impl Render for NebulaWorkspace { .selected(settings_active) .tooltip("设置 (Ctrl+,)") .on_click(cx.listener(|this, _, window, cx| { + // TODO(debug-layout): 临时诊断打点。 + eprintln!("[nebula:gpui] gear clicked"); this.open_settings(window, cx); })), ), @@ -4401,6 +5023,33 @@ impl Render for NebulaWorkspace { .flex_1() .min_h_0() .child(self.render_sidebar_slot(window, cx)) + .when( + // 侧栏拖宽热区(旧壳 `panel_resize` 设置门控):贴在 + // 侧栏右缘、零布局宽,不挤压终端卡。 + !self.sidebar_collapsed + && nebula_settings::RuntimeSettings::load().panel_resize, + |row| { + row.child( + div().relative().w_0().h_full().flex_shrink_0().child( + div() + .id("sidebar-resize-handle") + .absolute() + .top_0() + .bottom_0() + .left(px(-3.0)) + .w(px(6.0)) + .cursor_col_resize() + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.sidebar_resizing = true; + cx.notify(); + }), + ), + ), + ) + }, + ) .child( // 终端卡(一体化外壳):唯一的结构分界。圆角与旧壳卡 // 同源(UI_SHELL_RADIUS_LOGICAL=14),无描边——融合靠 @@ -4482,6 +5131,40 @@ impl Render for NebulaWorkspace { ), ) }) + .when(self.sidebar_resizing, |root| { + // 侧栏拖宽罩层(同 split_drag 的指针捕获模式):移动实时改宽 + // (夹在共享层的 170..420 之间),松手写盘 `sidebar_w`。 + root.child( + div() + .absolute() + .inset_0() + .occlude() + .cursor_col_resize() + .on_mouse_move(cx.listener(|this, event: &gpui::MouseMoveEvent, _, cx| { + let width = f32::from(event.position.x).clamp( + nebula_settings::MIN_SIDEBAR_WIDTH, + nebula_settings::MAX_SIDEBAR_WIDTH, + ); + if (width - this.sidebar_width).abs() >= 0.5 { + this.sidebar_width = width; + cx.notify(); + } + })) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.sidebar_resizing = false; + if let Err(err) = nebula_settings::persist_keys(&[( + "sidebar_w", + format!("{:.0}", this.sidebar_width), + )]) { + log::warn!("持久化侧栏宽度失败: {err}"); + } + cx.notify(); + }), + ), + ) + }) .when_some( self.split_drag.as_ref().map(|drag| drag.direction), |root, direction| { @@ -4566,10 +5249,11 @@ mod tests { use crate::session::LaunchSession; // `Default` 的实际 PTY 启动是引擎默认 PowerShell;标签必须只由这个 - // 已保存身份决定,不能在恢复时重新采样可变的用户设置。 + // 已保存身份决定,不能在恢复时重新采样可变的用户设置。引擎默认的 + // 那台是系统自带的 Windows PowerShell 5.1,短标 `ps`(7 才是 pwsh)。 assert_eq!( NebulaWorkspace::launch_shell_tag(&LaunchSession::Default).map(|tag| tag.to_string()), - Some("pwsh".to_owned()) + Some("ps".to_owned()) ); } @@ -4626,9 +5310,86 @@ mod tests { assert!(NebulaWorkspace::palette_action_available(&PaletteAction::NewTab, false)); } + /// 新建终端弹窗:默认 shell 必须占首行,因为「开弹窗 → 回车」是旧壳 + /// 点 "+" 后最常走的一条路,它必须等于「开一个默认终端」。 #[test] - fn ai_session_palette_exposes_verified_resume_and_fork_commands() { - let sessions = vec![ + fn shell_palette_puts_the_default_shell_first() { + let shells = ["pwsh", "powershell", "cmd", "nu"] + .into_iter() + .map(|id| crate::shell_detect::DetectedShell { + name: crate::shell_detect::display_name_for_id(id), + id: id.to_owned(), + program: format!("{id}.exe"), + args: Vec::new(), + }) + .collect::>(); + let language = crate::display::UiLanguage::ZhCn; + + // 检测顺序里 nu 在最后,设成默认后必须提到首行。 + let rows = shell_palette_rows(shells.clone(), ["box.example".to_owned()], "nu", language, 1.0); + assert_eq!(rows.len(), 5, "四台 shell + 一台 SSH"); + assert!(matches!( + &rows[0].action, + WorkspacePaletteAction::LaunchShell(shell) if shell.id == "nu" + )); + assert_eq!(rows[0].group, "推荐"); + let rest: Vec<_> = rows[1..4] + .iter() + .map(|row| match &row.action { + WorkspacePaletteAction::LaunchShell(shell) => shell.id.as_str(), + _ => "?", + }) + .collect(); + assert_eq!(rest, ["pwsh", "powershell", "cmd"]); + assert!(rows[1..4].iter().all(|row| row.group == "所有 Shell")); + assert!(matches!( + &rows[4].action, + WorkspacePaletteAction::LaunchSshHost(host) if host == "box.example" + )); + assert_eq!(rows[4].group, "SSH 主机"); + + // 默认 id 没在检测结果里(WSL 发行版被卸载等):不置顶也不 panic。 + let rows = shell_palette_rows(shells, None::, "wsl:Ghost", language, 1.0); + assert!(matches!( + &rows[0].action, + WorkspacePaletteAction::LaunchShell(shell) if shell.id == "pwsh" + )); + assert_eq!(rows[0].group, "所有 Shell"); + } + + #[test] + fn escape_does_not_bind_globally_when_the_terminal_is_focused() { + use gpui::{KeyContext, Keymap, Keystroke}; + + let keymap = Keymap::new(vec![KeyBinding::new( + "escape", + CloseCommandPalette, + Some(PALETTE_KEY_CONTEXT), + )]); + let terminal = [ + KeyContext::parse("Root").unwrap(), + KeyContext::parse("NebulaTerminal").unwrap(), + ]; + let (bindings, pending) = + keymap.bindings_for_input(&[Keystroke::parse("escape").unwrap()], &terminal); + assert!(!pending); + assert!( + bindings.iter().all(|binding| !binding.action().as_any().is::()), + "终端聚焦时 Esc 不得命中 CloseCommandPalette" + ); + + let palette = [ + KeyContext::parse("Root").unwrap(), + KeyContext::parse(PALETTE_KEY_CONTEXT).unwrap(), + ]; + let (bindings, pending) = + keymap.bindings_for_input(&[Keystroke::parse("escape").unwrap()], &palette); + assert!(!pending); + assert!(bindings[0].action().as_any().is::()); + } + + #[test] + fn ai_session_palette_exposes_verified_resume_and_fork_commands() { let sessions = vec![ crate::ai_sessions::AiSession::test_session( crate::ai_agents::AgentKind::Claude, "claude-42", diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index ba58f59..b55eb09 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -74,6 +74,7 @@ mod session; mod shell_detect; #[cfg(windows)] mod ssh; +mod svn_status; #[cfg(windows)] mod ssh_credentials; #[cfg(windows)] diff --git a/nebula_app/src/math/mod.rs b/nebula_app/src/math/mod.rs index 3f10220..239b621 100644 --- a/nebula_app/src/math/mod.rs +++ b/nebula_app/src/math/mod.rs @@ -34,12 +34,6 @@ pub(crate) const OPTICAL_SCALE: f32 = 1.21; /// 同类公式的字号不会因此分裂。 /// /// 这是刻意偏离 LaTeX/KaTeX 的一处:那两者排的是版面充裕的文档,我们排的 -/// 是终端里 AI 输出的一行文字。 -/// 上下标 / 分子分母的最小相对字号。Latin Modern 的 MATH 表给 70%,是为 -/// 纸面阅读字号调的;终端正文本来就小,0.7 em 的分子在 20px 字号下只有 -/// 14px,用户看到的就是"公式比正文小一圈"。 -/// -/// 这是刻意偏离 LaTeX/KaTeX 的一处:那两者排的是版面充裕的文档,我们排的 /// 是终端里 AI 输出的一行文字。上限卡在 0.8:再往上(实测 0.85)根号里的 /// 上标会把 `\sqrt` 顶过字形变体的阈值,高度从 30px 跳到 45px,那条公式 /// 就得缩——同类公式一样大比多两个百分点重要。 diff --git a/nebula_app/src/shell_detect.rs b/nebula_app/src/shell_detect.rs index 33ba164..3082421 100644 --- a/nebula_app/src/shell_detect.rs +++ b/nebula_app/src/shell_detect.rs @@ -405,9 +405,19 @@ pub fn shell_short_tag(name_or_id: &str) -> String { return distro.trim().to_ascii_lowercase(); } let lower = raw.to_ascii_lowercase(); + // 两个 PowerShell 各有稳定 id,先精确匹配 id 再看显示名:同一台 shell + // 经 `TabLaunch::Default`(喂 id)和 `TabLaunch::Shell`(喂菜单名)两条 + // 管道进来,必须标成同一个词,否则「新建的 tab」和「恢复出来的 tab」 + // 明明是同一个 shell 却挂着两个短标。 + match lower.as_str() { + "pwsh" => return "pwsh".into(), + "powershell" | "ps" => return "ps".into(), + _ => {}, + } if lower.contains("powershell") { - // 用户口头就用 pwsh 区分 7 和老 5.1,只有 Windows PowerShell 保留全名。 - return if lower.contains("windows") { "powershell".into() } else { "pwsh".into() }; + // 用户口头就用 pwsh 指 7、ps 指系统自带的 5.1;两者都是缩写,且 + // 同时装着两版时在侧栏一眼分得开。 + return if lower.contains("windows") { "ps".into() } else { "pwsh".into() }; } if lower.contains("bash") { return "bash".into(); @@ -559,12 +569,30 @@ mod tests { assert_eq!(shell_short_tag("WSL · Debian"), "debian"); assert_eq!(shell_short_tag("PowerShell 7"), "pwsh"); assert_eq!(shell_short_tag("pwsh"), "pwsh"); - assert_eq!(shell_short_tag("Windows PowerShell"), "powershell"); + assert_eq!(shell_short_tag("Windows PowerShell"), "ps"); assert_eq!(shell_short_tag("Git Bash"), "bash"); assert_eq!(shell_short_tag("Nushell"), "nu"); assert_eq!(shell_short_tag("CMD"), "cmd"); } + /// 同一台 shell 的 settings id 与菜单显示名必须给出同一个短标。 + /// 回归锁:`powershell`(id) 曾标成 `pwsh` 而「Windows PowerShell」(名) + /// 标成 `powershell`——同一台机器因为 tab 的启动方式不同挂了两个词, + /// 而其中一个还不是缩写。 + #[test] + fn shell_short_tags_agree_across_id_and_display_name() { + use super::{display_name_for_id, shell_short_tag}; + for id in ["pwsh", "powershell", "cmd", "bash", "nu", "wsl:Ubuntu"] { + assert_eq!( + shell_short_tag(id), + shell_short_tag(&display_name_for_id(id)), + "id `{id}` 与它的显示名短标不一致" + ); + } + // 两个 PowerShell 仍然分得开。 + assert_ne!(shell_short_tag("powershell"), shell_short_tag("pwsh")); + } + use super::*; #[test] diff --git a/nebula_app/src/svn_status.rs b/nebula_app/src/svn_status.rs new file mode 100644 index 0000000..6569e17 --- /dev/null +++ b/nebula_app/src/svn_status.rs @@ -0,0 +1,460 @@ +//! SVN 工作拷贝状态:直接读 `.svn/wc.db`(SQLite),零外部进程依赖。 +//! +//! 为什么不调 svn.exe:这台产品的目标机器未必装命令行客户端(TortoiseSVN +//! 默认只装 GUI),而且逐次 spawn 进程拉状态既慢又抖。SVN 1.7+ 的工作拷贝 +//! 元数据是稳定的 SQLite 库(`NODES` / `ACTUAL_NODE` 表),TortoiseSVN 的 +//! TSVNCache 走的就是这条路:只读打开、快速路径按 `recorded_size` / +//! `recorded_time` 判嫌疑、嫌疑文件再做 SHA-1 精判——本模块同一合同。 +//! +//! 范围(v1): +//! - 状态子集:Added / Replaced / Deleted / Modified / Missing / Conflicted / +//! Unversioned;`svn:ignore` 属性尚未解析(未忽略项会以 Unversioned 出现)。 +//! - `svnadmin create` 出来的**服务端仓库**(conf/db/hooks/format)不是 +//! 工作拷贝,[`classify_dir`] 会识别并明确报告,不做 FSFS 解析。 + +use std::collections::{BTreeMap, HashSet}; +use std::path::{Path, PathBuf}; + +use sha1::{Digest as _, Sha1}; + +/// 修改嫌疑文件做 SHA-1 精判的尺寸上限;超过就直接按 Modified 报告, +/// 避免状态刷新读几百 MB 大文件(TSVNCache 同款取舍)。 +const SHA1_VERIFY_LIMIT: u64 = 4 * 1024 * 1024; + +/// 一个目录相对 SVN 的身份。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SvnDirKind { + /// 位于某个工作拷贝内;携带工作拷贝根(含 `.svn/` 的目录)。 + WorkingCopy(PathBuf), + /// `svnadmin create` 生成的服务端仓库:只能被 svnserve/httpd 提供服务 + /// 或用 `file://` 协议 checkout,本身没有"文件状态"可言。 + Repository, + /// 与 SVN 无关。 + Plain, +} + +/// 判定 `dir` 的 SVN 身份:向上找 `.svn/wc.db`(工作拷贝),否则按 +/// `format` + `conf/db/hooks` 特征识别服务端仓库。 +pub fn classify_dir(dir: &Path) -> SvnDirKind { + let mut probe = Some(dir); + while let Some(current) = probe { + if current.join(".svn").join("wc.db").is_file() { + return SvnDirKind::WorkingCopy(current.to_owned()); + } + probe = current.parent(); + } + let is_repository = dir.join("format").is_file() + && dir.join("conf").is_dir() + && dir.join("db").is_dir() + && dir.join("hooks").is_dir(); + if is_repository { SvnDirKind::Repository } else { SvnDirKind::Plain } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum SvnState { + Added, + Replaced, + Deleted, + Modified, + Missing, + Conflicted, + Unversioned, +} + +impl SvnState { + /// 单字母角标(与 `svn status` 输出的第一列同字符集)。 + pub fn letter(self) -> &'static str { + match self { + Self::Added => "A", + Self::Replaced => "R", + Self::Deleted => "D", + Self::Modified => "M", + Self::Missing => "!", + Self::Conflicted => "C", + Self::Unversioned => "?", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SvnChange { + /// 相对工作拷贝根的正斜杠路径。 + pub rel_path: String, + pub state: SvnState, +} + +/// 读出整个工作拷贝的变更清单(按路径排序)。 +/// +/// 干净判定的快速路径:`recorded_size` 不同 → Modified;大小相同而 +/// `recorded_time`(µs)不同 → ≤ [`SHA1_VERIFY_LIMIT`] 时做 SHA-1 精判, +/// 只有内容真的不同才报 Modified(touch 过的文件不误报)。 +pub fn working_copy_status(root: &Path) -> Result, String> { + let db_path = root.join(".svn").join("wc.db"); + let connection = rusqlite::Connection::open_with_flags( + &db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + ) + .map_err(|error| format!("无法打开 {}: {error}", db_path.display()))?; + // svn 客户端可能正持有写锁;等一小会而不是立刻失败。 + connection.busy_timeout(std::time::Duration::from_millis(300)).ok(); + + #[derive(Debug)] + struct NodeRow { + op_depth: i64, + presence: String, + kind: String, + checksum: Option, + recorded_size: Option, + recorded_time: Option, + has_base: bool, + } + + // 每个 relpath 取 op_depth 最大的行(当前操作层),并记录是否存在 + // base 层(Added vs Replaced 的分界)。 + let mut nodes: BTreeMap = BTreeMap::new(); + { + let mut statement = connection + .prepare( + "SELECT local_relpath, op_depth, presence, kind, checksum, + recorded_size, recorded_time + FROM nodes ORDER BY local_relpath, op_depth", + ) + .map_err(|error| format!("wc.db 结构不符合预期: {error}"))?; + let rows = statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + NodeRow { + op_depth: row.get(1)?, + presence: row.get(2)?, + kind: row.get(3)?, + checksum: row.get(4)?, + recorded_size: row.get(5)?, + recorded_time: row.get(6)?, + has_base: false, + }, + )) + }) + .map_err(|error| format!("读取 NODES 失败: {error}"))?; + for row in rows { + let (relpath, node) = row.map_err(|error| format!("读取 NODES 失败: {error}"))?; + match nodes.get_mut(&relpath) { + // 行按 op_depth 升序到达:后到的层覆盖前面的,base 层的 + // 存在性单独记账。 + Some(existing) => { + let has_base = existing.has_base || existing.op_depth == 0; + if node.op_depth >= existing.op_depth { + *existing = NodeRow { has_base, ..node }; + } else { + existing.has_base = has_base || node.op_depth == 0; + } + }, + None => { + let has_base = node.op_depth == 0; + nodes.insert(relpath, NodeRow { has_base, ..node }); + }, + } + } + } + + // 冲突从 ACTUAL_NODE 来(树冲突/文本冲突统一报 Conflicted)。 + let mut conflicted: HashSet = HashSet::new(); + if let Ok(mut statement) = connection + .prepare("SELECT local_relpath FROM actual_node WHERE conflict_data IS NOT NULL") + { + if let Ok(rows) = statement.query_map([], |row| row.get::<_, String>(0)) { + for relpath in rows.flatten() { + conflicted.insert(relpath); + } + } + } + + let mut changes = Vec::new(); + let mut versioned_dirs: Vec = Vec::new(); + for (relpath, node) in &nodes { + if relpath.is_empty() { + versioned_dirs.push(String::new()); + continue; + } + if conflicted.contains(relpath) { + changes.push(SvnChange { rel_path: relpath.clone(), state: SvnState::Conflicted }); + continue; + } + let disk = root.join(relpath.replace('/', std::path::MAIN_SEPARATOR_STR)); + match node.presence.as_str() { + "base-deleted" => { + changes.push(SvnChange { rel_path: relpath.clone(), state: SvnState::Deleted }); + }, + "normal" if node.op_depth > 0 => { + let state = if node.has_base { SvnState::Replaced } else { SvnState::Added }; + changes.push(SvnChange { rel_path: relpath.clone(), state }); + if node.kind == "dir" { + versioned_dirs.push(relpath.clone()); + } + }, + "normal" => { + if node.kind == "dir" { + versioned_dirs.push(relpath.clone()); + continue; + } + let Ok(metadata) = std::fs::metadata(&disk) else { + changes.push(SvnChange { rel_path: relpath.clone(), state: SvnState::Missing }); + continue; + }; + if is_modified(&disk, &metadata, node.recorded_size, node.recorded_time, node.checksum.as_deref()) + { + changes.push(SvnChange { rel_path: relpath.clone(), state: SvnState::Modified }); + } + }, + // excluded/server-excluded/not-present 等:本地无实体,不报告。 + _ => {}, + } + } + + // 未版本化:已版本化目录的磁盘直接子项里,不在 NODES 且不是 .svn 的。 + for dir in versioned_dirs { + let disk_dir = + if dir.is_empty() { root.to_owned() } else { root.join(dir.replace('/', std::path::MAIN_SEPARATOR_STR)) }; + let Ok(entries) = std::fs::read_dir(&disk_dir) else { continue }; + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name == ".svn" { + continue; + } + let relpath = + if dir.is_empty() { name.to_string() } else { format!("{dir}/{name}") }; + if !nodes.contains_key(&relpath) { + changes.push(SvnChange { rel_path: relpath, state: SvnState::Unversioned }); + } + } + } + + changes.sort_by(|a, b| a.rel_path.cmp(&b.rel_path)); + Ok(changes) +} + +/// 工作拷贝根的当前修订号(`NODES` 根行的 `revision` 列)。 +pub fn working_copy_revision(root: &Path) -> Option { + let connection = rusqlite::Connection::open_with_flags( + root.join(".svn").join("wc.db"), + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + ) + .ok()?; + connection + .query_row( + "SELECT revision FROM nodes WHERE local_relpath = '' AND op_depth = 0", + [], + |row| row.get::<_, i64>(0), + ) + .ok() +} + +/// 干净判定:大小 → 时间戳 → SHA-1 三级快速路径。 +fn is_modified( + path: &Path, + metadata: &std::fs::Metadata, + recorded_size: Option, + recorded_time: Option, + checksum: Option<&str>, +) -> bool { + if let Some(size) = recorded_size { + if metadata.len() != size as u64 { + return true; + } + } + let disk_micros = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_micros() as i64); + if let (Some(disk), Some(recorded)) = (disk_micros, recorded_time) { + if disk == recorded { + return false; + } + } + // 时间戳变了(或缺记录):尺寸相同的小文件做内容精判,防 touch 误报。 + let Some(expected) = checksum.and_then(|value| value.strip_prefix("$sha1$")) else { + return true; + }; + if metadata.len() > SHA1_VERIFY_LIMIT { + return true; + } + let Ok(bytes) = std::fs::read(path) else { + return true; + }; + let digest = Sha1::digest(&bytes); + let mut actual = String::with_capacity(40); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(actual, "{byte:02x}"); + } + actual != expected +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 手工构造最小 wc.db:只建状态推导用到的列,与真实 schema 的子集 + /// 保持同名同义。 + fn fake_working_copy(dir: &Path) -> rusqlite::Connection { + std::fs::create_dir_all(dir.join(".svn")).unwrap(); + let connection = rusqlite::Connection::open(dir.join(".svn").join("wc.db")).unwrap(); + connection + .execute_batch( + "CREATE TABLE nodes ( + local_relpath TEXT, op_depth INTEGER, presence TEXT, kind TEXT, + checksum TEXT, recorded_size INTEGER, recorded_time INTEGER + ); + CREATE TABLE actual_node (local_relpath TEXT, conflict_data BLOB);", + ) + .unwrap(); + connection + } + + fn insert_node( + connection: &rusqlite::Connection, + relpath: &str, + op_depth: i64, + presence: &str, + kind: &str, + checksum: Option, + size: Option, + time: Option, + ) { + connection + .execute( + "INSERT INTO nodes VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![relpath, op_depth, presence, kind, checksum, size, time], + ) + .unwrap(); + } + + fn sha1_of(content: &str) -> String { + let digest = Sha1::digest(content.as_bytes()); + let mut hex = String::new(); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(hex, "{byte:02x}"); + } + format!("$sha1${hex}") + } + + fn state_of<'a>(changes: &'a [SvnChange], path: &str) -> Option<&'a SvnChange> { + changes.iter().find(|change| change.rel_path == path) + } + + #[test] + fn classifies_repository_and_working_copy() { + let temp = tempfile::tempdir().unwrap(); + let repo = temp.path().join("repo"); + for sub in ["conf", "db", "hooks"] { + std::fs::create_dir_all(repo.join(sub)).unwrap(); + } + std::fs::write(repo.join("format"), "8\n").unwrap(); + assert_eq!(classify_dir(&repo), SvnDirKind::Repository); + + let wc = temp.path().join("wc"); + fake_working_copy(&wc); + let inner = wc.join("src"); + std::fs::create_dir_all(&inner).unwrap(); + assert_eq!(classify_dir(&inner), SvnDirKind::WorkingCopy(wc.clone())); + + assert_eq!(classify_dir(temp.path()), SvnDirKind::Plain); + } + + #[test] + fn derives_the_full_state_alphabet_from_wc_db() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let connection = fake_working_copy(root); + + insert_node(&connection, "", 0, "normal", "dir", None, None, None); + // 干净文件:大小/时间都与记录一致。 + std::fs::write(root.join("clean.txt"), "clean").unwrap(); + let clean_meta = std::fs::metadata(root.join("clean.txt")).unwrap(); + let clean_micros = clean_meta + .modified() + .unwrap() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_micros() as i64; + insert_node( + &connection, + "clean.txt", + 0, + "normal", + "file", + Some(sha1_of("clean")), + Some(5), + Some(clean_micros), + ); + // touch 过但内容相同:时间戳不同 + SHA-1 相同 → 不报告。 + std::fs::write(root.join("touched.txt"), "same!").unwrap(); + insert_node( + &connection, + "touched.txt", + 0, + "normal", + "file", + Some(sha1_of("same!")), + Some(5), + Some(1), + ); + // 真改过:大小不同。 + std::fs::write(root.join("changed.txt"), "different content").unwrap(); + insert_node( + &connection, + "changed.txt", + 0, + "normal", + "file", + Some(sha1_of("old")), + Some(3), + Some(1), + ); + // 新增 / 替换 / 删除 / 丢失。 + std::fs::write(root.join("added.txt"), "new").unwrap(); + insert_node(&connection, "added.txt", 1, "normal", "file", None, None, None); + std::fs::write(root.join("replaced.txt"), "re-added").unwrap(); + insert_node(&connection, "replaced.txt", 0, "normal", "file", None, Some(1), Some(1)); + insert_node(&connection, "replaced.txt", 1, "normal", "file", None, None, None); + insert_node(&connection, "deleted.txt", 0, "normal", "file", None, Some(1), Some(1)); + insert_node(&connection, "deleted.txt", 1, "base-deleted", "file", None, None, None); + insert_node( + &connection, + "missing.txt", + 0, + "normal", + "file", + None, + Some(1), + Some(1), + ); + // 冲突覆盖其他状态。 + std::fs::write(root.join("conflict.txt"), "x").unwrap(); + insert_node(&connection, "conflict.txt", 0, "normal", "file", None, Some(1), Some(1)); + connection + .execute( + "INSERT INTO actual_node VALUES ('conflict.txt', x'00')", + [], + ) + .unwrap(); + // 未版本化的散文件。 + std::fs::write(root.join("stray.txt"), "?").unwrap(); + drop(connection); + + let changes = working_copy_status(root).unwrap(); + + assert!(state_of(&changes, "clean.txt").is_none(), "clean file must stay silent"); + assert!(state_of(&changes, "touched.txt").is_none(), "touch without edit must stay silent"); + assert_eq!(state_of(&changes, "changed.txt").unwrap().state, SvnState::Modified); + assert_eq!(state_of(&changes, "added.txt").unwrap().state, SvnState::Added); + assert_eq!(state_of(&changes, "replaced.txt").unwrap().state, SvnState::Replaced); + assert_eq!(state_of(&changes, "deleted.txt").unwrap().state, SvnState::Deleted); + assert_eq!(state_of(&changes, "missing.txt").unwrap().state, SvnState::Missing); + assert_eq!(state_of(&changes, "conflict.txt").unwrap().state, SvnState::Conflicted); + assert_eq!(state_of(&changes, "stray.txt").unwrap().state, SvnState::Unversioned); + } +} diff --git a/nebula_app/src/update_check.rs b/nebula_app/src/update_check.rs index fa01c98..3c42f20 100644 --- a/nebula_app/src/update_check.rs +++ b/nebula_app/src/update_check.rs @@ -15,7 +15,16 @@ use crate::event::{Event, EventType}; use crate::message_bar::{Message, MessageType}; const RELEASES_API: &str = "https://api.github.com/repos/Kuddev/nebula/releases/latest"; -const RELEASES_PAGE: &str = "https://github.com/Kuddev/nebula/releases"; +pub const RELEASES_PAGE: &str = "https://github.com/Kuddev/nebula/releases"; + +/// 设置主页手动检查所需的完整结果。启动检查与手动检查共用这一份版本 +/// 比较,避免两处对预发布后缀或分段数字产生不同结论。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UpdateCheckResult { + pub current: String, + pub latest: String, + pub update_available: bool, +} /// Kick off the once-per-process background check. The result (if any) /// arrives as a regular [`EventType::Message`] banner, which the message bar @@ -28,7 +37,13 @@ pub fn spawn_once(proxy: EventLoopProxy) { let spawned = std::thread::Builder::new().name("update-check".into()).spawn(move || { // 等窗口与首个会话安顿好再查,别和启动抢磁盘/网络。 std::thread::sleep(Duration::from_secs(12)); - let Some(latest) = fetch_latest_version() else { return }; + let latest = match fetch_latest_version() { + Ok(latest) => latest, + Err(error) => { + log::debug!("update-check: {error}"); + return; + }, + }; let current = env!("CARGO_PKG_VERSION"); if !is_newer(&latest, current) { log::debug!("update-check: v{current} is current (latest v{latest})"); @@ -45,7 +60,15 @@ pub fn spawn_once(proxy: EventLoopProxy) { } } -fn fetch_latest_version() -> Option { +/// 立即检查 GitHub 最新 release;调用方必须把它放到后台执行器,避免 +/// `curl` 的网络等待阻塞 UI 线程。 +pub fn check_now() -> Result { + let latest = fetch_latest_version()?; + let current = env!("CARGO_PKG_VERSION").to_owned(); + Ok(UpdateCheckResult { update_available: is_newer(&latest, ¤t), current, latest }) +} + +fn fetch_latest_version() -> Result { let mut command = Command::new("curl"); command.args([ "-fsSL", @@ -63,14 +86,28 @@ fn fetch_latest_version() -> Option { const CREATE_NO_WINDOW: u32 = 0x0800_0000; command.creation_flags(CREATE_NO_WINDOW); } - let output = command.output().ok()?; + let output = command.output().map_err(|error| format!("无法启动 curl:{error}"))?; if !output.status.success() { - return None; + let detail = String::from_utf8_lossy(&output.stderr); + let detail = detail.trim(); + return Err(if detail.is_empty() { + format!("GitHub 请求失败(curl {})", output.status) + } else { + format!("GitHub 请求失败:{detail}") + }); } - let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?; - let tag = json.get("tag_name")?.as_str()?; + let json: serde_json::Value = serde_json::from_slice(&output.stdout) + .map_err(|error| format!("GitHub 返回了无效数据:{error}"))?; + let tag = json + .get("tag_name") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "GitHub release 缺少 tag_name".to_owned())?; let version = tag.trim().trim_start_matches(['v', 'V']); - (!version.is_empty()).then(|| version.to_owned()) + if version.is_empty() { + Err("GitHub release 的版本号为空".to_owned()) + } else { + Ok(version.to_owned()) + } } /// Compare dotted numeric prefixes ("0.7.10" > "0.7.9"); anything after the diff --git a/nebula_app/src/window_context.rs b/nebula_app/src/window_context.rs index 603c286..5bd7a00 100644 --- a/nebula_app/src/window_context.rs +++ b/nebula_app/src/window_context.rs @@ -2177,6 +2177,9 @@ impl WindowContext { state.agent_hook_seen = true; state.agent_status_source = crate::ai_agents::AgentStatusSource::Hook; state.agent_status_rule = None; + // 精确边沿抵达 = 屏幕检测的空闲计数作废(上一回合攒下的拍数 + // 不能把新回合的第一个空闲闪现立即降级)。 + state.idle_screen_streak = 0; // 会话身份跟着事件走:同一个 pane 里 /clear、重开会话都会带来 // 新 id,最后一次上报永远是权威。133;D(CLI 退回提示符)清除。 if let Some(id) = ev.session_id.as_deref() { @@ -2216,13 +2219,15 @@ impl WindowContext { }, crate::ai_hook::AiHookKind::ToolComplete => { let state = &mut self.panes[idx].nebula_state; - // Tool completion is meaningful chiefly after a permission - // wait: the user answered and work resumed. - if state.agent_status == crate::ai_agents::AgentStatus::Blocked { - state.agent_status = crate::ai_agents::AgentStatus::Working; - state.awaiting_input = false; - state.needs_attention = false; - } + // 一个工具刚跑完 = agent 正在干活,这是无条件事实。此前只 + // 认 Blocked→Working,漏掉了「回合经不发 PromptSubmit 的路径 + // 继续(授权点头、队列消息)后状态还挂在 Done」的场景—— + // 也就是用户看到的「还在执行却已经显示完成蓝点」。 + state.agent_status = crate::ai_agents::AgentStatus::Working; + state.awaiting_input = false; + state.needs_attention = false; + state.finished_unseen = false; + state.command_started.get_or_insert_with(Instant::now); }, crate::ai_hook::AiHookKind::TurnDone | crate::ai_hook::AiHookKind::NeedsAttention => { // codex 的 notify 只有"回合完成"一种事件:弹出交互式提问时 @@ -2338,11 +2343,29 @@ impl WindowContext { }; let state = &mut pane.nebula_state; - // An exact hook-done must not be downgraded merely because an idle - // prompt is visible. Strong live blocker/working chrome may still - // correct a missed/new event. - if detection.status == crate::ai_agents::AgentStatus::Idle && state.agent_hook_seen { - continue; + // 空闲提示符降级要分三档(#「转圈不停」的根修): + // - hook 报过的 Done/Blocked 是精确终态,不被提示符降级; + // - Working 可能是丢了 TurnDone 的僵尸态(打断的回合没有 Stop + // 事件),但单拍空闲可能只是重绘间隙——连续两拍才收场; + // - 其余状态照常应用。 + if detection.status == crate::ai_agents::AgentStatus::Idle { + if state.agent_hook_seen + && matches!( + state.agent_status, + crate::ai_agents::AgentStatus::Done + | crate::ai_agents::AgentStatus::Blocked + ) + { + continue; + } + if state.agent_status == crate::ai_agents::AgentStatus::Working { + state.idle_screen_streak = state.idle_screen_streak.saturating_add(1); + if state.idle_screen_streak < 2 { + continue; + } + } + } else { + state.idle_screen_streak = 0; } let previous = state.agent_status; if previous != detection.status diff --git a/nebula_app/tests/file_line_budget.rs b/nebula_app/tests/file_line_budget.rs new file mode 100644 index 0000000..5d223eb --- /dev/null +++ b/nebula_app/tests/file_line_budget.rs @@ -0,0 +1,122 @@ +//! 文件行数预算守护(docs/project-constraints.md 第 1 条)。 +//! +//! 单个 `.rs` 文件硬上限 2000 行;存量超标文件在 [`GRANDFATHERED`] 登记, +//! 登记值只允许下调。测试失败的正确动作是拆文件,不是上调预算。 + +use std::fs; +use std::path::{Path, PathBuf}; + +/// 新代码红线:超过它就必须拆(docs/project-constraints.md)。 +const MAX_LINES: usize = 2000; + +/// 存量豁免清单(相对仓库根,正斜杠路径 → 行数上限)。 +/// +/// 登记值 = 收录当天的行数加不足百行的呼吸空间:小修小补不触线,成规模 +/// 新增必须伴随拆分。拆小后同步下调或删除对应条目。 +const GRANDFATHERED: &[(&str, usize)] = &[ + ("nebula_app/src/display/mod.rs", 12000), + ("nebula_app/src/display/settings.rs", 8650), + // 2026-08-17 上调(4850 → 5150):git 树 VS Code 化 + 源码 tab 接线; + // 下一步应把 VCS 面板拆成独立文件后下调。 + ("nebula_app/src/gpui_shell/workspace.rs", 5150), + // 2026-08-17 下调(4600 → 3600):SSH 区拆到 ssh_settings.rs 的成果锁定。 + ("nebula_app/src/gpui_shell/settings_pane.rs", 3600), + ("nebula_app/src/display/command_palette.rs", 3980), + ("nebula_app/src/window_context.rs", 3800), + ("nebula_app/src/event.rs", 3780), + ("nebula_terminal/src/term/mod.rs", 3650), + // 2026-08-17 上调(2900 → 2970):draw_overlays 几何抽成两壳共享的 plan。 + ("nebula_app/src/display/terminal_math.rs", 2970), + // 2026-08-17 上调(2680 → 2760):SVN wc.db 回退 + 单文件 stage/discard。 + ("nebula_app/src/display/side_panel.rs", 2760), + ("nebula_app/src/gpui_shell/terminal/view.rs", 2540), + ("nebula_app/src/display/chrome.rs", 2550), + ("nebula_app/src/display/markdown_view.rs", 2150), +]; + +/// 这些目录不受预算约束:构建产物、vendored 第三方代码、版本库内部。 +fn excluded(component: &str) -> bool { + component == ".git" + || component == "third_party" + || component == "target" + || component.starts_with(".target") +} + +fn collect_rust_files(root: &Path, out: &mut Vec) { + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + // 隐藏项(.git、.tmp_* 草稿等)不是源码,不受预算约束。 + if name.starts_with('.') { + continue; + } + if path.is_dir() { + if !excluded(&name) { + collect_rust_files(&path, out); + } + } else if path.extension().is_some_and(|extension| extension == "rs") { + out.push(path); + } + } +} + +#[test] +fn every_source_file_respects_its_line_budget() { + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("nebula_app sits directly under the repo root") + .to_path_buf(); + + let mut files = Vec::new(); + collect_rust_files(&repo_root, &mut files); + assert!( + files.len() > 100, + "行数守护扫到的文件数异常({}),排除规则可能吞掉了源码目录", + files.len() + ); + + let mut violations = Vec::new(); + let mut stale_grants = Vec::new(); + for path in files { + let relative = path + .strip_prefix(&repo_root) + .expect("collected files live under the repo root") + .to_string_lossy() + .replace('\\', "/"); + let Ok(content) = fs::read(&path) else { + panic!("无法读取 {relative}"); + }; + // 按换行字节计数:与编码无关,空文件为 0 行。 + let lines = content.iter().filter(|byte| **byte == b'\n').count() + + usize::from(!content.is_empty() && content.last() != Some(&b'\n')); + let grant = GRANDFATHERED + .iter() + .find(|(name, _)| *name == relative) + .map(|(_, budget)| *budget); + let budget = grant.unwrap_or(MAX_LINES); + if lines > budget { + violations.push(format!("{relative}: {lines} 行 > 预算 {budget}")); + } + // 豁免文件瘦身到红线以内后,条目必须删除,防止预算复胖。 + if let Some(budget) = grant + && lines <= MAX_LINES + { + stale_grants.push(format!("{relative}: 已降到 {lines} 行(≤{MAX_LINES}),豁免条目({budget})应删除")); + } + } + + assert!( + violations.is_empty(), + "以下文件超出行数预算(docs/project-constraints.md 第 1 条,正确动作是拆文件):\n{}", + violations.join("\n") + ); + assert!( + stale_grants.is_empty(), + "以下豁免条目已过期:\n{}", + stale_grants.join("\n") + ); +} diff --git a/nebula_settings/src/lib.rs b/nebula_settings/src/lib.rs index 474e45f..527ac28 100644 --- a/nebula_settings/src/lib.rs +++ b/nebula_settings/src/lib.rs @@ -550,6 +550,35 @@ impl NewTabPositionName { } } +/// 侧栏版本控制视图的数据源:自动探测(就近的 .svn 提示 + git 优先), +/// 或强制只认 Git / SVN(混合仓库、或想屏蔽其中一种时用)。 +#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] +pub enum VcsDisplayName { + #[default] + Auto, + Git, + Svn, +} + +impl VcsDisplayName { + pub fn from_settings(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "auto" => Some(Self::Auto), + "git" => Some(Self::Git), + "svn" => Some(Self::Svn), + _ => None, + } + } + + pub fn settings_value(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Git => "git", + Self::Svn => "svn", + } + } +} + #[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] pub enum CellWidthModeName { #[default] @@ -631,6 +660,8 @@ pub struct RuntimeSettings { pub density: DensityName, pub new_tab_position: NewTabPositionName, pub cell_width_mode: CellWidthModeName, + /// 侧栏版本控制视图的数据源(auto/git/svn)。 + pub vcs_display: VcsDisplayName, /// 新会话欢迎屏 fastfetch(默认关:启动速度优先于观感,旧壳裁定)。 pub fetch: bool, pub keep_session: bool, @@ -707,6 +738,10 @@ impl RuntimeSettings { .value("cell_width_mode") .and_then(CellWidthModeName::from_settings) .unwrap_or_default(), + vcs_display: raw + .value("vcs_display") + .and_then(VcsDisplayName::from_settings) + .unwrap_or_default(), fetch: raw.bool_on("fetch").unwrap_or(false), keep_session: raw.bool_on("keep_session").unwrap_or(false), restore_session: raw.bool_on("restore_session").unwrap_or(true), diff --git a/nebula_terminal/src/render.rs b/nebula_terminal/src/render.rs index bc40e85..9b001ff 100644 --- a/nebula_terminal/src/render.rs +++ b/nebula_terminal/src/render.rs @@ -210,6 +210,12 @@ pub struct CursorSnapshot { pub shape: CursorShape, /// Cursor sits on a wide char: draw it two cells wide. pub wide: bool, + /// Glyph under the cursor. Claude Code / Codex hide DECSCUSR and draw a + /// reverse-video space or block element; the frontend needs the cell to + /// recolor that fake cursor (old shell `is_application_cursor_cell`). + pub cell_ch: char, + pub cell_flags: Flags, + pub cell_bg: Color, } /// A cell rendered by built-in geometry ([`boxdraw`]) instead of a font: @@ -389,13 +395,16 @@ impl RenderSnapshot { flush(&mut snap.segments, &mut open); if let Some(vp) = cursor_vp { - if vp.line < rows && vp.column.0 < cols && cursor_shape != CursorShape::Hidden { - let wide = term.grid()[content.cursor.point].flags.contains(Flags::WIDE_CHAR); + if vp.line < rows && vp.column.0 < cols { + let cell = &term.grid()[content.cursor.point]; snap.cursor = Some(CursorSnapshot { row: vp.line as u16, col: vp.column.0 as u16, shape: cursor_shape, - wide, + wide: cell.flags.contains(Flags::WIDE_CHAR), + cell_ch: cell.c, + cell_flags: cell.flags, + cell_bg: cell.bg, }); } } @@ -553,6 +562,40 @@ mod tests { assert!(snap.cursor.expect("cursor").wide); } + #[test] + fn capture_keeps_a_hidden_cursor_and_its_cell() { + use crate::vte::ansi::{Handler, NamedPrivateMode}; + + let mut term = term_with(&[" "]); + term.grid_mut().cursor.point = crate::index::Point::new(Line(0), Column(0)); + term.grid_mut()[Line(0)][Column(0)].flags.insert(Flags::INVERSE); + term.unset_private_mode(NamedPrivateMode::ShowCursor.into()); + let snap = RenderSnapshot::capture(&term, &cfg(4, 8)); + let cursor = snap.cursor.expect("hidden cursor still has a cell"); + assert_eq!(cursor.shape, CursorShape::Hidden); + assert_eq!((cursor.row, cursor.col), (0, 0)); + assert!(cursor.cell_flags.contains(Flags::INVERSE)); + assert_eq!(cursor.cell_ch, ' '); + assert!( + snap.bg_runs.iter().any(|run| run.row == 0 && run.start <= 0 && 0 < run.end), + "inverse space still emits a bg run so the frontend can skip the black cell" + ); + } + + #[test] + fn capture_keeps_decscusr_hidden_block_glyph() { + use crate::vte::ansi::Handler; + + let mut term = term_with(&["█"]); + term.grid_mut().cursor.point = crate::index::Point::new(Line(0), Column(0)); + term.set_cursor_shape(CursorShape::Hidden); + let snap = RenderSnapshot::capture(&term, &cfg(4, 8)); + let cursor = snap.cursor.expect("DECSCUSR hidden cursor still has a cell"); + assert_eq!(cursor.shape, CursorShape::Hidden); + assert_eq!(cursor.cell_ch, '█'); + assert_eq!(snap.box_glyphs.iter().map(|g| g.ch).collect::>(), vec!['█']); + } + #[test] fn capture_routes_builtin_glyphs_to_geometry() { let term = term_with(&["a─█b"]); diff --git a/scripts/ui_probe.ps1 b/scripts/ui_probe.ps1 index a115ef6..81bb7ae 100644 --- a/scripts/ui_probe.ps1 +++ b/scripts/ui_probe.ps1 @@ -24,6 +24,32 @@ public class W { [DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr h, IntPtr dc, uint flags); [DllImport("user32.dll")] public static extern uint GetDpiForWindow(IntPtr h); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); + public delegate bool EnumProc(IntPtr h, IntPtr lparam); + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr lparam); + // Process.MainWindowHandle picks whatever top-level window answers first -- + // for nebula that is a 6x6 helper window (and a minimized one reports + // -32000,-32000). Enumerate instead and keep the largest visible window + // owned by the pid: that is the real shell window in both shells. + public static IntPtr FindMainWindow(int pid) { + IntPtr best = IntPtr.Zero; + long bestArea = 0; + EnumWindows(delegate(IntPtr h, IntPtr l) { + uint wpid; + GetWindowThreadProcessId(h, out wpid); + if (wpid != (uint)pid) return true; + if (!IsWindowVisible(h)) return true; + RECT r; + GetWindowRect(h, out r); + long area = (long)(r.R - r.L) * (long)(r.B - r.T); + // Minimized windows sit at -32000; never let one win the comparison. + if (r.L <= -30000 || r.T <= -30000) return true; + if (area > bestArea) { bestArea = area; best = h; } + return true; + }, IntPtr.Zero); + return best; + } public struct RECT { public int L, T, R, B; } } '@ @@ -45,8 +71,14 @@ if ($ProcId -eq 0) { $proc = Get-Process -Id $ProcId -ErrorAction SilentlyContinue if (-not $proc) { Write-Output "PROCESS $ProcId GONE"; exit 1 } if ($Kill) { $proc.Kill(); Write-Output "killed $ProcId"; exit 0 } -$h = $proc.MainWindowHandle -if ($h -eq 0) { Write-Output "NO WINDOW for $ProcId"; exit 1 } +$h = [W]::FindMainWindow($ProcId) +if ($h -eq [IntPtr]::Zero) { + # Fall back to the (unreliable) handle so a minimized-only state still + # reports something actionable instead of looking like a dead process. + $h = $proc.MainWindowHandle + if ($h -eq 0) { Write-Output "NO WINDOW for $ProcId"; exit 1 } + Write-Output "WARN: no visible top-level window; falling back to MainWindowHandle" +} $r = New-Object W+RECT [void][W]::GetWindowRect($h, [ref]$r) Write-Output "pid=$ProcId rect=($($r.L),$($r.T),$($r.R),$($r.B)) size=$($r.R-$r.L)x$($r.B-$r.T) dpi=$([W]::GetDpiForWindow($h))" @@ -80,7 +112,11 @@ if ($RightClick -ne "") { if ($Scroll -ne 0) { $sx = [int]($r.L + ($r.R - $r.L) * 0.6); $sy = [int]($r.T + ($r.B - $r.T) * 0.5) [void][W]::SetCursorPos($sx, $sy); Start-Sleep -Milliseconds 150 - [W]::mouse_event(0x0800, 0, 0, [uint32]($Scroll * 120), [UIntPtr]::Zero) + # WHEEL_DELTA is signed (negative scrolls down) but the P/Invoke signature + # declares dwData as uint: casting a negative int straight to [uint32] + # throws InvalidCast. Mask to the two's-complement bit pattern instead. + $wheel = [uint32]([int64]($Scroll * 120) -band 0xFFFFFFFFL) + [W]::mouse_event(0x0800, 0, 0, $wheel, [UIntPtr]::Zero) Start-Sleep -Milliseconds 500 } if ($CtrlScroll -ne 0) {