diff --git a/.gitignore b/.gitignore index 8b535ee..5e499d1 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,9 @@ tocodex-docs/ # `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. +# Third-party research (gap analyses, design studies, local checkouts) stays in +# this private tree and must never be allowlisted — tracked files bypass these +# rules until `git rm --cached`. /docs/** !/docs/lua-configuration.md !/docs/math-rendering-test.md @@ -39,6 +42,10 @@ tocodex-docs/ !/docs/release-notes/** !/docs/screenshots/ !/docs/screenshots/** +# Belt-and-suspenders: even if a later allowlist opens more of docs/, +# research filenames stay unpublished. +docs/**/*-gap-analysis.md +docs/**/*-study.md # Local interactive demos belong under the private docs tree. /*.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 74ffab2..7765226 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,16 +4,16 @@ Every release entry is provided in English and Simplified Chinese. 每个版本条目均同时提供英文和简体中文说明。 -## Unreleased +## 1.1.0 - 2026-08-17 ### English #### Added -- **Remote backups over multiple protocols** — the Settings → Backup page is now available and gains a "Remote backup" group that pushes the existing password-protected archive (Argon2id + AES-256-GCM; servers only ever see ciphertext) to a destination of choice: a local/network folder (NAS UNC paths), WebDAV, S3-compatible storage (AWS/MinIO/R2/B2 via SigV4, path-style), or SFTP reusing saved SSH hosts and their authentication. Archives are timestamped (`nebula-backup-YYYYMMDD-HHMMSS.nbk`), the newest 10 are retained per destination, and "Restore latest" fetches and applies the most recent one after passphrase entry. WebDAV/S3 secrets live in the Windows Credential Manager (env-var fallback elsewhere), non-secret settings persist in `nebula_backup.txt`, and all packing, key derivation, and network I/O run on a background thread. Provider surface modeled on Netcatty's cloud-sync adapters (its OAuth providers — GitHub/Google Drive/OneDrive — are out of scope for now). +- **Remote backups over multiple protocols** — the Settings → Backup page is now available and gains a "Remote backup" group that pushes the existing password-protected archive (Argon2id + AES-256-GCM; servers only ever see ciphertext) to a destination of choice: a local/network folder (NAS UNC paths), WebDAV, S3-compatible storage (AWS/MinIO/R2/B2 via SigV4, path-style), or SFTP reusing saved SSH hosts and their authentication. Archives are timestamped (`nebula-backup-YYYYMMDD-HHMMSS.nbk`), the newest 10 are retained per destination, and "Restore latest" fetches and applies the most recent one after passphrase entry. WebDAV/S3 secrets live in the Windows Credential Manager (env-var fallback elsewhere), non-secret settings persist in `nebula_backup.txt`, and all packing, key derivation, and network I/O run on a background thread. GitHub/Google Drive/OneDrive OAuth destinations are out of scope for now. - **Two completion styles with a switch** — Settings → Terminal → Completion adds a "Completion style" choice between the existing inline ghost text and a new popup candidate list. The popup gathers history, frecency directories, PATH commands, and filesystem matches (up to 8, with source tags), navigates with Up/Down, accepts with the configured accept key (Tab/Right), and dismisses with Esc without re-opening until the line changes. A command-palette action toggles the style, and the choice persists as `completion_style`. - **A real color picker for the custom background** — the background-color popup now leads with a saturation/value plane and a hue bar; dragging picks continuously with live terminal preview and persists on release. The preset swatches and the hex field remain, and all three inputs stay in sync (including hue retention across gray/black/white picks). -- **Clipboard screenshot paste, locally and over SSH** — pasting with an image-only clipboard (e.g. right after Win+Shift+S) now converts the bitmap to PNG and pastes a file path instead of nothing. Local panes write a temp file; SSH panes upload via the existing SFTP stack to `/tmp/nebula-paste-.png` in the background and then type the remote path into the pane — so image-accepting CLIs like codex and claude receive a usable path on both sides (interaction modeled on Netcatty). +- **Clipboard screenshot paste, locally and over SSH** — pasting with an image-only clipboard (e.g. right after Win+Shift+S) now converts the bitmap to PNG and pastes a file path instead of nothing. Local panes write a temp file; SSH panes upload via the existing SFTP stack to `/tmp/nebula-paste-.png` in the background and then type the remote path into the pane — so image-accepting CLIs like codex and claude receive a usable path on both sides. - **Configurable new-tab placement** — Settings → Interaction can place newly created tabs immediately after the active tab or at the end of the tab list. The choice persists, while restored tabs keep their original ordering semantics. - **Configurable terminal cell width** — Settings → Appearance offers Compact and Relaxed cell-width modes. Changing the mode immediately rebuilds font metrics, the terminal grid, pane layout, and PTY dimensions, and the choice persists across launches. - **Ordered font fallback chains** — `font_family` accepts comma-separated families such as `JetBrains Mono, LXGW WenKai, Cascadia Code`. The first family remains the primary face, later families fill missing glyphs in order for regular, bold, italic, and bold-italic text, and the embedded Maple font remains the final fallback. System and imported/private fonts now use the same family lookup path for validation, preview, and rendering. (#33) @@ -21,6 +21,10 @@ Every release entry is provided in English and Simplified Chinese. - **Audible terminal bell** — BEL (`\a`) now plays the system notification sound in addition to the visual bell, so an AI CLI finishing a turn is audible even from another tab. Throttled so a bell-happy program cannot machine-gun it; disable with `bell.audible = false`. (#37) - **AI conversations resume across restarts** — panes that had a claude/codex conversation open when Nebula closed (or crashed) now type the exact resume command (`claude --resume ` / `codex resume `) into the restored shell automatically. Session identity comes from the CLIs' own hook payloads; a claude detected without an id falls back to `claude --continue` in the restored directory. Injection only targets plain restored shells (never SSH/profile seeds), ids are validated before anything reaches the terminal, and Settings → Advanced offers the "resume AI conversations on restore" switch (`resume_ai`, on by default). - **System tray icon with agent attention** — a resident tray icon flips to an amber-dot state whenever any AI CLI stops and waits for input, even with every window buried or minimized. Right-click lists all agent panes with their state (running / waiting) and jumps straight to the source pane through the same focus path as toast clicks; left-click goes to the most urgent pane. The tray mirrors the sidebar badges (one source of truth) and can be turned off in Settings → Advanced (`tray`, on by default). +- **Line numbers for plain-text files (GPUI shell)** — double-clicking `.txt` / `.log` / `.json` / `.jsonl` files in the file tree now opens the code viewer with line numbers and per-line virtualization (same as source files) instead of the markdown document view; Markdown keeps the rich reader. +- **Clickable file and URL links in the GPUI shell** — OSC 8 hyperlinks and matched URLs draw a dashed underline in the cell's own foreground color. Hovering shows a preview (`decoded path · Ctrl+click`); Ctrl+click opens local `file://` paths in Explorer and other URIs with the default handler. +- **Configurable terminal bell in the GPUI shell** — Settings → Profiles → Terminal bell chooses Off, Flash, Sound, or Flash + sound. A BEL still plays the throttled system beep, briefly flashes the pane, toasts when the window is unfocused, and dots background tabs. The choice persists as `bell` (`none` / `visual` / `audible` / `both`, default both). +- **WYSIWYG font picker and folder-picker startup directory (GPUI shell)** — the font dropdown renders each family in its own face, strips junk extensions from display names, and the import button is just "Import font". Startup directory is a folder picker with "inherit current" / Clear, matching the legacy shell. #### Fixed @@ -30,19 +34,23 @@ Every release entry is provided in English and Simplified Chinese. - **Visible Windows windows recover from stuck render gates** — startup-time occlusion misreports and missing frame callbacks can no longer leave an already visible window accepting input without repainting. Occlusion is ignored unless the window is actually minimized, the existing 1 Hz window heartbeat idempotently releases stale `occluded` and `has_frame` gates, and the watchdog is now armed at window creation so even a freeze before the first frame self-heals within a second. (#21, #32) - **Drive-root context menu launches work** — right-clicking the background of `D:\` (any drive root) and choosing "Open in Nebula" failed with "invalid working directory": Explorer expands `%V` to `D:\`, whose trailing backslash escapes the closing quote on the command line. The mangled path is now repaired, and the error's log hint uses PowerShell syntax (`$env:NEBULA_LOG`) so copy-paste resolves. (#36) - **Multi-line paste stops interrupting codex** — the multi-line paste confirmation now only fires when newlines are actually headed to a shell that would execute them line by line. Applications in bracketed-paste mode (codex, vim, modern PSReadLine) receive the paste as one atomic chunk, so the warning no longer blocks them. The dark-theme "Enter" keycap on the confirm dialog's accent button also derives from the button's own ink instead of the near-black panel color. (#35) +- **Math overlays survive markdown-unescaped delimiters (GPUI shell)** — some AI CLIs render their markdown before printing and eat the backslashes of `\[ \]` / `\( \)` (markdown punctuation escapes), leaving bare `[ … ]` blocks and `(\sqrt{…})` spans that never overlaid. The shared scanner now recognizes those bare forms when the content carries a known TeX command (`\int`, `\frac`, …) and the brackets sit alone on their line edges; JSON arrays, `[INFO]` logs and regex `(\d+)` stay literal. Explicit TeX lengths (`\\[6pt]`, `\kern`) also regained their point-to-pixel scale in the GPUI shell, matching the legacy renderer. +- **Markdown reader fills the reading column (GPUI shell)** — soft line breaks inside a paragraph (hand-wrapped README prose) rendered as hard line breaks, leaving the right side of the column empty and ragged. They now merge into spaces per CommonMark, with CJK neighbours joining directly. +- **Markdown images actually load (GPUI shell)** — the document tab now resolves relative image paths against the document's own directory, loads absolute local paths from disk, and fetches `http(s)` images through a ureq HTTP client on the background executor (gpui ships a null client by default), so README logos, shields badges (SVG included) and screenshots finally display. #### Improved - **File/Git side-panel refreshes no longer block rendering** — directory traversal and Git status subprocesses now build a snapshot on a worker thread while the existing view stays usable. Completed snapshots are swapped in atomically; stale-root results are discarded and active search results are not overwritten. +- **Tab disclosure and file-tree menus in the GPUI shell** — the TABS collapse control uses the shell's linear chevrons instead of Nerd Font glyphs. File-tree context menus no longer pick up the drawer drop shadow, so they match the tab menus. ### 简体中文 #### 新增 -- **多协议远程备份** — “设置 → 备份”页面正式开放,并新增「远程备份」组:把既有的口令保护归档(Argon2id + AES-256-GCM,服务器只见密文)推送到所选目的地——本地/网络目录(含 NAS 的 UNC 路径)、WebDAV、S3 兼容存储(SigV4 签名、路径式 URL,AWS/MinIO/R2/B2 通吃)、或复用已保存 SSH 主机及其认证的 SFTP。归档按时间戳命名(`nebula-backup-YYYYMMDD-HHMMSS.nbk`),每个远端保留最近 10 份;「恢复最新备份」取回最新一份、输口令后落盘。WebDAV/S3 的密钥存 Windows 凭据管理器(其他平台走环境变量),非密文配置持久化在 `nebula_backup.txt`;打包、密钥派生与网络全部在后台线程完成。协议面参考 Netcatty 的云同步适配器(其 GitHub/Google Drive/OneDrive 三个 OAuth 后端暂不纳入)。 +- **多协议远程备份** — “设置 → 备份”页面正式开放,并新增「远程备份」组:把既有的口令保护归档(Argon2id + AES-256-GCM,服务器只见密文)推送到所选目的地——本地/网络目录(含 NAS 的 UNC 路径)、WebDAV、S3 兼容存储(SigV4 签名、路径式 URL,AWS/MinIO/R2/B2 通吃)、或复用已保存 SSH 主机及其认证的 SFTP。归档按时间戳命名(`nebula-backup-YYYYMMDD-HHMMSS.nbk`),每个远端保留最近 10 份;「恢复最新备份」取回最新一份、输口令后落盘。WebDAV/S3 的密钥存 Windows 凭据管理器(其他平台走环境变量),非密文配置持久化在 `nebula_backup.txt`;打包、密钥派生与网络全部在后台线程完成。GitHub/Google Drive/OneDrive 三个 OAuth 后端暂不纳入。 - **两种补全样式可切换** — “设置 → 终端 → 补全”新增「补全样式」:在既有的行内灰字与新的弹窗候选列表之间选择。弹窗汇总历史命令、常用目录、PATH 命令与文件系统匹配(至多 8 项,带来源标签),↑/↓ 选行、按补全接受键(Tab/→)接受、Esc 关闭且同一行不再重弹;命令面板提供切换动作,选择以 `completion_style` 持久化。 - **自定义背景色改用真调色盘** — 背景色浮层顶部新增饱和度/明度取色面与色相条,按住拖动即连续取色、终端实时预览、松手落盘。预设色板与 16 进制输入保留,三种输入互相同步(灰/黑/白取色时保留既有色相)。 -- **剪贴板截图粘贴(本地与 SSH)** — 剪贴板只有位图没有文本时(如 Win+Shift+S 之后),粘贴会把位图转成 PNG 并粘出文件路径:本地 pane 写入临时文件;SSH pane 经既有 SFTP 栈后台上传到远端 `/tmp/nebula-paste-<时间戳>.png` 再把远端路径敲进会话——codex/claude 这类接受图片路径的 CLI 在两侧都能直接用(交互参考 Netcatty)。 +- **剪贴板截图粘贴(本地与 SSH)** — 剪贴板只有位图没有文本时(如 Win+Shift+S 之后),粘贴会把位图转成 PNG 并粘出文件路径:本地 pane 写入临时文件;SSH pane 经既有 SFTP 栈后台上传到远端 `/tmp/nebula-paste-<时间戳>.png` 再把远端路径敲进会话——codex/claude 这类接受图片路径的 CLI 在两侧都能直接用。 - **可配置新标签页插入位置** — “设置 → 交互”可选择把新建标签页插在当前标签页之后,或放到标签列表末尾。选择会持久化;恢复会话中的标签页仍保持原有顺序语义。 - **可配置终端单元格宽度** — “设置 → 外观”新增“紧凑”和“宽松”两种单元格宽度模式。切换后会立即重算字体度量、终端网格、分屏布局和 PTY 尺寸,并在下次启动时保留选择。 - **有序字体 fallback 链** — `font_family` 支持逗号分隔的字体族,例如 `JetBrains Mono, 霞鹜文楷, Cascadia Code`。第一个字体族仍是主字体,后续字体族按顺序为常规、粗体、斜体和粗斜体补齐缺失字形,内置 Maple 字体始终作为最后兜底。系统字体与导入/私有字体现在统一通过同一族名查找路径完成校验、预览和渲染。(#33) @@ -50,6 +58,10 @@ Every release entry is provided in English and Simplified Chinese. - **终端铃声** — BEL(`\a`)现在在视觉铃声之外播放系统提示音:AI CLI 在别的标签页里完成回合也听得见。内置节流,刷铃声的程序不会连成机关枪;`bell.audible = false` 可关闭。(#37) - **AI 对话跨重启接续** — 关闭(或崩溃)时某个 pane 里还开着 claude/codex 对话的,冷恢复后会自动把 resume 命令(`claude --resume ` / `codex resume `)敲进恢复出来的 shell。会话身份来自 CLI 自己的 hook 载荷;识别到 claude 但没有 id 时退化为在恢复目录里 `claude --continue`。注入只针对恢复出的裸 shell(绝不注入 SSH/Profile 首格),id 上屏前先做字符集校验;“设置 → 高级”提供「恢复时自动接续 AI 对话」开关(`resume_ai`,默认开)。 - **托盘图标 agent 提醒** — 常驻系统托盘图标:任一 AI CLI 停下来等输入时翻转为橙点 attention 态,窗口全被压住或最小化也看得见。右键列出所有 agent pane 及状态(运行中/等待输入),点击经 toast 同一条聚焦路径直达来源 pane;左键直达最需要人的那个。托盘与侧栏徽章同一事实源;“设置 → 高级”可关(`tray`,默认开)。 +- **纯文本文件带行号打开(GPUI 壳)** — 文件树双击 `.txt` / `.log` / `.json` / `.jsonl` 现在进代码查看器:行号 + 按可视行虚拟化(与源码文件同款),不再进 markdown 文档视图;Markdown 仍走富文本阅读器。 +- **GPUI 壳可点击文件与 URL** — OSC 8 超链接和匹配到的 URL 用格子自身前景色画虚线下划线。悬停显示预览(`解码后的路径 · Ctrl+点击`);Ctrl+点击用资源管理器打开本地 `file://`,其余 URI 走默认打开方式。 +- **GPUI 壳可配置终端铃声** — “设置 → 配置文件 → 终端铃声”可选关 / 闪烁 / 声音 / 闪烁 + 声音。BEL 仍播放节流后的系统提示音、短暂闪一下 pane、窗口失焦时出 toast、后台 tab 打点。选择以 `bell` 持久化(`none` / `visual` / `audible` / `both`,默认两者都开)。 +- **GPUI 壳所见即所得字体选择器与启动目录** — 字体下拉用各族自己的字形渲染,展示名剥掉多余扩展名,导入按钮改为「导入字体」。启动目录改为选文件夹,「继承当前目录」/「清除」,与旧壳一致。 #### 修复 @@ -59,10 +71,14 @@ Every release entry is provided in English and Simplified Chinese. - **Windows 可见窗口可从卡死的渲染门控中恢复** — 启动期的遮挡误报或帧回调丢失不再让可见窗口陷入“能接收输入但不重绘”。窗口没有真正最小化时会忽略遮挡误报,既有的 1 Hz 窗口心跳也会以幂等方式释放卡住的 `occluded` 与 `has_frame` 门控;看门狗现在在建窗时即刻武装,首帧之前的冻结也能在一秒内自愈。(#21、#32) - **盘符根目录右键启动可用** — 在 `D:\` 这类盘符根目录背景右键「在 Nebula 中打开」此前报「无效的工作目录」:资源管理器把 `%V` 展开成 `D:\`,末尾反斜杠在命令行上转义了收尾引号。被吃掉的路径现在会被修复;错误提示里的日志变量改用 PowerShell 语法(`$env:NEBULA_LOG`),复制即可用。(#36) - **多行粘贴不再打断 codex** — 多行粘贴确认现在只在换行真的会被 shell 逐行执行时弹出。处于 bracketed paste 模式的应用(codex、vim、新版 PSReadLine)会把粘贴当作一个整体接收,警告不再拦路。确认框里深色主题下 accent 主按钮上的「Enter」键帽也改从按钮自身墨色派生,不再是一块近黑色。(#35) +- **公式覆盖层兼容被 markdown 反转义的定界符(GPUI 壳)** — 部分 AI CLI 先渲染 markdown 再上屏,`\[ \]` / `\( \)` 的反斜杠被当作 markdown 标点转义吃掉,屏幕上只剩裸 `[ … ]` 块与 `(\sqrt{…})`,公式从不渲染。共享扫描器现在识别这些裸形态:内容须携带已知 TeX 命令(`\int`、`\frac`……)且方括号独占行首/行尾;JSON 数组、`[INFO]` 日志、正则 `(\d+)` 保持原样。GPUI 壳里 TeX 显式长度(`\\[6pt]`、`\kern`)的 pt→px 换算也已对齐旧壳。 +- **Markdown 阅读器铺满阅读列(GPUI 壳)** — 段落内的软换行(README 手工折行的正文)此前渲染成硬换行,右侧留出一大片参差空白。现按 CommonMark 合并为空格,中日韩相邻行直接相连不插空格。 +- **Markdown 图片真正能加载(GPUI 壳)** — 文档 tab 现在把相对图片路径按文档所在目录解析、绝对路径直接读盘、`http(s)` 图源经后台 ureq 客户端拉取(gpui 默认装的是空客户端),README 的 logo、shields 徽章(含 SVG)与截图终于都能显示。 #### 改进 - **文件/Git 侧栏刷新不再阻塞渲染** — 目录遍历与 Git 状态子进程改为在工作线程中生成快照,旧内容在刷新期间仍可正常使用。完成后的快照会整体替换;根目录已变化的过期结果会被丢弃,正在显示的搜索结果也不会被覆盖。 +- **GPUI 壳 Tab 折叠箭头与文件树菜单** — TABS 折叠控件改用壳自带的线性 Chevron,不再用 Nerd Font 字形。文件树右键菜单不再叠上抽屉阴影,观感与 Tab 菜单一致。 ## 1.0.0 - 2026-08-10 diff --git a/Cargo.lock b/Cargo.lock index 55b894a..c5b899d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4629,7 +4629,7 @@ dependencies = [ [[package]] name = "nebula" -version = "1.0.0" +version = "1.1.0" dependencies = [ "ab_glyph_rasterizer", "aes-gcm", @@ -4706,7 +4706,7 @@ dependencies = [ [[package]] name = "nebula-completions" -version = "1.0.0" +version = "1.1.0" dependencies = [ "lscolors", "nu-ansi-term", @@ -4726,15 +4726,15 @@ dependencies = [ [[package]] name = "nebula-settings" -version = "1.0.0" +version = "1.1.0" [[package]] name = "nebula-split" -version = "1.0.0" +version = "1.1.0" [[package]] name = "nebula_config" -version = "1.0.0" +version = "1.1.0" dependencies = [ "log", "nebula_config_derive", @@ -4744,7 +4744,7 @@ dependencies = [ [[package]] name = "nebula_config_derive" -version = "1.0.0" +version = "1.1.0" dependencies = [ "log", "nebula_config", @@ -4761,7 +4761,7 @@ version = "0.6.0" [[package]] name = "nebula_terminal" -version = "1.0.0" +version = "1.1.0" dependencies = [ "base64", "bitflags 2.13.0", diff --git a/Cargo.toml b/Cargo.toml index b63b3e2..8df0224 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,20 +77,19 @@ winit = { path = "third_party/winit-0.30.13" } # # 加补丁 = 提交到 fork 的 nebula 分支 → push → 更新下面的 rev(永远 pin # exact rev、不 pin branch)。每个补丁注明原因 + upstream PR 链接,上游合并 -# 即撤;升级配对版本 = rebase nebula 分支到新发布基点(参照 ../tty7 的 -# Cargo.toml 注释风格与守护测试做法)。gpui 的仓内兄弟 crate(gpui_macros、 -# sum_tree、zed-reqwest 等)经 git 仓库内 path 依赖自动解析,无需逐个 patch。 +# 即撤;升级配对版本 = rebase nebula 分支到新发布基点。gpui 的仓内兄弟 crate +# (gpui_macros、sum_tree、zed-reqwest 等)经 git 仓库内 path 依赖自动解析, +# 无需逐个 patch。 # -# 升级基点到 gpui-component 0.5.2+ 时的回归检查单(2026-08-13 对照 -# l0ng-ai/gpui-component 的 tty7 分支得出;0.5.1 上这些行为本来是对的, -# 是 0.5.2 重构 button 颜色推导时弄丢的,届时把 tty7 的两个 fix 带上): +# 升级基点到 gpui-component 0.5.2+ 时的回归检查单(0.5.1 上这些行为本来是对的, +# 是 0.5.2 重构 button 颜色推导时弄丢的,届时把下面两个 fix 带上): # 1. ButtonVariant::Custom 的 text_color 必须回 colors.foreground(0.5.2 -# 回归成背景色 colors.color,透明底按钮字直接隐形)→ tty7 3826fafacd3c +# 回归成背景色 colors.color,透明底按钮字直接隐形)→ 3826fafacd3c # 2. hovered()/active() 必须读 ButtonCustomVariant::hover/active(0.5.2 # 回归成从 color 派生,透明底按钮没有任何悬停/按下反馈)→ 469ab7a44f72 # 3. 新增的 text/inline_flow.rs:shape_line 前把 \n\r 换成空格(gpui # shaper 遇换行 panic;替换保 byte 长度,run 偏移不失效) -# 本地 fork 工作拷贝在 ../gpui-component-fork(已挂 tty7 remote 可 diff)。 +# 本地 fork 工作拷贝在 ../gpui-component-fork。 # # 上游行为备忘(不是补丁,是调用侧要知道的事):`Scrollable` # (scroll/scrollable.rs 的 overflow_*_scrollbar)不是就地加滚动条,而是把 diff --git a/nebula-completions/Cargo.toml b/nebula-completions/Cargo.toml index e0b5aa2..16d0622 100644 --- a/nebula-completions/Cargo.toml +++ b/nebula-completions/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula-completions" -version = "1.0.0" +version = "1.1.0" edition.workspace = true [features] diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index 5d9a3f9..31c9323 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula" -version = "1.0.0" +version = "1.1.0" license = "GPL-3.0-or-later" description = "Nebula Terminal — a premium GPU-accelerated terminal emulator" readme = "README.md" @@ -15,15 +15,15 @@ path = "src/main.rs" [dependencies.nebula_terminal] path = "../nebula_terminal" -version = "1.0.0" +version = "1.1.0" [dependencies.nebula_config_derive] path = "../nebula_config_derive" -version = "1.0.0" +version = "1.1.0" [dependencies.nebula_config] path = "../nebula_config" -version = "1.0.0" +version = "1.1.0" # 可选:GPUI UI 层(feature = "gpui-shell",模块 src/gpui_shell/)。 # gpui 与 gpui-component 成对锁定:0.5.1 编译在 gpui 0.2.2 上;两者由根 @@ -59,7 +59,7 @@ optional = true [dependencies.nebula-completions] path = "../nebula-completions" -version = "1.0.0" +version = "1.1.0" [dependencies] ahash = { version = "0.8.6", features = ["no-rng"] } diff --git a/nebula_app/src/agent_detection/amp.toml b/nebula_app/src/agent_detection/amp.toml index ee2c509..0d47f1d 100644 --- a/nebula_app/src/agent_detection/amp.toml +++ b/nebula_app/src/agent_detection/amp.toml @@ -1,4 +1,4 @@ -# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +# Screen-chrome evidence for this CLI (idle / working / blocked). # OSC-title-only rules are omitted because Nebula detects against terminal text. id = "amp" aliases = ["amp-local"] diff --git a/nebula_app/src/agent_detection/antigravity.toml b/nebula_app/src/agent_detection/antigravity.toml index 1c0369f..c83a3c9 100644 --- a/nebula_app/src/agent_detection/antigravity.toml +++ b/nebula_app/src/agent_detection/antigravity.toml @@ -1,4 +1,4 @@ -# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +# Screen-chrome evidence for this CLI (idle / working / blocked). id = "antigravity" aliases = ["agy", "antigravity-cli"] diff --git a/nebula_app/src/agent_detection/claude.toml b/nebula_app/src/agent_detection/claude.toml index ae9b81f..3a4b96b 100644 --- a/nebula_app/src/agent_detection/claude.toml +++ b/nebula_app/src/agent_detection/claude.toml @@ -1,4 +1,4 @@ -# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +# Screen-chrome evidence for this CLI (idle / working / blocked). id = "claude" aliases = ["claude-code"] diff --git a/nebula_app/src/agent_detection/cline.toml b/nebula_app/src/agent_detection/cline.toml index 17085ab..aaf8cfc 100644 --- a/nebula_app/src/agent_detection/cline.toml +++ b/nebula_app/src/agent_detection/cline.toml @@ -1,4 +1,4 @@ -# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +# Screen-chrome evidence for this CLI (idle / working / blocked). # The upstream catch-all working rule is intentionally omitted: it would make # every visible Cline screen look busy after a turn has already stopped. id = "cline" diff --git a/nebula_app/src/agent_detection/codex.toml b/nebula_app/src/agent_detection/codex.toml index bfd5975..1bd81ee 100644 --- a/nebula_app/src/agent_detection/codex.toml +++ b/nebula_app/src/agent_detection/codex.toml @@ -1,4 +1,4 @@ -# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +# Screen-chrome evidence for this CLI (idle / working / blocked). id = "codex" [[rules]] diff --git a/nebula_app/src/agent_detection/copilot.toml b/nebula_app/src/agent_detection/copilot.toml index a2ec0f3..290e3b0 100644 --- a/nebula_app/src/agent_detection/copilot.toml +++ b/nebula_app/src/agent_detection/copilot.toml @@ -1,4 +1,4 @@ -# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +# Screen-chrome evidence for this CLI (idle / working / blocked). id = "copilot" aliases = ["github-copilot", "ghcs"] diff --git a/nebula_app/src/agent_detection/cursor.toml b/nebula_app/src/agent_detection/cursor.toml index c11fa8c..8c383ea 100644 --- a/nebula_app/src/agent_detection/cursor.toml +++ b/nebula_app/src/agent_detection/cursor.toml @@ -1,4 +1,4 @@ -# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +# Screen-chrome evidence for this CLI (idle / working / blocked). id = "cursor" aliases = ["cursor-agent"] diff --git a/nebula_app/src/agent_detection/devin.toml b/nebula_app/src/agent_detection/devin.toml index cf2cb0c..30a5fab 100644 --- a/nebula_app/src/agent_detection/devin.toml +++ b/nebula_app/src/agent_detection/devin.toml @@ -1,4 +1,4 @@ -# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +# Screen-chrome evidence for this CLI (idle / working / blocked). id = "devin" aliases = ["devin-cli"] diff --git a/nebula_app/src/agent_detection/droid.toml b/nebula_app/src/agent_detection/droid.toml index 9ff6758..4be3377 100644 --- a/nebula_app/src/agent_detection/droid.toml +++ b/nebula_app/src/agent_detection/droid.toml @@ -1,4 +1,4 @@ -# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +# Screen-chrome evidence for this CLI (idle / working / blocked). id = "droid" [[rules]] diff --git a/nebula_app/src/agent_detection/gemini.toml b/nebula_app/src/agent_detection/gemini.toml index 1d4a7fd..8824ce2 100644 --- a/nebula_app/src/agent_detection/gemini.toml +++ b/nebula_app/src/agent_detection/gemini.toml @@ -1,4 +1,4 @@ -# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +# Screen-chrome evidence for this CLI (idle / working / blocked). id = "gemini" aliases = ["gemini-cli"] diff --git a/nebula_app/src/agent_detection/grok.toml b/nebula_app/src/agent_detection/grok.toml index 05f4e55..e851644 100644 --- a/nebula_app/src/agent_detection/grok.toml +++ b/nebula_app/src/agent_detection/grok.toml @@ -1,4 +1,4 @@ -# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +# Screen-chrome evidence for this CLI (idle / working / blocked). id = "grok" aliases = ["grok-cli", "grok-build"] diff --git a/nebula_app/src/agent_detection/hermes.toml b/nebula_app/src/agent_detection/hermes.toml index fc082a7..1e36180 100644 --- a/nebula_app/src/agent_detection/hermes.toml +++ b/nebula_app/src/agent_detection/hermes.toml @@ -1,4 +1,4 @@ -# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +# Screen-chrome evidence for this CLI (idle / working / blocked). id = "hermes" aliases = ["hermes-agent"] diff --git a/nebula_app/src/agent_detection/kilo.toml b/nebula_app/src/agent_detection/kilo.toml index fe74049..72ae3aa 100644 --- a/nebula_app/src/agent_detection/kilo.toml +++ b/nebula_app/src/agent_detection/kilo.toml @@ -1,4 +1,4 @@ -# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +# Screen-chrome evidence for this CLI (idle / working / blocked). id = "kilo" aliases = ["kilo-code"] diff --git a/nebula_app/src/agent_detection/kimi.toml b/nebula_app/src/agent_detection/kimi.toml index 980d3c6..0dadb8c 100644 --- a/nebula_app/src/agent_detection/kimi.toml +++ b/nebula_app/src/agent_detection/kimi.toml @@ -1,4 +1,4 @@ -# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +# Screen-chrome evidence for this CLI (idle / working / blocked). id = "kimi" aliases = ["kimi-code"] diff --git a/nebula_app/src/agent_detection/kiro.toml b/nebula_app/src/agent_detection/kiro.toml index e97b14a..9e36276 100644 --- a/nebula_app/src/agent_detection/kiro.toml +++ b/nebula_app/src/agent_detection/kiro.toml @@ -1,4 +1,4 @@ -# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +# Screen-chrome evidence for this CLI (idle / working / blocked). id = "kiro" aliases = ["kiro-cli"] diff --git a/nebula_app/src/agent_detection/maki.toml b/nebula_app/src/agent_detection/maki.toml index 46e993d..0905cfd 100644 --- a/nebula_app/src/agent_detection/maki.toml +++ b/nebula_app/src/agent_detection/maki.toml @@ -1,4 +1,4 @@ -# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +# Screen-chrome evidence for this CLI (idle / working / blocked). id = "maki" [[rules]] diff --git a/nebula_app/src/agent_detection/opencode.toml b/nebula_app/src/agent_detection/opencode.toml index f6aebd0..a5f2e9a 100644 --- a/nebula_app/src/agent_detection/opencode.toml +++ b/nebula_app/src/agent_detection/opencode.toml @@ -1,4 +1,4 @@ -# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +# Screen-chrome evidence for this CLI (idle / working / blocked). id = "opencode" aliases = ["open-code"] diff --git a/nebula_app/src/agent_detection/pi.toml b/nebula_app/src/agent_detection/pi.toml index fc447ba..c60a783 100644 --- a/nebula_app/src/agent_detection/pi.toml +++ b/nebula_app/src/agent_detection/pi.toml @@ -1,4 +1,4 @@ -# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +# Screen-chrome evidence for this CLI (idle / working / blocked). id = "pi" [[rules]] diff --git a/nebula_app/src/agent_detection/qodercli.toml b/nebula_app/src/agent_detection/qodercli.toml index ae193da..967fa28 100644 --- a/nebula_app/src/agent_detection/qodercli.toml +++ b/nebula_app/src/agent_detection/qodercli.toml @@ -1,4 +1,4 @@ -# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +# Screen-chrome evidence for this CLI (idle / working / blocked). id = "qodercli" aliases = ["qoderclicn", "qoder", "qodercn"] diff --git a/nebula_app/src/backup_remote.rs b/nebula_app/src/backup_remote.rs index 6be2265..9b4dd6f 100644 --- a/nebula_app/src/backup_remote.rs +++ b/nebula_app/src/backup_remote.rs @@ -1,14 +1,14 @@ //! 多协议远程备份:把 [`crate::encrypted_backup`] 的加密归档(NEBUBAK1, //! Argon2id + AES-256-GCM,服务器只见密文)推到远端,或取回最新一份恢复。 //! -//! 协议后端(对照 Netcatty 的 cloudSync 适配器面裁剪): +//! 协议后端: //! - 本地/网络目录(含 NAS 的 UNC 路径) //! - WebDAV(PUT/GET/PROPFIND/DELETE,Basic 认证,同 `sync.rs` 的栈) //! - S3 兼容(SigV4 签名,路径式 URL——AWS/MinIO/R2/B2/OSS 通吃) -//! - SFTP(复用既有 SSH 会话栈与已保存主机的认证,Netcatty 没有的一路) +//! - SFTP(复用既有 SSH 会话栈与已保存主机的认证) //! -//! Netcatty 还有 GitHub/Google Drive/OneDrive 三个 OAuth 后端;桌面终端里 -//! 没有浏览器授权回环,先不做。归档按 UTC 时间戳命名(字典序即时间序), +//! GitHub/Google Drive/OneDrive 三个 OAuth 后端需要浏览器授权回环,桌面终端里 +//! 先不做。归档按 UTC 时间戳命名(字典序即时间序), //! 每个远端保留最近 [`KEEP_ARCHIVES`] 份,多出的在推送成功后尽力清理。 //! 网络/密钥派生都在调用方的后台线程阻塞完成(与 `sync.rs` 同一模型)。 @@ -539,7 +539,7 @@ impl WebDavBackend { } /// 目录不存在时的一次性补救。MKCOL 只建最后一级——多级路径请先在 - /// 服务端建好(和 Netcatty 的 ensureDirectory 语义一致)。 + /// 服务端建好。 fn mkcol(&self) -> Result<(), String> { let request = ureq::http::Request::builder() .method("MKCOL") diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index ef827a1..52753ad 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -159,7 +159,13 @@ pub use settings::{ BgPickerPart, NebulaSettingsSection, SettingsDropdown, SettingsHit, settings_hit, }; pub(crate) use settings::{ - BACKGROUND_SWATCHES, CellWidthMode, NewTabPosition, SettingsOpacityTarget, + BACKGROUND_SWATCHES, CellWidthMode, NewTabPosition, SettingsOpacityTarget, hsv_to_rgb, + rgb_to_hsv, +}; +#[cfg(feature = "gpui-shell")] +pub(crate) use settings::{ + MANUAL_PROXY_PROTOCOL_OPTIONS, ManualProxyProtocol, ProxyTestStatus, manual_proxy_parts, + manual_proxy_value, }; /// 按显示列宽贪心断行(确认框正文等 UI 段落用):CJK 逐字可断,行首空 @@ -883,7 +889,7 @@ pub(crate) fn ai_logo(program: &str) -> Option { /// (一个汉字 → `%E6%98%9F` 九列),48 列预算被编码噪声吃光后再截尾,提示 /// "显示不全"。file 路径解码后再展示;其他 scheme 保持原样(URL 的编码 /// 属于其身份,不替它翻译)。 -fn strip_file_scheme(uri: &str) -> String { +pub(crate) fn strip_file_scheme(uri: &str) -> String { match uri.strip_prefix("file:///").or_else(|| uri.strip_prefix("file://")) { // `file:///D:/x` yields `D:/x`; a UNC-ish `file://host/x` keeps `host/x`. Some(rest) => percent_decode_lossy(rest), @@ -926,7 +932,7 @@ fn percent_decode_lossy(s: &str) -> String { /// Truncate `s` to at most `budget` display columns (CJK counts as 2), keeping /// the TAIL and prefixing `…` when cut — the filename end is what a hover /// tooltip most needs. Returns a string whose display width is `<= budget`. -fn fit_tail(s: &str, budget: usize) -> String { +pub(crate) fn fit_tail(s: &str, budget: usize) -> String { let width = |c: char| c.width().unwrap_or(0); let total: usize = s.chars().map(width).sum(); if total <= budget { @@ -2008,7 +2014,7 @@ fn alt_screen_vertical_padding_bands( /// 不弹系统确认(Nebula 自己的确认弹窗已经问过了),不弹系统错误框—— /// 失败以 Err 返回给面板提示条。 #[cfg(windows)] -fn send_to_recycle_bin(path: &std::path::Path) -> Result<(), String> { +pub(crate) fn send_to_recycle_bin(path: &std::path::Path) -> Result<(), String> { use std::os::windows::ffi::OsStrExt; use windows_sys::Win32::UI::Shell::{ @@ -2042,7 +2048,7 @@ fn send_to_recycle_bin(path: &std::path::Path) -> Result<(), String> { } #[cfg(not(windows))] -fn send_to_recycle_bin(path: &std::path::Path) -> Result<(), String> { +pub(crate) fn send_to_recycle_bin(path: &std::path::Path) -> Result<(), String> { // 非 Windows 暂无回收站集成:递归删除前置确认已由弹窗承担。 let result = if path.is_dir() { std::fs::remove_dir_all(path) } else { std::fs::remove_file(path) }; @@ -8631,7 +8637,7 @@ impl Display { Vec::new() }; let math_pixel_size = self.glyph_cache.font_size.as_px(); - let math_pixels_per_point = self.window.scale_factor as f32 * 96.0 / 72.27; + let math_pixels_per_point = crate::math::pixels_per_point(self.window.scale_factor as f32); let prepared_math = terminal_math::prepare_overlays( &mut pane_state.terminal_math, &terminal_math_overlays, @@ -11561,7 +11567,7 @@ mod nebula_ux_tests { fn relaxed_cell_width_rounds_up_the_fraction_compact_floors_it() { let config = UiConfig::default(); // Maple Mono NF CN 这类字体的平均 advance 常落在 .5 以上,紧凑向下 - // 取整因此比 Windows Terminal 少一像素——宽松就是为补这一像素而设。 + // 取整因此会少一像素——宽松就是为补这一像素而设。 let m = metrics(9.6, 20.0); assert_eq!(compute_cell_size(&config, &m, CellWidthMode::Compact).0, 9.0); assert_eq!(compute_cell_size(&config, &m, CellWidthMode::Relaxed).0, 10.0); diff --git a/nebula_app/src/display/settings.rs b/nebula_app/src/display/settings.rs index b3783a8..1b65d8a 100644 --- a/nebula_app/src/display/settings.rs +++ b/nebula_app/src/display/settings.rs @@ -28,8 +28,8 @@ use super::keymap; use super::ui::theme::Skin; use super::ui::{icons, os_icons, surface, text_field, tokens, widgets}; use super::{ - AcceptKey, CompletionStyle, LanguagePreference, NebulaShell, NebulaTheme, SizeInfo, UiLanguage, - chrome_settings_button_rect, contains_rect, nebula_data_dir, truncate_tab_label, + chrome_settings_button_rect, contains_rect, nebula_data_dir, truncate_tab_label, AcceptKey, + CompletionStyle, LanguagePreference, NebulaShell, NebulaTheme, SizeInfo, UiLanguage, }; // Visual language: one flat panel color, one hairline, three text grays, ONE @@ -217,19 +217,19 @@ fn ssh_proxy_mode_label(mode: crate::ssh_proxy::ProxyMode, language: UiLanguage) } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub(super) enum ManualProxyProtocol { +pub(crate) enum ManualProxyProtocol { #[default] Socks5, Http, } -pub(super) const MANUAL_PROXY_PROTOCOL_OPTIONS: [ManualProxyProtocol; 2] = +pub(crate) const MANUAL_PROXY_PROTOCOL_OPTIONS: [ManualProxyProtocol; 2] = [ManualProxyProtocol::Socks5, ManualProxyProtocol::Http]; /// 网络页“测试网络”的窗口态。结果只代表发起测试时已经落盘的那份设置; /// 用户随后改动模式或地址时会回到 `Idle`,避免旧成功状态冒充新配置有效。 #[derive(Debug, Default, Clone, PartialEq, Eq)] -pub(super) enum ProxyTestStatus { +pub(crate) enum ProxyTestStatus { #[default] Idle, Running, @@ -254,7 +254,7 @@ fn manual_proxy_protocol_label( /// 把持久化 URL 拆成协议选择与可编辑地址。协议前缀由下拉框负责,输入框 /// 只显示地址正文,避免用户在两个控件里重复维护同一份信息。 -pub(super) fn manual_proxy_parts(value: &str) -> (ManualProxyProtocol, &str) { +pub(crate) fn manual_proxy_parts(value: &str) -> (ManualProxyProtocol, &str) { let value = value.trim(); for (prefix, protocol) in [ ("socks5://", ManualProxyProtocol::Socks5), @@ -271,7 +271,7 @@ pub(super) fn manual_proxy_parts(value: &str) -> (ManualProxyProtocol, &str) { (ManualProxyProtocol::Socks5, value) } -pub(super) fn manual_proxy_value(protocol: ManualProxyProtocol, address: &str) -> String { +pub(crate) fn manual_proxy_value(protocol: ManualProxyProtocol, address: &str) -> String { let address = address.trim(); if address.is_empty() { return String::new(); @@ -453,7 +453,7 @@ fn new_tab_position_label(position: NewTabPosition, language: UiLanguage) -> &'s /// 单元格宽度模式:终端把字体的非整数设计宽度转换为整像素列宽的方式。 /// 「紧凑」保持上游的向下取整并作为兼容默认;「宽松」采用最接近整数取整, -/// 补足与 Windows Terminal 相差的那一像素。它只影响列宽,不改变单元格高度、 +/// 补足向下取整丢掉的那一像素。它只影响列宽,不改变单元格高度、 /// 字形比例或原生界面排版。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub(crate) enum CellWidthMode { @@ -1431,7 +1431,11 @@ fn settings_viewport_h(popup_h: f32, scale_factor: f32) -> f32 { } fn advanced_content_end(advanced_y0: f32, sync_y0: f32, row_h: f32) -> f32 { - if SHOW_WEBDAV_SYNC_SETTINGS { sync_y0 + 7.0 * row_h } else { advanced_y0 + 4.0 * row_h } + if SHOW_WEBDAV_SYNC_SETTINGS { + sync_y0 + 7.0 * row_h + } else { + advanced_y0 + 4.0 * row_h + } } /// Max scroll offset for `section` at the current window size. The input @@ -1840,9 +1844,8 @@ fn settings_geometry( // 隐藏尾部行,动作行经 `backup_remote_actions_rect` 上移贴住可见行。 let backup_remote_y0 = 944.0; let backup_remote_protocol = (row_x, at(backup_remote_y0), row_w, row_h); - let backup_remote_field = |index: usize| { - (row_x, at(backup_remote_y0 + (1.0 + index as f32) * ROW_H), row_w, row_h) - }; + let backup_remote_field = + |index: usize| (row_x, at(backup_remote_y0 + (1.0 + index as f32) * ROW_H), row_w, row_h); let backup_remote_fields = [ backup_remote_field(0), backup_remote_field(1), @@ -1851,8 +1854,7 @@ fn settings_geometry( backup_remote_field(4), ]; let backup_remote_actions_y = backup_remote_y0 + 6.0 * ROW_H + 12.0; - let backup_remote_actions = - (row_x, at(backup_remote_actions_y), s(300.0).min(row_w), s(38.0)); + let backup_remote_actions = (row_x, at(backup_remote_actions_y), s(300.0).min(row_w), s(38.0)); let backup_h = s(backup_remote_actions_y + 38.0 + 64.0 - 72.0); SettingsGeometry { @@ -3057,8 +3059,7 @@ pub fn settings_hit( } } if backup_protocol != crate::backup_remote::BackupProtocol::Off { - let actions = - backup_remote_actions_rect(&geometry, scale_factor, field_count); + let actions = backup_remote_actions_rect(&geometry, scale_factor, field_count); let [push, pull] = sync_button_rects(actions, scale_factor); if contains_rect(push, x, y) { return SettingsHit::BackupRemotePush; @@ -3071,7 +3072,11 @@ pub fn settings_hit( } } - if contains_rect(geometry.popup, x, y) { SettingsHit::Panel } else { SettingsHit::None } + if contains_rect(geometry.popup, x, y) { + SettingsHit::Panel + } else { + SettingsHit::None + } } // ---- rendering ---- @@ -5584,7 +5589,11 @@ pub(super) fn draw_popup_text( icon_draws .push((id.clone(), (rx + s(8.0), ry + (rh - s(24.0)) / 2.0, s(24.0), s(24.0)))); text_x = rx + s(40.0); - if program.is_empty() { name.clone() } else { format!("{name} · {program}") } + if program.is_empty() { + name.clone() + } else { + format!("{name} · {program}") + } }, SettingsDropdown::Font => { // 第 0 行是搜索框:它的底与光标在 quads pass 里画,这里只 @@ -5906,7 +5915,11 @@ pub(super) fn draw_text( let clip_bot = py + ph - s(6.0); let visible = |ry: f32, rh: f32| ry >= clip_top && ry + rh <= clip_bot; let row_text_y = |ry: f32, rh: f32| { - if geometry.stacked_rows { ry + s(9.0) } else { ry + (rh - cell_h) / 2.0 } + if geometry.stacked_rows { + ry + s(9.0) + } else { + ry + (rh - cell_h) / 2.0 + } }; // 通用 combobox 的当前值:控件框内左对齐,截断在 chevron 井之前。 let combobox_value = |r: &mut Renderer, @@ -7476,7 +7489,11 @@ pub(super) fn draw_text( rect.0 + s(16.0), ty, if i == keymap::QUICK_TERMINAL_ROW && view.quick_hotkey_error.is_some() { - if sk.is_light { Rgb::new(207, 34, 46) } else { Rgb::new(248, 81, 73) } + if sk.is_light { + Rgb::new(207, 34, 46) + } else { + Rgb::new(248, 81, 73) + } } else { sk.ink }, @@ -8000,7 +8017,14 @@ pub(super) fn draw_text( let max_cols = (((iw - s(24.0)) / cell_w) as usize).max(1); let (text, placeholder, _) = backup_remote_input_display(view, index, max_cols); let ink = if placeholder { sk.ink_dim } else { sk.ink }; - r.draw_chrome_text(size, ix + s(12.0), iy + (ih - cell_h) / 2.0, ink, &text, gc); + r.draw_chrome_text( + size, + ix + s(12.0), + iy + (ih - cell_h) / 2.0, + ink, + &text, + gc, + ); } if view.backup_protocol != crate::backup_remote::BackupProtocol::Off { let actions = backup_remote_actions_rect(&geometry, scale, field_count); @@ -8059,19 +8083,19 @@ pub(super) fn draw_text( #[cfg(test)] mod tests { use super::{ - CELL_WIDTH_MODE_OPTIONS, CellWidthMode, KeymapPaneState, ManualProxyProtocol, - NEW_TAB_POSITION_OPTIONS, NebulaSettingsSection, NewTabPosition, ProxyChoice, - ProxyPaneState, Rgb, SHOW_BACKUP_SETTINGS, SHOW_WEBDAV_SYNC_SETTINGS, - STANDARD_ROW_ACTION_W, SettingsHit, TabRevealMotion, UiLanguage, advanced_content_end, - background_color_popup, cell_width_mode_label, font_popup_row_count, font_popup_slot, - hsv_to_rgb, manual_proxy_parts, manual_proxy_value, new_tab_position_label, - opacity_from_pointer, proxy_section_title_y, rgb_to_hsv, row_action_rect, - settings_geometry, settings_hit, ssh_proxy_manual_controls, ssh_proxy_mode_control, - ssh_proxy_test_button, + advanced_content_end, background_color_popup, cell_width_mode_label, font_popup_row_count, + font_popup_slot, hsv_to_rgb, manual_proxy_parts, manual_proxy_value, + new_tab_position_label, opacity_from_pointer, proxy_section_title_y, rgb_to_hsv, + row_action_rect, settings_geometry, settings_hit, ssh_proxy_manual_controls, + ssh_proxy_mode_control, ssh_proxy_test_button, CellWidthMode, KeymapPaneState, + ManualProxyProtocol, NebulaSettingsSection, NewTabPosition, ProxyChoice, ProxyPaneState, + Rgb, SettingsHit, TabRevealMotion, UiLanguage, CELL_WIDTH_MODE_OPTIONS, + NEW_TAB_POSITION_OPTIONS, SHOW_BACKUP_SETTINGS, SHOW_WEBDAV_SYNC_SETTINGS, + STANDARD_ROW_ACTION_W, }; - use crate::display::SizeInfo; use crate::display::ui::tokens::Density; use crate::display::ui::widgets; + use crate::display::SizeInfo; #[test] fn hsv_rgb_round_trip_and_gray_hue_convention() { diff --git a/nebula_app/src/display/size_info.rs b/nebula_app/src/display/size_info.rs index bbc50d1..23679c5 100644 --- a/nebula_app/src/display/size_info.rs +++ b/nebula_app/src/display/size_info.rs @@ -177,7 +177,7 @@ impl SizeInfo { /// of rows, and once those rows overflow the scrollback the oldest ones are /// dropped for good — widening back cannot resurrect what is no longer in /// the buffer. The window floor derived from this keeps the user out of that - /// region entirely (Windows Terminal does the same with a fixed 460 DIP + /// region entirely (a typical host uses a fixed ~460 DIP /// minimum; deriving it from `chrome` keeps it honest when the sidebar is /// open or the font changes). pub const MIN_USABLE_COLUMNS: usize = 20; diff --git a/nebula_app/src/display/terminal_math.rs b/nebula_app/src/display/terminal_math.rs index 4d39303..8419104 100644 --- a/nebula_app/src/display/terminal_math.rs +++ b/nebula_app/src/display/terminal_math.rs @@ -571,6 +571,17 @@ enum DelimiterKind { Parenthesized, DollarDisplay, BracketDisplay, + /// Markdown-unescaped `\[ … \]`: bare `[` / `]` left behind by an AI CLI + /// whose markdown renderer ate the backslashes. + BareBracketDisplay, + /// Markdown-unescaped `\( … \)`: bare `( … )` around TeX content. + BareParenInline, +} + +impl DelimiterKind { + fn is_display(self) -> bool { + matches!(self, Self::DollarDisplay | Self::BracketDisplay | Self::BareBracketDisplay) + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -1214,6 +1225,10 @@ fn scan_grid_result(grid: &TextGrid, allow_inline_dollar: bool) -> GridScanResul ), None, ) + } else if grid.character(position) == Some('[') && !grid.is_escaped(position) { + (find_bare_bracket_formula(grid, position), None) + } else if grid.character(position) == Some('(') && !grid.is_escaped(position) { + (find_bare_paren_formula(grid, position), None) } else if allow_inline_dollar && grid.character(position) == Some('$') && !grid.is_escaped(position) @@ -1254,7 +1269,7 @@ fn find_formula( let close = grid.find_closing(source_start, closing, same_row)?; let after = grid.after(close, closing.len()); let source = grid.extract(source_start, close)?; - let display = matches!(kind, DelimiterKind::DollarDisplay | DelimiterKind::BracketDisplay); + let display = kind.is_display(); if !plausible_math_source(&source, display) { return None; } @@ -1295,6 +1310,136 @@ fn find_dollar_formula( None } +/// Markdown-unescaped display block: some AI CLIs run their answer through a +/// markdown renderer that eats the backslash of `\[` / `\]` / `\,` (they are +/// markdown punctuation escapes) while `\int`, `\frac` … survive, leaving a +/// bare `[` block on screen. Bare brackets carry no math intent of their own — +/// JSON, arrays and `[INFO]` logs all use them — so this form is held to a +/// stricter shape than `\[`: `[` must start its row, `]` must end its row (or +/// stand alone), and the content must contain a known TeX command. +fn find_bare_bracket_formula( + grid: &TextGrid, + open: GridPosition, +) -> Option<(FormulaOverlay, GridPosition)> { + if !grid.span_is_blank(open.row, 0, open.column) { + return None; + } + let source_start = grid.after(open, 1); + let close = if grid.span_is_blank(open.row, open.column + 1, grid.columns) { + // 多行形态:`[` 独占一行,闭合 `]` 也必须独占一行。数学源码里 + // `[0, 1]` 区间的 `]` 不具备闭合资格,跳过继续找。 + let mut search = source_start; + loop { + let candidate = grid.find_closing(search, &[']'], false)?; + if grid.span_is_blank(candidate.row, 0, candidate.column) + && grid.span_is_blank(candidate.row, candidate.column + 1, grid.columns) + { + break candidate; + } + search = grid.next(candidate)?; + } + } else { + // 单行形态 `[ … ]`:闭合必须是本行最后一个非空白字符。 + let cells = grid.rows.get(open.row)?; + let last = cells.iter().rposition(|cell| cell.is_some_and(|c| c != ' '))?; + let close = GridPosition { row: open.row, column: last }; + if last <= open.column || grid.character(close) != Some(']') { + return None; + } + close + }; + let after = grid.after(close, 1); + let source = grid.extract(source_start, close)?; + if !has_known_tex_command(&source) || !plausible_math_source(&source, true) { + return None; + } + Some((make_overlay(grid, open, after, source, DelimiterKind::BareBracketDisplay), after)) +} + +/// Markdown-unescaped inline formula: `\( … \)` stripped down to bare +/// parentheses. Bare parens are prose punctuation, so only content that leads +/// with a backslash and carries a known TeX command qualifies (`(\sqrt{…})`); +/// single-letter regex escapes like `(\d+)` are not in the whitelist and stay +/// literal. The closer is matched by paren depth so `(\sin(x))` keeps its +/// inner `)`. +fn find_bare_paren_formula( + grid: &TextGrid, + open: GridPosition, +) -> Option<(FormulaOverlay, GridPosition)> { + let source_start = grid.after(open, 1); + if grid.character(source_start) != Some('\\') { + return None; + } + + let mut depth = 1usize; + let mut position = source_start; + let close = loop { + match grid.character(position) { + Some('(') if !grid.is_escaped(position) => depth += 1, + Some(')') if !grid.is_escaped(position) => { + depth -= 1; + if depth == 0 { + break position; + } + }, + _ => {}, + } + position = grid.next(position)?; + // 与 `\(…\)` 的 same_row 合同一致:行内公式不跨物理行。 + if position.row != open.row { + return None; + } + }; + let after = grid.after(close, 1); + let source = grid.extract(source_start, close)?; + if !has_known_tex_command(&source) || !plausible_math_source(&source, false) { + return None; + } + Some((make_overlay(grid, open, after, source, DelimiterKind::BareParenInline), after)) +} + +/// Commands that justify treating bare delimiters as math. Deliberately a +/// whitelist instead of "any `\` + letters": regex escapes (`\d`, `\w`), C +/// escapes and Windows paths all match the loose shape. Single-letter +/// commands are excluded wholesale by the caller-side length check baked in +/// here (every entry is ≥2 chars). +const KNOWN_TEX_COMMANDS: &[&str] = &[ + "alpha", "approx", "arccos", "arcsin", "arctan", "bar", "begin", "beta", "big", "bigg", + "binom", "boldsymbol", "bullet", "cap", "cdot", "cdots", "chi", "circ", "cos", "cosh", "cot", + "csc", "cup", "ddot", "ddots", "delta", "det", "dfrac", "div", "dot", "dots", "emptyset", + "end", "epsilon", "equiv", "eta", "exists", "exp", "forall", "frac", "gamma", "gcd", "ge", + "geq", "gg", "hat", "iff", "iiint", "iint", "implies", "in", "inf", "infty", "int", "iota", + "kappa", "lambda", "land", "langle", "lceil", "ldots", "le", "left", "leftarrow", + "leftrightarrow", "leq", "lfloor", "lg", "lim", "liminf", "limsup", "ll", "ln", "log", "lor", + "mapsto", "mathbb", "mathbf", "mathcal", "mathfrak", "mathit", "mathrm", "mathsf", "max", + "min", "mp", "mu", "nabla", "ne", "neg", "neq", "notin", "nu", "odot", "oint", "omega", + "ominus", "operatorname", "oplus", "otimes", "overbrace", "overline", "partial", "phi", "pi", + "pm", "prod", "propto", "psi", "qquad", "quad", "rangle", "rceil", "rfloor", "rho", "right", + "rightarrow", "sec", "sigma", "sim", "simeq", "sin", "sinh", "sqrt", "subset", "subseteq", + "sum", "sup", "supset", "supseteq", "tan", "tanh", "tau", "text", "textbf", "textit", + "textrm", "theta", "tilde", "times", "to", "underbrace", "underline", "upsilon", "varepsilon", + "varnothing", "varphi", "varpi", "varrho", "varsigma", "vartheta", "vec", "vert", "widehat", + "widetilde", "xi", "zeta", "Big", "Bigg", "Delta", "Gamma", "Lambda", "Leftarrow", + "Leftrightarrow", "Omega", "Phi", "Pi", "Psi", "Rightarrow", "Sigma", "Theta", "Upsilon", + "Vert", "Xi", +]; + +/// Whether `source` contains at least one whitelisted TeX command. Bare +/// delimiters have no `\[` / `\(` intent statement backing them, so a real +/// math command is required as evidence before they may render. +fn has_known_tex_command(source: &str) -> bool { + let mut rest = source; + while let Some(index) = rest.find('\\') { + rest = &rest[index + 1..]; + let end = rest.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(rest.len()); + if end > 1 && KNOWN_TEX_COMMANDS.contains(&&rest[..end]) { + return true; + } + rest = &rest[end..]; + } + false +} + /// Delimiters state intent, content supplies evidence. Display blocks /// (`$$…$$`, `\[…\]`) rarely occur outside real math, so lax evidence /// suffices; bare `$…$` and `\(…\)` collide with currency, shell variables @@ -1440,7 +1585,7 @@ fn make_overlay( source: Box, kind: DelimiterKind, ) -> FormulaOverlay { - let display = matches!(kind, DelimiterKind::DollarDisplay | DelimiterKind::BracketDisplay); + let display = kind.is_display(); let mut hasher = DefaultHasher::new(); source.hash(&mut hasher); kind.hash(&mut hasher); @@ -2265,6 +2410,63 @@ d & -b \\ assert!(sources(&["$mc^2$"], true).is_empty()); } + #[test] + fn markdown_unescaped_bare_brackets_render_as_display_math() { + // 部分 AI CLI 的 markdown 渲染会吃掉 `\[` `\]` `\,` 的反斜杠 + // (它们是 markdown 标点转义),`\int`/`\frac` 不是转义所以幸存, + // 屏幕上只剩裸 `[` 块——2026-08-17 截图的真实形态。 + assert_eq!( + sources(&["[", r"\int_0^1 x^2,dx = \frac{1}{3}", "]"], false), + vec![(r"\int_0^1 x^2,dx = \frac{1}{3}".into(), true)] + ); + assert_eq!( + sources(&["[", r"\sum_{i=1}^{n} i = \frac{n(n+1)}{2}", "]"], false), + vec![(r"\sum_{i=1}^{n} i = \frac{n(n+1)}{2}".into(), true)] + ); + assert_eq!( + sources(&[r"[ \lim_{x\to 0}\frac{\sin x}{x}=1 ]"], false), + vec![(r"\lim_{x\to 0}\frac{\sin x}{x}=1".into(), true)] + ); + } + + #[test] + fn bare_bracket_interval_inside_formula_does_not_close_early() { + // 内容行里的 `[0, 1]` 区间:其 `]` 不独占一行,没有闭合资格。 + assert_eq!( + sources(&["[", r"x \in [0, 1]", "]"], false), + vec![(r"x \in [0, 1]".into(), true)] + ); + } + + #[test] + fn bare_brackets_without_tex_evidence_stay_literal() { + assert!(sources(&["[", " 1, 2, 3,", "]"], false).is_empty()); + assert!(sources(&[r#"["alpha", "beta"]"#], false).is_empty()); + assert!(sources(&["[INFO] server started"], false).is_empty()); + assert!(sources(&["[x^2]"], false).is_empty()); + assert!(sources(&["result [ x^2 ] done"], false).is_empty()); + assert!(sources(&["[", r"C:\temp\integral.txt", "]"], false).is_empty()); + } + + #[test] + fn markdown_unescaped_bare_parens_render_inline() { + assert_eq!( + sources(&[r"也可以使用 (\sqrt{x^2+y^2}) 表示行内公式"], false), + vec![(r"\sqrt{x^2+y^2}".into(), false)] + ); + // 括号深度配对:`\sin(x)` 的内层 `)` 不提前截断。 + assert_eq!(sources(&[r"值 (\sin(x)) 收敛"], false), vec![(r"\sin(x)".into(), false)]); + } + + #[test] + fn bare_parens_reject_regex_prose_and_single_letter_escapes() { + assert!(sources(&[r"grep -E (\d+) input.txt"], false).is_empty()); + assert!(sources(&[r"match (\w*) here"], false).is_empty()); + assert!(sources(&["plain (normal prose) text"], false).is_empty()); + assert!(sources(&[r"case (\n) newline"], false).is_empty()); + assert!(sources(&[r"path (C:\temp\frac) oops"], false).is_empty()); + } + #[test] fn escaped_dollars_currency_and_shell_variables_stay_literal() { assert!(sources(&[r"escaped \$x$"], true).is_empty()); diff --git a/nebula_app/src/font_install.rs b/nebula_app/src/font_install.rs index fa5532a..7ca355b 100644 --- a/nebula_app/src/font_install.rs +++ b/nebula_app/src/font_install.rs @@ -182,14 +182,30 @@ pub fn imported_font_directory() -> PathBuf { #[cfg(windows)] pub fn imported_font_files() -> Vec { - let Ok(entries) = std::fs::read_dir(imported_font_directory()) else { return Vec::new() }; - let mut files = entries + let mut files = font_files_in(&imported_font_directory()); + // 隔离启动把 `data_dir` 指到临时配置时,用户以前导入到 + // `%APPDATA%\Nebula\fonts` 的字体会「消失」。探测实例仍应能看见它们。 + if std::env::var_os("NEBULA_CONFIG_DIR").is_some() { + if let Some(appdata) = std::env::var_os("APPDATA") { + let user_fonts = PathBuf::from(appdata).join("Nebula").join("fonts"); + if user_fonts != imported_font_directory() { + files.extend(font_files_in(&user_fonts)); + } + } + } + files.sort(); + files.dedup(); + files +} + +#[cfg(windows)] +fn font_files_in(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; + entries .flatten() .map(|entry| entry.path()) .filter(|path| path.is_file() && supported_font_extension(path)) - .collect::>(); - files.sort(); - files + .collect() } #[cfg(windows)] diff --git a/nebula_app/src/gpui_shell/background_color.rs b/nebula_app/src/gpui_shell/background_color.rs new file mode 100644 index 0000000..f39ca92 --- /dev/null +++ b/nebula_app/src/gpui_shell/background_color.rs @@ -0,0 +1,578 @@ +//! 外观页「背景色」:闭态 combobox + 开态 SV/色相/色板/hex 浮层。 +//! +//! 几何与命中对照旧壳 `combobox_rect` / `push_combobox` / +//! `background_color_popup`;不走组件库 `ColorPicker`。 + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + Bounds, Context, InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent, + MouseMoveEvent, ParentElement as _, Pixels, Point, SharedString, + StatefulInteractiveElement as _, Styled as _, Window, anchored, canvas, deferred, div, fill, + point, px, size, +}; + +use crate::display::color::Rgb; +use crate::display::ui::tokens::radius; +use crate::display::{BACKGROUND_SWATCHES, BgPickerPart, hsv_to_rgb, rgb_to_hsv}; +use crate::gpui_shell::prelude::*; +use gpui_component::input::InputEvent; +use nebula_settings::{format_hex_rgb, parse_hex_rgb}; + +use super::SettingsPane; + +const COMBOBOX_W: f32 = 220.0; +const COMBOBOX_H: f32 = 32.0; +const CHIP: f32 = 16.0; +const PAD: f32 = 12.0; +const GAP: f32 = 8.0; +const SV_H: f32 = 128.0; +const HUE_H: f32 = 14.0; +const CELL: f32 = 30.0; +const HEX_H: f32 = 34.0; +const COLS: usize = 6; +const SV_COLS: usize = 24; +const SV_ROWS: usize = 16; +const HUE_STEPS: usize = 36; + +fn popup_size() -> (f32, f32) { + let grid_w = COLS as f32 * CELL + (COLS - 1) as f32 * GAP; + let grid_h = 2.0 * CELL + GAP; + let w = (grid_w + 2.0 * PAD).max(COMBOBOX_W); + let h = PAD + SV_H + GAP + HUE_H + GAP + grid_h + GAP + HEX_H + PAD; + (w, h) +} + +impl SettingsPane { + pub(super) fn effective_background_rgb(&self, cx: &gpui::App) -> [u8; 3] { + if let Some(rgb) = self.runtime.background { + return rgb; + } + let term = crate::gpui_shell::theme::chrome_theme_resolved(cx).palette().term_bg; + [term.r, term.g, term.b] + } + + fn set_hsv_from_rgb(&mut self, rgb: [u8; 3]) { + let (h, s, v) = rgb_to_hsv(Rgb::new(rgb[0], rgb[1], rgb[2])); + if s > f32::EPSILON && v > f32::EPSILON { + self.bg_picker_hsv = (h, s, v); + } else { + self.bg_picker_hsv = (self.bg_picker_hsv.0, s, v); + } + } + + fn write_hex_field(&mut self, hex: &str, window: &mut Window, cx: &mut Context) { + self.bg_hex_syncing = true; + self.bg_hex_input.update(cx, |input, cx| input.set_value(hex.to_owned(), window, cx)); + self.bg_hex_syncing = false; + } + + fn persist_background_rgb( + &mut self, + rgb: [u8; 3], + window: &mut Window, + cx: &mut Context, + ) { + let hex = format_hex_rgb(rgb); + self.write_hex_field(&hex, window, cx); + self.persist(&[("background", hex)], cx); + } + + fn commit_background_rgb(&mut self, rgb: [u8; 3], window: &mut Window, cx: &mut Context) { + self.set_hsv_from_rgb(rgb); + self.persist_background_rgb(rgb, window, cx); + } + + fn commit_hsv(&mut self, window: &mut Window, cx: &mut Context) { + let (h, s, v) = self.bg_picker_hsv; + let color = hsv_to_rgb(h, s, v); + self.persist_background_rgb([color.r, color.g, color.b], window, cx); + } + + pub(super) fn sync_background_color_picker( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + let rgb = self.effective_background_rgb(cx); + self.set_hsv_from_rgb(rgb); + if !self.bg_hex_focused { + self.write_hex_field(&format_hex_rgb(rgb), window, cx); + } + } + + pub(super) fn close_background_picker(&mut self, cx: &mut Context) { + if !self.bg_picker_open { + return; + } + self.bg_picker_open = false; + self.bg_picker_drag = None; + cx.notify(); + } + + fn toggle_background_picker(&mut self, window: &mut Window, cx: &mut Context) { + if self.bg_picker_open { + self.close_background_picker(cx); + return; + } + let rgb = self.effective_background_rgb(cx); + self.set_hsv_from_rgb(rgb); + self.write_hex_field(&format_hex_rgb(rgb), window, cx); + self.bg_picker_open = true; + cx.notify(); + } + + pub(super) fn on_bg_hex_event( + &mut self, + event: &InputEvent, + _window: &mut Window, + cx: &mut Context, + ) { + match event { + InputEvent::Focus => { + self.bg_hex_focused = true; + cx.notify(); + }, + InputEvent::Blur => { + self.bg_hex_focused = false; + cx.notify(); + }, + InputEvent::Change => { + if self.bg_hex_syncing { + return; + } + let value = self.bg_hex_input.read(cx).value().to_string(); + if let Some(rgb) = parse_hex_rgb(&value) { + self.set_hsv_from_rgb(rgb); + self.persist(&[("background", format_hex_rgb(rgb))], cx); + } + }, + _ => {}, + } + } + + pub(super) fn apply_bg_pointer( + &mut self, + position: Point, + window: &mut Window, + cx: &mut Context, + ) { + let Some(part) = self.bg_picker_drag else { return }; + match part { + BgPickerPart::Sv => { + let Some(bounds) = self.bg_sv_bounds else { return }; + let w = f32::from(bounds.size.width).max(1.0); + let h = f32::from(bounds.size.height).max(1.0); + let x = f32::from(position.x - bounds.origin.x); + let y = f32::from(position.y - bounds.origin.y); + self.bg_picker_hsv.1 = (x / w).clamp(0.0, 1.0); + self.bg_picker_hsv.2 = (1.0 - y / h).clamp(0.0, 1.0); + }, + BgPickerPart::Hue => { + let Some(bounds) = self.bg_hue_bounds else { return }; + let w = f32::from(bounds.size.width).max(1.0); + let x = f32::from(position.x - bounds.origin.x); + self.bg_picker_hsv.0 = (x / w).clamp(0.0, 1.0) * 360.0; + }, + } + self.commit_hsv(window, cx); + } + + pub(super) fn begin_bg_picker_drag( + &mut self, + part: BgPickerPart, + event: &MouseDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + self.bg_picker_drag = Some(part); + self.apply_bg_pointer(event.position, window, cx); + } + + pub(super) fn on_bg_picker_move( + &mut self, + event: &MouseMoveEvent, + window: &mut Window, + cx: &mut Context, + ) { + if self.bg_picker_drag.is_some() { + self.apply_bg_pointer(event.position, window, cx); + } + } + + pub(super) fn finish_bg_picker_drag(&mut self, cx: &mut Context) { + if self.bg_picker_drag.take().is_some() { + cx.notify(); + } + } + + /// 闭态 220×32 combobox + 开态右缘对齐浮层(行下 +6px)。 + pub(super) fn background_color_row(&mut self, cx: &mut Context) -> impl IntoElement { + let custom = self.runtime.background.is_some(); + let rgb = self.effective_background_rgb(cx); + let hex = format_hex_rgb(rgb); + let label: SharedString = + if custom { hex.clone().into() } else { format!("主题默认 {hex}").into() }; + let open = self.bg_picker_open; + let sk = crate::gpui_shell::theme::chrome_theme_resolved(cx).skin(); + let accent = super::rgb_hsla(sk.accent.r, sk.accent.g, sk.accent.b); + let ink_dim = super::rgb_hsla(sk.ink_dim.r, sk.ink_dim.g, sk.ink_dim.b); + let hairline = cx.theme().border; + let surface = super::rgb_hsla(sk.panel.r, sk.panel.g, sk.panel.b); + let hover = gpui::Rgba { + r: f32::from(sk.hover.r) / 255.0, + g: f32::from(sk.hover.g) / 255.0, + b: f32::from(sk.hover.b) / 255.0, + a: (f32::from(sk.hover.a) / 255.0).max(0.35), + }; + let chip = super::rgb_hsla(rgb[0], rgb[1], rgb[2]); + let picker = cx.entity().downgrade(); + let control = div() + .id("background-color-combo") + .relative() + .w(px(COMBOBOX_W)) + .h(px(COMBOBOX_H)) + .flex_shrink_0() + .rounded(px(radius::CONTROL)) + .border_1() + .border_color(hairline) + .bg(surface) + .when(open, |el| el.bg(hover)) + .hover(|el| el.bg(hover)) + .cursor_pointer() + .overflow_hidden() + .child( + div() + .h_full() + .px(px(12.0)) + .pr(px(28.0)) + .flex() + .items_center() + .gap(px(8.0)) + .child( + div() + .size(px(CHIP)) + .flex_shrink_0() + .rounded(px(radius::CHIP)) + .border_1() + .border_color(hairline) + .bg(chip), + ) + .child(div().flex_1().min_w_0().truncate().text_color(accent).child(label)), + ) + .child( + div().absolute().right(px(8.0)).top_0().bottom_0().flex().items_center().child( + Icon::new(if open { IconName::ChevronUp } else { IconName::ChevronDown }) + .xsmall() + .text_color(ink_dim), + ), + ) + .child( + canvas( + move |bounds, _, cx| { + let _ = picker.update(cx, |pane, _| { + pane.bg_picker_trigger_bounds = Some(bounds); + }); + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_background_picker(window, cx); + })); + + let panel = if open { Some(self.background_picker_panel(cx)) } else { None }; + let trigger_bounds = self.bg_picker_trigger_bounds; + let row = self.row_with_reset( + "背景色", + custom, + |this, window, cx| { + this.persist(&[("background", String::new())], cx); + this.sync_background_color_picker(window, cx); + }, + control, + cx, + ); + div().relative().w_full().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 background_picker_panel(&mut self, cx: &mut Context) -> gpui::Div { + let (h0, s0, v0) = self.bg_picker_hsv; + let (pw, _) = popup_size(); + let sk = crate::gpui_shell::theme::chrome_theme_resolved(cx).skin(); + let hairline = cx.theme().border; + let accent = super::rgb_hsla(sk.accent.r, sk.accent.g, sk.accent.b); + let ink_dim = super::rgb_hsla(sk.ink_dim.r, sk.ink_dim.g, sk.ink_dim.b); + let focused = self.bg_hex_focused; + let hex_border = if focused { + accent + } else { + gpui::Rgba { + r: f32::from(sk.ink_dim.r) / 255.0, + g: f32::from(sk.ink_dim.g) / 255.0, + b: f32::from(sk.ink_dim.b) / 255.0, + a: 120.0 / 255.0, + } + .into() + }; + + let picker = cx.entity().downgrade(); + let sv = div() + .w_full() + .h(px(SV_H)) + .rounded(px(radius::CHIP)) + .overflow_hidden() + .cursor_pointer() + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, event: &MouseDownEvent, window, cx| { + cx.stop_propagation(); + this.begin_bg_picker_drag(BgPickerPart::Sv, event, window, cx); + }), + ) + .child( + canvas( + move |bounds, _, cx| { + let _ = picker.update(cx, |pane, _| pane.bg_sv_bounds = Some(bounds)); + }, + move |bounds, _, window, _| { + paint_sv(window, bounds, h0, s0, v0); + }, + ) + .size_full(), + ); + + let picker = cx.entity().downgrade(); + let hue = div() + .w_full() + .h(px(HUE_H)) + .rounded(px(radius::CHIP)) + .overflow_hidden() + .cursor_pointer() + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, event: &MouseDownEvent, window, cx| { + cx.stop_propagation(); + this.begin_bg_picker_drag(BgPickerPart::Hue, event, window, cx); + }), + ) + .child( + canvas( + move |bounds, _, cx| { + let _ = picker.update(cx, |pane, _| pane.bg_hue_bounds = Some(bounds)); + }, + move |bounds, _, window, _| { + paint_hue(window, bounds, h0); + }, + ) + .size_full(), + ); + + let swatches = (0..BACKGROUND_SWATCHES.len()).fold( + div() + .flex() + .flex_wrap() + .gap(px(GAP)) + .w(px(COLS as f32 * CELL + (COLS - 1) as f32 * GAP)), + |grid, index| { + let color = BACKGROUND_SWATCHES[index]; + let rgb = [color.r, color.g, color.b]; + let selected = self.runtime.background == Some(rgb); + grid.child( + div() + .id(SharedString::from(format!("bg-swatch-{index}"))) + .size(px(CELL)) + .rounded(px(6.0)) + .border_1() + .border_color(if selected { accent } else { hairline }) + .when(selected, |cell| { + cell.shadow(vec![gpui::BoxShadow { + color: accent, + offset: gpui::point(px(0.0), px(0.0)), + blur_radius: px(0.0), + spread_radius: px(2.0), + }]) + }) + .bg(super::rgb_hsla(color.r, color.g, color.b)) + .cursor_pointer() + .hover(|cell| cell.border_color(ink_dim)) + .on_click(cx.listener(move |this, _, window, cx| { + this.commit_background_rgb(rgb, window, cx); + })), + ) + }, + ); + + v_flex() + .w(px(pw)) + .p(px(PAD)) + .gap(px(GAP)) + .popover_style(cx) + .occlude() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, window, cx| { + this.on_bg_picker_move(event, window, cx); + })) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.finish_bg_picker_drag(cx); + }), + ) + .child(sv) + .child(hue) + .child(swatches) + .child( + div() + .h(px(HEX_H)) + .w_full() + .rounded(px(7.0)) + .border_1() + .border_color(hex_border) + .overflow_hidden() + .child(Input::new(&self.bg_hex_input)), + ) + } +} + +fn paint_quad( + window: &mut Window, + origin: Point, + x: f32, + y: f32, + w: f32, + h: f32, + r: f32, + color: gpui::Hsla, +) { + window.paint_quad( + fill( + Bounds::new( + point(origin.x + px(x), origin.y + px(y)), + size(px(w.max(0.0)), px(h.max(0.0))), + ), + color, + ) + .corner_radii(px(r.max(0.0))), + ); +} + +fn rgb_paint(color: Rgb) -> gpui::Hsla { + gpui::Rgba { + r: f32::from(color.r) / 255.0, + g: f32::from(color.g) / 255.0, + b: f32::from(color.b) / 255.0, + a: 1.0, + } + .into() +} + +fn paint_sv(window: &mut Window, bounds: Bounds, h0: f32, s0: f32, v0: f32) { + let origin = bounds.origin; + let svw = f32::from(bounds.size.width); + let svh = f32::from(bounds.size.height); + let cell_w = svw / SV_COLS as f32; + let cell_h = svh / SV_ROWS as f32; + for row in 0..SV_ROWS { + for col in 0..SV_COLS { + let sat = (col as f32 + 0.5) / SV_COLS as f32; + let val = 1.0 - (row as f32 + 0.5) / SV_ROWS as f32; + let c = hsv_to_rgb(h0, sat, val); + paint_quad( + window, + origin, + col as f32 * cell_w, + row as f32 * cell_h, + cell_w + 0.5, + cell_h + 0.5, + 0.0, + rgb_paint(c), + ); + } + } + let dot_x = s0.clamp(0.0, 1.0) * svw; + let dot_y = (1.0 - v0.clamp(0.0, 1.0)) * svh; + let dot_r = 6.0; + paint_quad( + window, + origin, + dot_x - dot_r, + dot_y - dot_r, + dot_r * 2.0, + dot_r * 2.0, + dot_r, + gpui::Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }.into(), + ); + paint_quad( + window, + origin, + dot_x - dot_r + 1.5, + dot_y - dot_r + 1.5, + (dot_r - 1.5) * 2.0, + (dot_r - 1.5) * 2.0, + dot_r - 1.5, + gpui::Rgba { r: 0.0, g: 0.0, b: 0.0, a: 200.0 / 255.0 }.into(), + ); + let picked = hsv_to_rgb(h0, s0, v0); + paint_quad( + window, + origin, + dot_x - dot_r + 3.0, + dot_y - dot_r + 3.0, + (dot_r - 3.0) * 2.0, + (dot_r - 3.0) * 2.0, + dot_r - 3.0, + rgb_paint(picked), + ); +} + +fn paint_hue(window: &mut Window, bounds: Bounds, h0: f32) { + let origin = bounds.origin; + let huw = f32::from(bounds.size.width); + let huh = f32::from(bounds.size.height); + let hue_w = huw / HUE_STEPS as f32; + for step in 0..HUE_STEPS { + let hue = (step as f32 + 0.5) / HUE_STEPS as f32 * 360.0; + let c = hsv_to_rgb(hue, 1.0, 1.0); + paint_quad(window, origin, step as f32 * hue_w, 0.0, hue_w + 0.5, huh, 0.0, rgb_paint(c)); + } + let cursor_x = (h0.rem_euclid(360.0) / 360.0) * huw; + let cursor_outer = 4.0; + let cursor_inner = 2.0; + paint_quad( + window, + origin, + cursor_x - cursor_outer * 0.5, + -cursor_inner, + cursor_outer, + huh + cursor_inner * 2.0, + cursor_outer * 0.5, + gpui::Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }.into(), + ); + paint_quad( + window, + origin, + cursor_x - cursor_inner * 0.5, + -cursor_inner * 0.5, + cursor_inner, + huh + cursor_inner, + cursor_inner * 0.5, + gpui::Rgba { r: 0.0, g: 0.0, b: 0.0, a: 180.0 / 255.0 }.into(), + ); +} diff --git a/nebula_app/src/gpui_shell/code_tab.rs b/nebula_app/src/gpui_shell/code_tab.rs index e5fcfd3..e8ba2d4 100644 --- a/nebula_app/src/gpui_shell/code_tab.rs +++ b/nebula_app/src/gpui_shell/code_tab.rs @@ -58,6 +58,9 @@ fn language_for_extension(extension: &str) -> Option<&'static str> { "ini" | "cfg" | "conf" => "ini", "vim" => "vim", "xml" => "xml", + "json" | "jsonl" | "ndjson" => "json", + // 纯文本走 Plain(无高亮):诉求是行号 + 行级虚拟化,不是着色。 + "txt" | "log" | "text" => "text", _ => return None, }) } @@ -96,8 +99,7 @@ impl CodeTabView { .indent_guides(true) .soft_wrap(false) }); - let mut this = - Self { path, title, input, notice: None, lines: 0 }; + let mut this = Self { path, title, input, notice: None, lines: 0 }; this.reload(window, cx); this } @@ -109,8 +111,9 @@ impl CodeTabView { 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)); + let notice = truncated.then(|| { + format!("文件超过 {} MB,仅显示开头部分", MAX_CODE_BYTES / 1024 / 1024) + }); (text, notice) }, Err(error) => { diff --git a/nebula_app/src/gpui_shell/config.rs b/nebula_app/src/gpui_shell/config.rs index 597678b..d8a62db 100644 --- a/nebula_app/src/gpui_shell/config.rs +++ b/nebula_app/src/gpui_shell/config.rs @@ -1,4 +1,4 @@ -//! 读取用户配置:`nebula.toml` + `nebula_settings.txt`,与 `nebula_app` +//! 读取用户配置:`nebula.toml` + `nebula_settings.txt`,与 `nebula_app` //! 共享同一套文件与语义。 //! //! 两个来源、一个优先级:`nebula_settings.txt`(设置界面持久化的运行时 @@ -108,7 +108,8 @@ impl Settings { // 跟随系统时由当前亮/暗主题全权决定终端底色;否则用户取色器压轴。 let mut palette = build_palette(&raw.colors); apply_theme(&mut palette, theme); - if let Some(background) = runtime_background(runtime.follow_system_theme, runtime.background) + if let Some(background) = + runtime_background(runtime.follow_system_theme, runtime.background) { palette.background = rgba8(background); } @@ -167,8 +168,7 @@ impl Settings { // 同一行语义,否则光标漂移。 #[cfg(windows)] { - config.suppress_bringup_da1 = - nebula_terminal::tty::windows::conpty_sideload_enabled(); + config.suppress_bringup_da1 = nebula_terminal::tty::windows::conpty_sideload_enabled(); config.conpty_resize = true; } config.kitty_keyboard = true; @@ -267,10 +267,9 @@ fn find_config_file() -> Option { } if let Ok(home) = std::env::var("HOME") { let home = PathBuf::from(home); - for candidate in [ - home.join(".config/nebula").join(file_name), - home.join(format!(".{file_name}")), - ] { + for candidate in + [home.join(".config/nebula").join(file_name), home.join(format!(".{file_name}"))] + { if candidate.exists() { return Some(candidate); } @@ -286,13 +285,14 @@ fn find_config_file() -> Option { fn load_merged_toml(path: &Path, notice: &mut Option) -> toml::Value { let main = read_toml(path, notice); - let imports: Vec = [main.get("general").and_then(|g| g.get("import")), main.get("import")] - .into_iter() - .flatten() - .filter_map(|v| v.as_array()) - .flatten() - .filter_map(|v| v.as_str().map(String::from)) - .collect(); + let imports: Vec = + [main.get("general").and_then(|g| g.get("import")), main.get("import")] + .into_iter() + .flatten() + .filter_map(|v| v.as_array()) + .flatten() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); let mut merged = toml::Value::Table(Default::default()); for import in imports { @@ -321,9 +321,7 @@ fn read_toml(path: &Path, notice: &mut Option) -> toml::Value { Err(err) => { eprintln!("[nebula:gpui] failed to parse config {}: {err}", path.display()); let first_line = err.to_string().lines().next().unwrap_or("解析失败").to_owned(); - notice.get_or_insert_with(|| { - format!("配置 {} 解析失败:{first_line}", path.display()) - }); + notice.get_or_insert_with(|| format!("配置 {} 解析失败:{first_line}", path.display())); toml::Value::Table(Default::default()) }, } @@ -480,12 +478,8 @@ fn build_palette(raw: &RawColors) -> Palette { set(&mut palette.foreground, &raw.primary.foreground); set(&mut palette.background, &raw.primary.background); // 主应用语义:bright/dim foreground 未配置时派生自 foreground。 - palette.bright_foreground = raw - .primary - .bright_foreground - .as_deref() - .and_then(parse_rgb) - .unwrap_or(palette.foreground); + palette.bright_foreground = + raw.primary.bright_foreground.as_deref().and_then(parse_rgb).unwrap_or(palette.foreground); palette.dim_foreground = raw .primary .dim_foreground diff --git a/nebula_app/src/gpui_shell/doc_tabs.rs b/nebula_app/src/gpui_shell/doc_tabs.rs index af95aba..a440155 100644 --- a/nebula_app/src/gpui_shell/doc_tabs.rs +++ b/nebula_app/src/gpui_shell/doc_tabs.rs @@ -15,7 +15,7 @@ use std::sync::Arc; use gpui::prelude::FluentBuilder as _; use gpui::{ - Bounds, Context, ContentMask, Corners, InteractiveElement as _, IntoElement, MouseButton, + Bounds, ContentMask, Context, Corners, InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, RenderImage, ScrollWheelEvent, SharedString, Styled as _, Window, div, px, }; @@ -24,7 +24,7 @@ use image::Frame; use crate::display::image_viewer::ImageView; use crate::gpui_shell::prelude::*; -use gpui_component::text::TextView; +use gpui_component::text::{TextView, TextViewStyle}; /// 双击路由:应用内能读的开 tab(图片/文档/源码),其余交系统处理器。 /// 源码查看是 GPUI 壳新增能力,旧壳合同(`input/chrome.rs`)之上的超集。 @@ -176,13 +176,8 @@ impl Render for ImageTabView { gpui::size(px(target.2.max(1.0)), px(target.3.max(1.0))), ); window.with_content_mask(Some(ContentMask { bounds }), |window| { - let _ = window.paint_image( - target_bounds, - Corners::all(px(0.0)), - image, - 0, - false, - ); + let _ = + window.paint_image(target_bounds, Corners::all(px(0.0)), image, 0, false); }); }, ) @@ -253,9 +248,8 @@ impl DocTabView { let truncated = bytes.len() > MAX_DOC_BYTES; let slice = if truncated { &bytes[..MAX_DOC_BYTES] } else { &bytes[..] }; let text = String::from_utf8_lossy(slice).into_owned(); - self.notice = truncated.then(|| { - format!("文件超过 {} KB,仅显示开头部分", MAX_DOC_BYTES / 1024) - }); + self.notice = truncated + .then(|| format!("文件超过 {} KB,仅显示开头部分", MAX_DOC_BYTES / 1024)); self.content = text.into(); }, Err(error) => { @@ -311,7 +305,15 @@ impl Render for DocTabView { div().flex_1().min_h_0().child( TextView::markdown(doc_id, self.content.clone(), window, cx) .selectable(true) - .scrollable(true), + .scrollable(true) + // image_base:相对图片路径(README 的 logo/截图)按 + // 文档所在目录解析;高亮主题跟当前壳主题走。 + .style(TextViewStyle { + image_base: self.path.parent().map(Arc::from), + highlight_theme: cx.theme().highlight_theme.clone(), + is_dark: cx.theme().is_dark(), + ..TextViewStyle::default() + }), ), ) } diff --git a/nebula_app/src/gpui_shell/http.rs b/nebula_app/src/gpui_shell/http.rs new file mode 100644 index 0000000..c134903 --- /dev/null +++ b/nebula_app/src/gpui_shell/http.rs @@ -0,0 +1,119 @@ +//! gpui 图片管线的 HTTP 客户端。 +//! +//! gpui 默认装的是 `NullHttpClient`(一切请求直接报错),markdown 文档里的 +//! 网络图(shields 徽章、外链截图)会全部加载失败。这里用项目里已有的 +//! ureq(rustls)实现 `http_client::HttpClient`,请求跑在 gpui 的后台 +//! executor 线程上,不阻塞 UI。 +//! +//! 范围合同:只服务只读资源加载(GET 语义的图片/附件),跟随重定向; +//! 不实现代理与流式 body——gpui 图片缓存一次性 `read_to_end`,聚合 body +//! 足够。 +//! +//! 错误类型用 gpui `http_client` 再导出的 `Result`(trait 签名要求), +//! 本 crate 不直接依赖 anyhow。 + +use std::sync::Arc; +use std::time::Duration; + +use futures::AsyncReadExt; +use gpui::BackgroundExecutor; +use gpui::http_client::{AsyncBody, HttpClient, Result as HttpResult, Url, anyhow}; + +/// 单张图片的响应体上限。gpui 会把 body 全量读进内存再解码,超大响应 +/// (错误配置的链接指向视频等)不该拖垮查看器。 +const MAX_RESPONSE_BYTES: u64 = 64 * 1024 * 1024; + +pub struct UreqClient { + executor: BackgroundExecutor, + agent: ureq::Agent, +} + +impl UreqClient { + pub fn new(executor: BackgroundExecutor) -> Self { + let agent = ureq::config::Config::builder() + .timeout_global(Some(Duration::from_secs(30))) + // 状态码不当错误:gpui 侧按 `status().is_success()` 自行分流, + // 让它拿到真实状态码而不是 ureq 的 Status 错误。 + .http_status_as_error(false) + .build() + .new_agent(); + Self { executor, agent } + } +} + +type FetchedBody = (u16, Vec<(String, Vec)>, Vec); + +fn fetch_blocking( + agent: ureq::Agent, + parts: gpui::http_client::http::request::Parts, + request_bytes: Vec, +) -> HttpResult { + let mut builder = ureq::http::Request::builder() + .method(parts.method.as_str()) + .uri(parts.uri.to_string()); + for (name, value) in parts.headers.iter() { + builder = builder.header(name.as_str(), value.as_bytes()); + } + let request = builder + .body(request_bytes) + .map_err(|error| anyhow!("构造请求失败: {error}"))?; + let mut response = agent.run(request).map_err(|error| anyhow!("{error}"))?; + let status = response.status().as_u16(); + let mut header_pairs: Vec<(String, Vec)> = Vec::new(); + for (name, value) in response.headers().iter() { + header_pairs.push((name.as_str().to_owned(), value.as_bytes().to_vec())); + } + let bytes = response + .body_mut() + .with_config() + .limit(MAX_RESPONSE_BYTES) + .read_to_vec() + .map_err(|error| anyhow!("读取响应失败: {error}"))?; + Ok((status, header_pairs, bytes)) +} + +impl HttpClient for UreqClient { + fn type_name(&self) -> &'static str { + std::any::type_name::() + } + + fn user_agent(&self) -> Option<&gpui::http_client::http::HeaderValue> { + None + } + + fn proxy(&self) -> Option<&Url> { + None + } + + fn send( + &self, + request: gpui::http_client::http::Request, + ) -> futures::future::BoxFuture<'static, HttpResult>> + { + let agent = self.agent.clone(); + let executor = self.executor.clone(); + Box::pin(async move { + let (parts, mut body) = request.into_parts(); + // AsyncBody 是异步流,在 async 上下文里先聚合成字节再交给 + // 阻塞的 ureq;图片加载的请求体实际为空。 + let mut request_bytes = Vec::new(); + body.read_to_end(&mut request_bytes).await?; + + let (status, header_pairs, bytes) = + executor.spawn(async move { fetch_blocking(agent, parts, request_bytes) }).await?; + + let mut builder = gpui::http_client::http::Response::builder().status(status); + for (name, value) in header_pairs { + builder = builder.header(name, value); + } + builder + .body(AsyncBody::from(bytes)) + .map_err(|error| anyhow!("构造响应失败: {error}")) + }) + } +} + +/// 注册为 gpui 的全局 HTTP 客户端;`gpui_shell::init` 调用一次。 +pub fn register(cx: &mut gpui::App) { + cx.set_http_client(Arc::new(UreqClient::new(cx.background_executor().clone()))); +} diff --git a/nebula_app/src/gpui_shell/math_view.rs b/nebula_app/src/gpui_shell/math_view.rs index df317d2..efaf8b9 100644 --- a/nebula_app/src/gpui_shell/math_view.rs +++ b/nebula_app/src/gpui_shell/math_view.rs @@ -63,9 +63,7 @@ pub fn register(cx: &mut App) { } // 探针编译:失败的公式仍是文档文本,交回组件库按代码样式排版。 assets.layout(&spec.source, spec.display, PROBE_PX, 1.0)?; - Some( - MathView { source: spec.source.clone(), display: spec.display }.into_any_element(), - ) + Some(MathView { source: spec.source.clone(), display: spec.display }.into_any_element()) }); } @@ -119,6 +117,11 @@ impl MathAssets { } } + /// 数学字体加载失败时终端覆盖层必须保留源码,不能先跳格再画空位图。 + pub(crate) fn can_rasterize(&self) -> bool { + self.rasterizer.is_some() + } + /// 编排(含负缓存:失败公式不逐帧重试)。 pub(crate) fn layout( &mut self, @@ -137,10 +140,15 @@ impl MathAssets { 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); + 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) @@ -275,8 +283,7 @@ fn compose_image( pixel[3] = *alpha; } let buffer = image::RgbaImage::from_raw(width, height, bgra)?; - let geometry = - ImageGeometry { baseline: baseline as u32, pad: CANVAS_PAD, width, height }; + let geometry = ImageGeometry { baseline: baseline as u32, pad: CANVAS_PAD, width, height }; Some((Arc::new(gpui::RenderImage::new([Frame::new(buffer)])), geometry)) } @@ -375,14 +382,15 @@ impl Element for MathView { 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 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 pixels_per_point = crate::math::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| { @@ -455,13 +463,13 @@ impl Element for MathView { // 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 pixels_per_point = crate::math::pixels_per_point(window.scale_factor()); let assets = cx.global_mut::(); let Some((layout, pixel_size)) = assets.fit( &self.source, self.display, nominal, - scale_factor, + pixels_per_point, bounds_width, ) else { return; @@ -496,7 +504,7 @@ impl MathView { &self.source, self.display, pixel_size, - window.scale_factor(), + crate::math::pixels_per_point(window.scale_factor()), bounds.left(), baseline, color, @@ -508,12 +516,14 @@ impl MathView { 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 = 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); } } @@ -548,10 +558,8 @@ pub(crate) fn paint_formula_image( 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 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(), diff --git a/nebula_app/src/gpui_shell/mod.rs b/nebula_app/src/gpui_shell/mod.rs index 4df2ff9..a263255 100644 --- a/nebula_app/src/gpui_shell/mod.rs +++ b/nebula_app/src/gpui_shell/mod.rs @@ -13,9 +13,11 @@ pub mod code_tab; pub mod config; pub mod doc_tabs; +pub mod http; pub mod math_view; pub mod prelude; pub mod session_restore; +pub mod network_settings; pub mod settings_pane; pub mod ssh_hosts; pub mod ssh_settings; @@ -35,11 +37,47 @@ use std::borrow::Cow; use workspace::NebulaWorkspace; +/// GPUI 壳的跨线程唤醒:托盘、mux ATTACH、runtime 控制都汇到工作区 pump。 +pub(crate) enum GpuiShellEvent { + TrayFocus(Option), + TrayQuit, + MuxAttach, + RuntimeControl(std::sync::Arc), +} + /// 在当前线程启动 GPUI 运行时并打开主窗口,阻塞直至 UI 退出。 /// /// GPUI 拥有自己的消息循环:主窗形态从主线程调用(winit 不启动); /// spike 形态从专用线程调用。一个进程内只允许调用一次。 pub fn run_shell() { + let (shell_tx, shell_rx) = std::sync::mpsc::channel(); + crate::tray::init_gpui({ + let tx = shell_tx.clone(); + move |command| { + let event = match command { + crate::tray::GpuiTrayCommand::Focus(pane) => GpuiShellEvent::TrayFocus(pane), + crate::tray::GpuiTrayCommand::Quit => GpuiShellEvent::TrayQuit, + }; + let _ = tx.send(event); + } + }); + // 驻留进程写 runtime.port:第二份 `nebula --gpui` 经 try_attach_existing + // ATTACH 回来,而不是再拉一套 PTY。Drop 会删 port 文件。 + let _runtime_server = crate::runtime_api::RuntimeServer::spawn_callback( + { + let tx = shell_tx; + move |callback| { + let event = match callback { + crate::runtime_api::RuntimeCallback::Attach => GpuiShellEvent::MuxAttach, + crate::runtime_api::RuntimeCallback::Control(dispatch) => { + GpuiShellEvent::RuntimeControl(dispatch) + }, + }; + let _ = tx.send(event); + } + }, + crate::runtime_api::RuntimeHub::new(), + ); gpui::Application::new().with_assets(Assets).run(|cx| { // GPUI is the only event loop in `--gpui` mode, so it owns the same // per-process hook pipe before the first TerminalView spawns. @@ -48,8 +86,9 @@ pub fn run_shell() { crate::ai_hook::spawn_config_guard(); init(cx); cx.activate(true); - open_main_window(cx, ai_events); + open_main_window(cx, ai_events, shell_rx); }); + crate::tray::shutdown(); } /// 组件库/主题/快捷键/用户配置的一次性初始化。 @@ -62,6 +101,9 @@ fn init(cx: &mut App) { // TextView 的公式渲染钩子:旧壳数学管线(compile → 栅格化)接入组件库 // 的 markdown 渲染;不注册时公式回退为源码文本。 math_view::register(cx); + // 网络图片加载:gpui 默认 NullHttpClient,markdown 文档里的 http(s) + // 图源全部失败;换成 ureq 实现(跑在后台 executor)。 + http::register(cx); theme::apply_chrome_theme(cx); terminal::init(cx); workspace::init(cx); @@ -85,9 +127,8 @@ fn register_bundled_fonts(cx: &App) { // GPUI resolves a family through the system collection first and silently // falls back when it is absent. Add Maple before any component/window can // resolve a font so the default remains the same private face as winit. - if let Err(error) = cx - .text_system() - .add_fonts(vec![Cow::Borrowed(crate::font_install::REQUIRED_FONT_BYTES)]) + if let Err(error) = + cx.text_system().add_fonts(vec![Cow::Borrowed(crate::font_install::REQUIRED_FONT_BYTES)]) { eprintln!("[nebula:gpui] failed to register bundled Maple font: {error}"); } @@ -105,10 +146,9 @@ fn register_bundled_fonts(cx: &App) { ); } }, - Err(error) => eprintln!( - "[nebula:gpui] failed to read imported font {}: {error}", - path.display() - ), + Err(error) => { + eprintln!("[nebula:gpui] failed to read imported font {}: {error}", path.display()) + }, } } } @@ -116,6 +156,7 @@ fn register_bundled_fonts(cx: &App) { fn open_main_window( cx: &mut App, ai_events: std::sync::mpsc::Receiver, + shell_events: std::sync::mpsc::Receiver, ) { let bounds = Bounds::centered(None, size(px(1080.0), px(720.0)), cx); let options = WindowOptions { @@ -130,7 +171,7 @@ fn open_main_window( }; cx.open_window(options, move |window, cx| { - let workspace = cx.new(|cx| NebulaWorkspace::new(window, ai_events, cx)); + let workspace = cx.new(|cx| NebulaWorkspace::new(window, ai_events, shell_events, cx)); // 调试/验收后门:启动即打开指定文档(见 open_document_at_startup)。 if let Ok(path) = std::env::var("NEBULA_GPUI_OPEN_DOC") && !path.is_empty() @@ -146,3 +187,65 @@ fn open_main_window( // 窗口存在后补一次视效应用(DWM backdrop 等窗口级效果此前无处落)。 wallpaper::refresh(cx); } + +/// 按 `tray` 设置挂上或摘掉系统托盘图标(旧壳 `tray::set_enabled`)。 +/// 启动、设置热应用、系统外观变化都经 [`theme::apply_chrome_theme`] 落到这里。 +pub(crate) fn apply_tray_setting() { + crate::tray::set_enabled(nebula_settings::RuntimeSettings::load().tray); +} + +/// 关窗保留会话:藏起 HWND,PTY 与工作区继续活着。 +/// +/// 旧壳 detach 是销毁窗口、进程无窗驻留。GPUI 用 `SW_HIDE` 达到同一 +/// 用户可见效果。禁止 `minimize_window`:托盘关着时任务栏不能留一个 +/// 点不掉的最小化窗口。 +pub(crate) fn hide_native_window(window: &gpui::Window) { + #[cfg(windows)] + { + native_show(window, false); + } + #[cfg(not(windows))] + { + let _ = window; + } +} + +/// 托盘点击 / 关掉托盘后:把藏着的窗口捞回来。 +pub(crate) fn show_native_window(window: &gpui::Window) { + #[cfg(windows)] + { + native_show(window, true); + } + window.activate_window(); +} + +pub(crate) fn reveal_all_windows(cx: &mut App) { + for handle in cx.windows() { + let _ = handle.update(cx, |_, window, _| { + show_native_window(window); + }); + } +} + +#[cfg(windows)] +fn native_show(window: &gpui::Window, show: bool) -> bool { + use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + let Ok(handle) = HasWindowHandle::window_handle(window) else { + return false; + }; + let RawWindowHandle::Win32(handle) = handle.as_raw() else { + return false; + }; + let hwnd = handle.hwnd.get() as *mut core::ffi::c_void; + let cmd = if show { + windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOW + } else { + windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE + }; + // SAFETY: hwnd 来自当前 GPUI 窗口;ShowWindow 对无效句柄安全失败。 + unsafe { + windows_sys::Win32::UI::WindowsAndMessaging::ShowWindow(hwnd, cmd); + } + true +} diff --git a/nebula_app/src/gpui_shell/network_settings.rs b/nebula_app/src/gpui_shell/network_settings.rs new file mode 100644 index 0000000..c2ef91c --- /dev/null +++ b/nebula_app/src/gpui_shell/network_settings.rs @@ -0,0 +1,265 @@ +//! 设置页的网络代理区(`SettingsPane` 的网络专属 impl 拆分文件)。 +//! +//! 合同对齐旧壳 `NebulaSettingsSection::Proxy`:测试横幅在最前,下面是 +//! 「代理方式」;只有 Custom 才展开协议下拉 + 地址。绕过列表、扫描、跳板 +//! 和每主机覆盖当前都不画。出网测试走 `ssh_session::start_proxy_test`, +//! 读的是落盘后的 `SshProxyConfig::load_global`。 + +use gpui::prelude::FluentBuilder as _; +use gpui::{Context, IntoElement, ParentElement as _, SharedString, Styled as _, div, px}; +use gpui_component::input::InputEvent; +use nebula_settings::ProxyModeName; + +use crate::display::{ + MANUAL_PROXY_PROTOCOL_OPTIONS, ManualProxyProtocol, ProxyTestStatus, manual_proxy_parts, + manual_proxy_value, +}; +use crate::gpui_shell::prelude::*; +use crate::gpui_shell::settings_pane::SettingsPane; +use crate::gpui_shell::widgets::NebulaButton; + +/// 旧壳 `ssh_proxy_test` 横幅高度。 +const PROXY_TEST_BANNER_H: f32 = 54.0; +/// 旧壳测试横幅与方式行之间的 18px 空隙。 +const PROXY_TEST_GAP: f32 = 18.0; +/// 旧壳 `ssh_proxy_mode_control` 紧凑宽度。 +const PROXY_MODE_SELECT_W: f32 = 156.0; +/// 旧壳 `ssh_proxy_manual_controls` 协议下拉。 +const PROXY_PROTOCOL_SELECT_W: f32 = 112.0; +const PROXY_MANUAL_GAP: f32 = 8.0; +/// 旧壳 `ssh_proxy_test_button`:约 108,下限 88。 +const PROXY_TEST_BUTTON_W: f32 = 108.0; +const PROXY_TEST_BUTTON_MIN_W: f32 = 88.0; + +/// Custom 才画出地址行。Off / System 的命中与绘制都不含 URL / bypass。 +pub(super) fn shows_manual_proxy_address(mode: ProxyModeName) -> bool { + mode == ProxyModeName::Custom +} + +/// 序号或状态对不上时丢掉过期结果,避免旧成功冒充新配置。 +pub(super) fn apply_proxy_test_result( + seq: u64, + status: &ProxyTestStatus, + request_id: u64, + ok: bool, + message: &str, + elapsed_ms: u64, +) -> Option { + if request_id != seq || !matches!(status, ProxyTestStatus::Running) { + return None; + } + Some(if ok { + ProxyTestStatus::Success { elapsed_ms, route: message.to_owned() } + } else { + ProxyTestStatus::Failed { message: message.to_owned() } + }) +} + +impl SettingsPane { + pub(super) fn invalidate_proxy_test(&mut self) { + self.proxy_test_seq = self.proxy_test_seq.wrapping_add(1); + self.proxy_test_status = ProxyTestStatus::Idle; + } + + fn current_proxy_protocol(&self, cx: &gpui::App) -> ManualProxyProtocol { + let row = self + .proxy_protocol_select + .read(cx) + .selected_index(cx) + .map(|path| path.row) + .unwrap_or(0); + MANUAL_PROXY_PROTOCOL_OPTIONS.get(row).copied().unwrap_or_default() + } + + fn composed_proxy_url(&self, cx: &gpui::App) -> String { + let protocol = self.current_proxy_protocol(cx); + let typed = self.proxy_url_input.read(cx).value(); + let (_, host) = manual_proxy_parts(typed.trim()); + manual_proxy_value(protocol, host) + } + + /// 把协议 + 地址写成 `ssh_proxy_url`。测试线程随后读落盘值。 + pub(super) fn commit_proxy_address(&mut self, cx: &mut Context) { + let url = self.composed_proxy_url(cx); + self.persist(&[("ssh_proxy_url", url)], cx); + } + + pub(super) fn request_proxy_test(&mut self, cx: &mut Context) { + if matches!(self.proxy_test_status, ProxyTestStatus::Running) { + return; + } + // 先落盘当前输入,再跑测试:验证的是下一条真实连接会读到的值。 + self.commit_proxy_address(cx); + self.proxy_test_seq = self.proxy_test_seq.wrapping_add(1); + let request_id = self.proxy_test_seq; + self.proxy_test_status = ProxyTestStatus::Running; + let receiver = match crate::ssh_session::start_proxy_test(request_id) { + Ok(receiver) => receiver, + Err(error) => { + self.proxy_test_status = ProxyTestStatus::Failed { + message: format!("无法启动网络测试:{error}"), + }; + cx.notify(); + return; + }, + }; + cx.spawn(async move |this, cx| { + let result = receiver.await; + let _ = this.update(cx, |pane, cx| { + match result { + Ok(result) => pane.finish_proxy_test( + result.request_id, + result.ok, + &result.message, + result.elapsed_ms, + ), + Err(_) => { + pane.finish_proxy_test(request_id, false, "网络测试任务意外结束,请重试", 0) + }, + } + cx.notify(); + }); + }) + .detach(); + cx.notify(); + } + + fn finish_proxy_test(&mut self, request_id: u64, ok: bool, message: &str, elapsed_ms: u64) { + if let Some(status) = apply_proxy_test_result( + self.proxy_test_seq, + &self.proxy_test_status, + request_id, + ok, + message, + elapsed_ms, + ) { + self.proxy_test_status = status; + } + } + + pub(super) fn on_proxy_address_event(&mut self, event: &InputEvent, cx: &mut Context) { + if matches!(event, InputEvent::Change) { + self.commit_proxy_address(cx); + } + } + + pub(super) fn section_network(&mut self, cx: &mut Context) -> gpui::Div { + let custom = shows_manual_proxy_address(self.runtime.ssh_proxy_mode); + self.group("网络代理", cx) + .child(self.proxy_test_banner(cx)) + .child(div().h(px(PROXY_TEST_GAP)).w_full().flex_shrink_0()) + .child(self.proxy_mode_row(cx)) + .when(custom, |page| page.child(self.proxy_address_row(cx))) + } + + fn proxy_test_banner(&self, cx: &mut Context) -> gpui::Div { + let theme = cx.theme(); + let running = matches!(self.proxy_test_status, ProxyTestStatus::Running); + let (status, status_color) = match &self.proxy_test_status { + ProxyTestStatus::Idle => { + (SharedString::from("测试当前设置是否可以访问网络"), theme.muted_foreground) + }, + ProxyTestStatus::Running => { + (SharedString::from("正在通过当前设置测试网络…"), theme.link) + }, + ProxyTestStatus::Success { elapsed_ms, route } => ( + SharedString::from(format!("网络连接正常 · {route} · {elapsed_ms} ms")), + theme.success, + ), + ProxyTestStatus::Failed { message } => { + (SharedString::from(format!("测试失败:{message}")), theme.danger) + }, + }; + let caption = if running { "测试中…" } else { "测试网络" }; + h_flex() + .w_full() + .h(px(PROXY_TEST_BANNER_H)) + .flex_shrink_0() + .items_center() + .pl(px(14.0)) + .pr(px(12.0)) + .gap_3() + .rounded(px(crate::display::ui::tokens::radius::OVERLAY)) + .border_1() + .border_color(theme.border) + .bg(theme.muted) + .child(div().flex_1().min_w_0().text_color(status_color).child(status)) + .child( + div() + .w(px(PROXY_TEST_BUTTON_W)) + .min_w(px(PROXY_TEST_BUTTON_MIN_W)) + .flex_shrink_0() + .child( + NebulaButton::new("proxy-test-network") + .label(caption) + .outline() + .disabled(running) + .on_click(cx.listener(|this, _, _, cx| this.request_proxy_test(cx))), + ), + ) + } + + fn proxy_mode_row(&self, cx: &Context) -> impl IntoElement { + let select = self.select_of("ssh_proxy_mode"); + self.row( + "代理方式", + div() + .w(px(PROXY_MODE_SELECT_W)) + .text_color(cx.theme().link) + .children(select.map(|state| Select::new(&state))), + ) + } + + fn proxy_address_row(&self, cx: &Context) -> impl IntoElement { + self.row( + "代理地址", + h_flex() + .flex_1() + .min_w(px(PROXY_PROTOCOL_SELECT_W + PROXY_MANUAL_GAP + 80.0)) + .max_w(px(360.0)) + .items_center() + .gap(px(PROXY_MANUAL_GAP)) + .child( + div() + .w(px(PROXY_PROTOCOL_SELECT_W)) + .flex_shrink_0() + .text_color(cx.theme().link) + .child(Select::new(&self.proxy_protocol_select)), + ) + .child(div().flex_1().min_w_0().child(Input::new(&self.proxy_url_input))), + ) + } +} + +#[cfg(test)] +mod tests { + use super::{apply_proxy_test_result, shows_manual_proxy_address}; + use crate::display::ProxyTestStatus; + use nebula_settings::ProxyModeName; + + #[test] + fn custom_mode_is_the_only_one_that_expands_the_address_row() { + assert!(shows_manual_proxy_address(ProxyModeName::Custom)); + assert!(!shows_manual_proxy_address(ProxyModeName::Off)); + assert!(!shows_manual_proxy_address(ProxyModeName::System)); + } + + #[test] + fn stale_proxy_test_results_are_discarded_after_invalidate() { + let seq = 7; + let running = ProxyTestStatus::Running; + assert_eq!(apply_proxy_test_result(seq, &running, 6, true, "直接连接", 12), None); + assert_eq!( + apply_proxy_test_result(seq, &ProxyTestStatus::Idle, seq, true, "直接连接", 12), + None + ); + assert_eq!( + apply_proxy_test_result(seq, &running, seq, true, "直接连接", 41), + Some(ProxyTestStatus::Success { elapsed_ms: 41, route: "直接连接".into() }) + ); + assert_eq!( + apply_proxy_test_result(seq, &running, seq, false, "连接被拒绝", 8), + Some(ProxyTestStatus::Failed { message: "连接被拒绝".into() }) + ); + } +} diff --git a/nebula_app/src/gpui_shell/prelude.rs b/nebula_app/src/gpui_shell/prelude.rs index 7135324..a2b804f 100644 --- a/nebula_app/src/gpui_shell/prelude.rs +++ b/nebula_app/src/gpui_shell/prelude.rs @@ -13,7 +13,9 @@ pub use gpui_component::{ checkbox::Checkbox, color_picker::{ColorPicker, ColorPickerEvent, ColorPickerState}, dialog::{Dialog, DialogButtonProps}, - dock::{DockArea, DockAreaState, DockItem, Panel, PanelControl, PanelEvent, PanelState, TabPanel}, + dock::{ + DockArea, DockAreaState, DockItem, Panel, PanelControl, PanelEvent, PanelState, TabPanel, + }, h_flex, input::{Input, InputEvent, InputState}, menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu}, diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs index afac844..f59cfaf 100644 --- a/nebula_app/src/gpui_shell/settings_pane.rs +++ b/nebula_app/src/gpui_shell/settings_pane.rs @@ -1,3500 +1,3748 @@ -//! 设置页(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); - }), - ), - ) - }) - } -} +//! 设置页(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, MouseMoveEvent, 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; + +#[path = "background_color.rs"] +mod background_color; + +/// 主题下拉(展示名 = 持久化名,与旧壳一致)。 +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; +// 触发条仍 220,对齐旧壳 combobox。弹层加宽,长族名 + 徽标才读得完。 +const FONT_PICKER_WIDTH: f32 = 220.0; +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 font_display_name(name: &str) -> String { + let trimmed = name.trim(); + let lower = trimmed.to_ascii_lowercase(); + for ext in [".ttf", ".otf", ".ttc", ".otc", ".woff", ".woff2"] { + if let Some(stem) = lower.strip_suffix(ext) { + return trimmed[..stem.len()].to_owned(); + } + } + trimmed.to_owned() +} + +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 +} + +/// 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), +} + +pub(super) 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 后整体重载。 + pub(super) runtime: RuntimeSettings, + /// 当前分区(`SECTIONS` 下标);默认落在应用主页。 + active_section: usize, + about_logo: Arc, + about_update: AboutUpdateState, + about_update_seq: u64, + selects: Vec<(&'static str, SharedSelect)>, + shell_select: SharedShellSelect, + /// 外观页背景色:闭态 combobox + 开态 SV/色相/色板/hex 浮层(旧壳 + /// `SettingsDropdown::BackgroundColor`,不是组件库 ColorPicker)。 + bg_picker_open: bool, + bg_picker_hsv: (f32, f32, f32), + bg_picker_drag: Option, + bg_hex_input: Entity, + bg_hex_focused: bool, + bg_hex_syncing: bool, + bg_picker_trigger_bounds: Option>, + bg_sv_bounds: Option>, + bg_hue_bounds: Option>, + opacity_slider: Entity, + wallpaper_opacity_slider: Entity, + pub(super) proxy_url_input: Entity, + pub(super) proxy_protocol_select: SharedSelect, + pub(super) proxy_test_seq: u64, + pub(super) proxy_test_status: crate::display::ProxyTestStatus, + 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( + "bell", + &["关", "闪烁", "声音", "闪烁 + 声音"], + &["none", "visual", "audible", "both"], + runtime.bell.settings_value(), + window, + cx, + ); + 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 bg_hex_input = { + let term = crate::gpui_shell::theme::chrome_theme_resolved(cx).palette().term_bg; + let rgb = runtime.background.unwrap_or([term.r, term.g, term.b]); + let input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("#rrggbb") + .default_value(format_hex_rgb(rgb)) + }); + subscriptions.push(cx.subscribe_in( + &input, + window, + |this: &mut Self, + _: &Entity, + event: &InputEvent, + window: &mut Window, + cx: &mut Context| { + this.on_bg_hex_event(event, window, cx); + }, + )); + input + }; + 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_protocol, proxy_address) = + crate::display::manual_proxy_parts(&runtime.ssh_proxy_url); + let proxy_protocol_ix = crate::display::MANUAL_PROXY_PROTOCOL_OPTIONS + .iter() + .position(|item| *item == proxy_protocol) + .unwrap_or(0); + let proxy_protocol_select = cx.new(|cx| { + SelectState::new( + vec![SharedString::from("SOCKS5"), SharedString::from("HTTP")], + Some(IndexPath::default().row(proxy_protocol_ix)), + window, + cx, + ) + }); + subscriptions.push(cx.subscribe_in( + &proxy_protocol_select, + window, + |this: &mut Self, + _, + event: &SelectEvent>, + _, + cx: &mut Context| { + if matches!(event, SelectEvent::Confirm(Some(_))) { + this.commit_proxy_address(cx); + } + }, + )); + let proxy_url_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("127.0.0.1:7890") + .pattern(regex::Regex::new(r"^[^\s]{0,256}$").expect("static regex")) + .default_value(proxy_address.to_owned()) + }); + subscriptions.push(cx.subscribe_in( + &proxy_url_input, + window, + |this: &mut Self, _, event: &InputEvent, _, cx: &mut Context| { + this.on_proxy_address_event(event, cx); + }, + )); + 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(); + } + }, + )); + + let bg_picker_hsv = { + let term = crate::gpui_shell::theme::chrome_theme_resolved(cx).palette().term_bg; + let rgb = runtime.background.unwrap_or([term.r, term.g, term.b]); + crate::display::rgb_to_hsv(crate::display::color::Rgb::new(rgb[0], rgb[1], rgb[2])) + }; + + 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, + bg_picker_open: false, + bg_picker_hsv, + bg_picker_drag: None, + bg_hex_input, + bg_hex_focused: false, + bg_hex_syncing: false, + bg_picker_trigger_bounds: None, + bg_sv_bounds: None, + bg_hue_bounds: None, + opacity_slider, + wallpaper_opacity_slider, + proxy_url_input, + proxy_protocol_select, + proxy_test_seq: 0, + proxy_test_status: crate::display::ProxyTestStatus::Idle, + 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` → 通知宿主热应用。 + pub(super) 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); + if updates.iter().any(|(key, _)| matches!(*key, "ssh_proxy_mode" | "ssh_proxy_url")) { + self.invalidate_proxy_test(); + } + 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; + } + if key == "background_image_cover_chrome" { + self.request_cover_chrome(value, window, cx); + return; + } + self.persist(&[(key, (value as u8).to_string())], cx); + } + + /// 旧壳 `request_toggle_background_image_cover_chrome`:关→开要确认 + /// (壳变半透明、控件对比度下降);开→关直接生效。 + fn request_cover_chrome(&mut self, enable: bool, window: &mut Window, cx: &mut Context) { + if !enable { + self.persist(&[("background_image_cover_chrome", "0".to_owned())], cx); + return; + } + if self.runtime.background_image_cover_chrome { + return; + } + let pane = cx.entity().downgrade(); + window.open_dialog(cx, move |dialog, window, _cx| { + let pane = pane.clone(); + center_confirm_dialog(dialog, window) + .title("让背景图覆盖窗口控件区域?") + .confirm() + .button_props( + DialogButtonProps::default().ok_text("开启").cancel_text("取消"), + ) + .child(SharedString::from( + "背景图会延伸到标题栏、窗口按钮、Tab 与 SSH 侧栏下方,低对比度图片可能影响操作可见性;界面仍会保留最低不透明度保护。", + )) + .on_ok(move |_, _, cx| { + let _ = pane.update(cx, |this, cx| { + this.persist(&[("background_image_cover_chrome", "1".to_owned())], cx); + }); + true + }) + }); + cx.notify(); + } + + /// 对齐旧壳 `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); + } + + /// 旧壳“恢复默认设置”只重置外观,不触碰 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(font_display_name(&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)) + // 旧壳 begin_preview_face:候选行用该字体自己的字形画名字。 + // 展示名去掉文件后缀(导入路径偶尔会把 .ttf 带进族名)。 + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_sm() + .font_family(family) + .child(font_display_name(&entry.name)), + ) + .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 槽托管,不参与设置文档流高度; + // 弹层宽于触发条,长族名和「内置/导入/比例」徽标并排才读得完。 + .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() + .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 + }) + .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), + ) + }, + ) + } + + pub(super) 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)) + } + + pub(super) fn row(&self, label: &'static str, control: impl IntoElement) -> impl IntoElement { + self.row_inner(label, None, control) + } + + /// 标题旁的撤销箭头只在该项被覆盖时出现,点下去清回默认 + /// (旧壳背景图的「↶」同一合同)。 + fn row_with_reset( + &self, + label: &'static str, + dirty: bool, + on_reset: impl Fn(&mut Self, &mut Window, &mut Context) + 'static, + control: impl IntoElement, + cx: &mut Context, + ) -> impl IntoElement { + let reset = dirty.then(|| { + let id = SharedString::from(format!("setting-reset-{label}")); + div() + .id(id) + .size(px(22.0)) + .rounded_md() + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .hover(|el| el.bg(cx.theme().list_hover)) + .tooltip(|window, cx| { + gpui_component::tooltip::Tooltip::new("还原为默认值").build(window, cx) + }) + .on_click(cx.listener(move |this, _, window, cx| on_reset(this, window, cx))) + .child(Icon::new(IconName::Undo2).xsmall()) + .into_any_element() + }); + self.row_inner(label, reset, control) + } + + fn row_inner( + &self, + label: &'static str, + reset: Option, + 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( + h_flex() + .flex_1() + .min_w_0() + .pl_4() + .items_center() + .gap_1() + .child(div().min_w_0().child(label)) + .children(reset), + ) + .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),不是勾选框。 + /// 四通道动画对照 `SettingsToggleAnim`(LiquidToggle 过冲 / 按住拉伸 / recoil)。 + 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 startup_directory_row(&self, cx: &mut Context) -> impl IntoElement { + let current = self + .runtime + .startup_directory + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()); + let has_dir = current.is_some(); + let label: SharedString = current + .map(str::to_owned) + .unwrap_or_else(|| "继承当前目录".to_owned()) + .into(); + let color = if has_dir { cx.theme().link } else { cx.theme().muted_foreground }; + self.row( + "启动目录", + h_flex() + .gap_2() + .items_center() + .child( + div() + .id("startup-directory") + .min_w_0() + .max_w(px(280.0)) + .truncate() + .cursor_pointer() + .text_color(color) + .child(label) + .on_click(cx.listener(|this, _, _, cx| { + this.pick_startup_directory(cx); + })), + ) + .when(has_dir, |row| { + row.child( + NebulaButton::new("startup-directory-clear").label("清除").on_click( + cx.listener(|this, _, _, cx| { + this.clear_startup_directory(cx); + }), + ), + ) + }), + ) + } + + fn pick_startup_directory(&mut self, cx: &mut Context) { + let picked = cx.prompt_for_paths(gpui::PathPromptOptions { + files: false, + directories: true, + 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 }; + if !path.is_dir() { + return; + } + let value = path.to_string_lossy().into_owned(); + let _ = this.update(cx, |pane, cx| { + pane.persist(&[("startup_directory", value)], cx); + }); + }) + .detach(); + } + + fn clear_startup_directory(&mut self, cx: &mut Context) { + self.persist(&[("startup_directory", String::new())], 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 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 path_label: Option = + current.as_deref().filter(|path| !path.trim().is_empty()).map(|path| { + std::path::Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(path) + .to_owned() + .into() + }); + self.row_with_reset( + "背景图片", + has_image, + |this, _, cx| { + this.persist(&[("background_image", String::new())], cx); + }, + h_flex() + .items_center() + .gap_2() + .child(NebulaButton::new("background-image-choose").label("选择图片").on_click( + cx.listener(|this, _, _, cx| { + this.choose_background_image(cx); + }), + )) + .when_some(path_label, |row, name| { + row.child( + div() + .max_w(px(180.0)) + .min_w_0() + .truncate() + .text_color(cx.theme().muted_foreground) + .child(name), + ) + }), + 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.startup_directory_row(cx)) + .child(self.select_row("bell", "终端铃声", 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_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), + ), + ); + } + } + + // 旧壳按键映射页没有悬挂分组标题:搜索框独占整行(row_w × 34px), + // 占位「搜索动作或按键…」,下面再空 12px 才到冲突条 / 分组。 + v_flex() + .w_full() + // 捕获态的「点击任何位置先撤销」(旧壳 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(); + } + }), + ) + }) + .child( + div() + .w_full() + .h(px(34.0)) + .flex_shrink_0() + .child(Input::new(&self.keymap_search_input).w_full()), + ) + .child(div().h(px(12.0)).w_full().flex_shrink_0()) + // 冲突是允许存在的可见状态,用组件库的 Warning Alert 呈现; + // 不再用自绘 danger 色块,也不静默删掉另一个动作。 + .when_some(clash_note, |section, note| { + section.child(Alert::warning("keymap-clash-warning", note).small()) + }) + .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 bg_picker_open = self.bg_picker_open && self.active_section == 1; + let bg_dragging = self.bg_picker_drag.is_some(); + let ssh_editor_modal = self.ssh_editor_modal(cx); + let show_reset = !matches!(self.active_section, 0 | 4); + + 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( + h_flex() + .flex_1() + .items_center() + .gap_1() + .child(SECTIONS[self.active_section]) + .when(show_reset, |header| { + header.child( + div() + .id("settings-reset") + .size(px(24.0)) + .rounded_md() + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .hover(|el| el.bg(cx.theme().list_hover)) + .tooltip(|window, cx| { + gpui_component::tooltip::Tooltip::new( + "还原此页为默认值", + ) + .build(window, cx) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.reset_appearance(window, cx); + })) + .child(Icon::new(IconName::Undo2)), + ) + }), + ), + ) + .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)), + ), + ) + .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); + }), + ), + ) + }) + .when(bg_picker_open, |root| { + root.on_key_down(cx.listener(|this, event: &KeyDownEvent, _, cx| { + if event.keystroke.key.eq_ignore_ascii_case("escape") { + cx.stop_propagation(); + this.close_background_picker(cx); + } + })) + .when(bg_dragging, |root| { + root.on_mouse_move(cx.listener( + |this, event: &MouseMoveEvent, window, cx| { + this.on_bg_picker_move(event, window, cx); + }, + )) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.finish_bg_picker_drag(cx); + }), + ) + }) + .child( + div() + .id("bg-picker-dismiss-layer") + .absolute() + .top_0() + .right_0() + .bottom_0() + .left_0() + .cursor_default() + .when(bg_dragging, |layer| { + layer.on_mouse_move(cx.listener( + |this, event: &MouseMoveEvent, window, cx| { + this.on_bg_picker_move(event, window, cx); + }, + )) + }) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.finish_bg_picker_drag(cx); + }), + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + cx.stop_propagation(); + this.close_background_picker(cx); + }), + ), + ) + }) + } +} diff --git a/nebula_app/src/gpui_shell/ssh_settings.rs b/nebula_app/src/gpui_shell/ssh_settings.rs index ba0a922..f0dc891 100644 --- a/nebula_app/src/gpui_shell/ssh_settings.rs +++ b/nebula_app/src/gpui_shell/ssh_settings.rs @@ -26,6 +26,16 @@ use crate::gpui_shell::widgets::NebulaButton; /// 删除撤销窗口时长,旧壳 Undo 条同值。 const SSH_DELETE_UNDO_SECS: u64 = 8; +/// 对齐旧壳 `display/ssh_editor_render.rs` / `settings.rs` 的 SSH 几何。 +const SSH_EDITOR_W: f32 = 440.0; +const SSH_EDITOR_LABEL_W: f32 = 84.0; +const SSH_EDITOR_FIELD_GAP: f32 = 6.0; +const SSH_EDITOR_SPACE_S: f32 = 12.0; +const SSH_EDITOR_SPACE_XS: f32 = 8.0; +const SSH_EDITOR_IDENT_NAME_H: f32 = 30.0; +const SSH_HOST_ROW_H: f32 = 58.0; +const SSH_HOST_GAP: f32 = 8.0; + /// 设置页 SSH 添加/编辑面板的非文本草稿。文字实体常驻在 `SettingsPane`, /// 这样输入事件可以统一使测试结果失效;草稿只保存认证、密钥和编辑身份。 #[derive(Clone)] @@ -544,13 +554,23 @@ impl SettingsPane { let field_row = |label: &'static str, control: gpui::AnyElement| { h_flex() - .gap_3() + .gap(px(SSH_EDITOR_SPACE_S)) .items_center() - .child(div().w(px(72.0)).flex_shrink_0().text_sm().child(label)) + .child( + div() + .w(px(SSH_EDITOR_LABEL_W)) + .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) + div() + .pl(px(SSH_EDITOR_LABEL_W + SSH_EDITOR_SPACE_S)) + .text_xs() + .text_color(muted) + .child(text) }; Some( @@ -588,7 +608,7 @@ impl SettingsPane { .p_6() .child( v_flex() - .w(px(500.0)) + .w(px(SSH_EDITOR_W)) .max_w(gpui::relative(1.0)) .max_h(gpui::relative(1.0)) .rounded(px(8.0)) @@ -621,26 +641,29 @@ impl SettingsPane { .min_h_0() .overflow_y_scrollbar() .p_4() - .gap_3() + .gap(px(SSH_EDITOR_SPACE_S)) .child( h_flex() .relative() - .gap_3() - .items_center() + .gap(px(SSH_EDITOR_SPACE_S)) + .items_start() .child(avatar) .child( v_flex() .flex_1() .min_w_0() + .h(px(46.0)) .child( - Input::new(&self.ssh_label_input) - .appearance(false) - .bordered(false) - .focus_bordered(false), + div().h(px(SSH_EDITOR_IDENT_NAME_H)).w_full().child( + Input::new(&self.ssh_label_input) + .appearance(false) + .bordered(false) + .focus_bordered(false), + ), ) .child( div() - .px_3() + .pt(px(2.0)) .text_xs() .text_color(muted) .truncate() @@ -649,18 +672,19 @@ impl SettingsPane { ) .children(icon_popup), ) - .child(div().w_full().h(px(1.0)).my_1().bg(theme.border)) + .child(div().w_full().h(px(1.0)).bg(theme.border)) .child( v_flex() .w_full() - .p_3() - .gap_3() + .p(px(SSH_EDITOR_SPACE_S)) + .gap(px(SSH_EDITOR_FIELD_GAP)) .rounded(px(8.0)) .border_1() .border_color(theme.border) .bg(theme.group_box) .child( div() + .mb(px(SSH_EDITOR_SPACE_XS - SSH_EDITOR_FIELD_GAP)) .text_sm() .text_color(theme.group_box_foreground) .child("连接"), @@ -675,11 +699,11 @@ impl SettingsPane { )) .child( h_flex() - .gap_3() + .gap(px(SSH_EDITOR_SPACE_S)) .items_center() .child( div() - .w(px(72.0)) + .w(px(SSH_EDITOR_LABEL_W)) .flex_shrink_0() .text_sm() .child("端口"), @@ -700,14 +724,15 @@ impl SettingsPane { .child( v_flex() .w_full() - .p_3() - .gap_3() + .p(px(SSH_EDITOR_SPACE_S)) + .gap(px(SSH_EDITOR_FIELD_GAP)) .rounded(px(8.0)) .border_1() .border_color(theme.border) .bg(theme.group_box) .child( div() + .mb(px(SSH_EDITOR_SPACE_XS - SSH_EDITOR_FIELD_GAP)) .text_sm() .text_color(theme.group_box_foreground) .child("认证"), @@ -756,11 +781,11 @@ impl SettingsPane { section .child( h_flex() - .gap_3() + .gap(px(SSH_EDITOR_SPACE_S)) .items_center() .child( div() - .w(px(72.0)) + .w(px(SSH_EDITOR_LABEL_W)) .flex_shrink_0() .text_sm() .child("密码"), @@ -804,7 +829,7 @@ impl SettingsPane { "密码不会写入配置文件,只保存到 Windows 凭据管理器。", )) .child( - div().pl(px(84.0)).child( + div().pl(px(SSH_EDITOR_LABEL_W + SSH_EDITOR_SPACE_S)).child( Checkbox::new("ssh-save-password") .small() .checked(editor.save_password) @@ -832,11 +857,11 @@ impl SettingsPane { section .child( h_flex() - .gap_3() + .gap(px(SSH_EDITOR_SPACE_S)) .items_center() .child( div() - .w(px(72.0)) + .w(px(SSH_EDITOR_LABEL_W)) .flex_shrink_0() .text_sm() .child("私钥"), @@ -863,7 +888,7 @@ impl SettingsPane { })), ), ) - .child(v_flex().pl(px(84.0)).gap_2().children(key_rows)) + .child(v_flex().pl(px(SSH_EDITOR_LABEL_W + SSH_EDITOR_SPACE_S)).gap(px(SSH_EDITOR_FIELD_GAP)).children(key_rows)) }) .when(!shows_password && !shows_keys, |section| { section.child(field_note( @@ -1156,6 +1181,14 @@ impl SettingsPane { .map(|settings| settings.font_family.clone()) .unwrap_or_else(|| String::from("Maple Mono Normal NF CN")) .into(); + let font_px = cx + .try_global::() + .map(|settings| settings.base_font_size_px) + .unwrap_or(15.0); + // 只保留标题→地址的行距(旧壳 title_y + 0.95*cell_h / 副行 0.78)。 + // 图标槽、行内 gap、卡片描边退回改前那套:整行重排后观感反而不如原来。 + let title_h = font_px; + let subtitle_h = font_px * 0.78; let hidden: Vec = self.ssh_hosts.hidden_hosts().to_vec(); let delete_confirm = self.ssh_delete_confirm.clone(); @@ -1178,7 +1211,7 @@ impl SettingsPane { .group(row_group.clone()) // 旧壳 `SSH_HOST_ROW_H` 固定 58px;两行文字与 OS 图标在 // 这个高度里共用中线,不能压成普通 48px 设置行。 - .h(px(58.0)) + .h(px(SSH_HOST_ROW_H)) .w_full() .px_3() .items_center() @@ -1219,17 +1252,28 @@ impl SettingsPane { v_flex() .flex_1() .min_w_0() - .gap_1() - .child(div().truncate().child(label)) + .justify_center() + .child( + div() + .h(px(title_h * 0.95)) + .flex() + .items_center() + .text_size(px(title_h)) + .line_height(px(title_h)) + .truncate() + .child(label), + ) .child( h_flex() + .h(px(subtitle_h)) .gap_2() .items_center() // 副行与旧壳同合同:只放目的地本身;来源用 // 小徽章表达(config 源的删除语义是隐藏)。 .child( div() - .text_xs() + .text_size(px(subtitle_h)) + .line_height(px(subtitle_h)) .text_color(muted) .truncate() .child(host.clone()), @@ -1394,7 +1438,7 @@ impl SettingsPane { ), ), ) - .child(div().h(px(8.0))) + .child(div().h(px(SSH_HOST_GAP))) .child( v_flex() .w_full() @@ -1419,22 +1463,20 @@ impl SettingsPane { ) }), ) - .child(div().h(px(8.0))) + .child(div().h(px(SSH_HOST_GAP))) .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(); - }), - ), - ) + .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( @@ -1479,17 +1521,15 @@ impl SettingsPane { .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( - 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)), - )), + 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)| { diff --git a/nebula_app/src/gpui_shell/terminal/element.rs b/nebula_app/src/gpui_shell/terminal/element.rs index 2b6c50c..b6c0e2f 100644 --- a/nebula_app/src/gpui_shell/terminal/element.rs +++ b/nebula_app/src/gpui_shell/terminal/element.rs @@ -12,10 +12,12 @@ //! 字符左右跳动。逐 cell 塑形时首字形天然落在 x=0,排版引擎没有移动 //! 字形的权力;单字符行在 GPUI 行缓存中按 (字符, 字体) 去重,命中率极高。 +use std::collections::HashSet; + use gpui::{ - App, Bounds, Element, ElementId, GlobalElementId, HitboxBehavior, Hsla, InspectorElementId, - LayoutId, Pixels, Rgba, SharedString, Style, TextRun, UnderlineStyle, Window, fill, outline, - point, px, relative, size, + App, Bounds, CursorStyle, Element, ElementId, GlobalElementId, Hitbox, HitboxBehavior, Hsla, + InspectorElementId, LayoutId, Pixels, Rgba, SharedString, Style, TextRun, UnderlineStyle, + Window, fill, outline, point, px, relative, size, }; use gpui_component::{ActiveTheme as _, PixelsExt as _}; use nebula_terminal::grid::Dimensions as _; @@ -41,6 +43,7 @@ pub struct TermLayout { line_height: Pixels, rows: usize, cols: usize, + hitbox: Hitbox, } impl TerminalElement { @@ -52,9 +55,10 @@ impl TerminalElement { rows: usize, cols: usize, cx: &App, - ) -> Option<(RenderSnapshot, Option, usize)> { + ) -> Option<(RenderSnapshot, Option, usize, HashSet<(u16, u16)>)> { let view = self.view.read(cx); let session = view.session.as_ref()?; + let hint_config = view.hint_config.clone(); let term = session.term.lock(); #[cfg(windows)] let prompt_line = if term.mode().intersects(TermMode::ALT_SCREEN | TermMode::VI) { @@ -72,7 +76,8 @@ impl TerminalElement { &SnapshotConfig { rows: rows as u16, cols: cols as u16 }, ); let history = term.history_size(); - Some((snapshot, prompt_line, history)) + let dashed = super::osc_links::dashed_cells(&term, &hint_config, rows, cols); + Some((snapshot, prompt_line, history, dashed)) } } @@ -147,10 +152,16 @@ impl Element for TerminalElement { view.set_layout(bounds.origin, cell_width, line_height, bounds.size, scale, cx); }); - window.insert_hitbox(bounds, HitboxBehavior::Normal); + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); let view = self.view.read(cx); - TermLayout { cell_width, line_height, rows: view.grid_rows(), cols: view.grid_cols() } + TermLayout { + cell_width, + line_height, + rows: view.grid_rows(), + cols: view.grid_cols(), + hitbox, + } } fn paint( @@ -178,7 +189,7 @@ impl Element for TerminalElement { let focused = focus_handle.is_focused(window); // 旧壳只让光标本身参与闪烁;ghost、弹窗补齐和 IME 仍复用同一个坐标锚点。 let cursor_visible = self.view.read(cx).cursor_visible(); - let Some((snap, prompt_line, history)) = self.snapshot(layout.rows, layout.cols, cx) else { + let Some((snap, prompt_line, history, dashed)) = self.snapshot(layout.rows, layout.cols, cx) else { return; }; let suggest_anchor = @@ -206,6 +217,7 @@ impl Element for TerminalElement { // 公式覆盖层:与本帧快照同一份网格状态(term 锁内扫描 + 计划), // 探测/fit/几何合同全部由共享的 display::terminal_math 裁定。 let scale_factor = window.scale_factor(); + let math_pixels_per_point = crate::math::pixels_per_point(scale_factor); let math_frame = { let cell_w = layout.cell_width.as_f32(); let line_h = layout.line_height.as_f32(); @@ -216,42 +228,33 @@ impl Element for TerminalElement { (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, + // CommandStart / NEBULA| / hook 都写入 running_program;回合 + // 结束后 hook 身份仍可能留在 ai_session.source(旧壳同事实源)。 + view.math.observe_program( + view.running_program + .as_deref() + .or(view.ai_session.as_ref().map(|session| session.source.as_str())), ); + let size_info = + super::math_overlay::grid_size_info(layout.cols, layout.rows, cell_w, line_h); 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) - }) - }, - ) + view.math.plan_frame(&term, &size_info, foreground, font_px, math_pixels_per_point) }) }; + // 栅格器不可用时不能跳源格:旧壳 draw_math 失败会补画 fallback, + // GPUI 格子已经画过,只能整帧不覆盖,让 `\[` 源码留在屏幕上。 + let math_frame = if cx + .try_global::() + .is_some_and(|assets| assets.can_rasterize()) + { + math_frame + } else { + super::math_overlay::MathFrame::default() + }; let cell_rect = |row: usize, start: usize, count: usize| -> Bounds { Bounds::new( @@ -458,7 +461,8 @@ impl Element for TerminalElement { } else { theme.resolve(cell.fg, &overrides, cell.bold).into() }; - let underline = cell.underline.then(|| UnderlineStyle { + let dashed_link = dashed.contains(&(seg.row, cell.col)); + let underline = (cell.underline && !dashed_link).then(|| UnderlineStyle { thickness: px(1.0), color: Some(fg), wavy: false, @@ -487,6 +491,15 @@ impl Element for TerminalElement { origin, layout.line_height, ); + if dashed_link { + paint_dashed_underline( + window, + origin, + layout.cell_width, + layout.line_height, + fg, + ); + } } } @@ -497,7 +510,7 @@ impl Element for TerminalElement { &math_frame, bounds.origin, (layout.cell_width.as_f32(), layout.line_height.as_f32()), - scale_factor, + math_pixels_per_point, window, cx, ); @@ -648,9 +661,93 @@ impl Element for TerminalElement { let radius = thumb.size.width / 2.0; window.paint_quad(fill(thumb, color).corner_radii(radius)); } + + let (bell_flash, link_hover) = { + let view = self.view.read(cx); + (view.bell_flash, view.link_hover.clone()) + }; + if bell_flash { + let mut flash = theme.foreground; + flash.a = 0.12; + window.paint_quad(fill(bounds, flash)); + } + if let Some(hover) = link_hover { + window.set_cursor_style(CursorStyle::PointingHand, &layout.hitbox); + paint_link_preview( + window, + cx, + &hover.preview, + hover.anchor_row as usize, + hover.anchor_col as usize, + layout, + &bounds, + &font, + font_size, + ); + } } } +fn paint_dashed_underline( + window: &mut Window, + origin: gpui::Point, + cell_width: Pixels, + line_height: Pixels, + color: Hsla, +) { + let y = origin.y + line_height - px(1.0); + let end: f32 = origin.x.as_f32() + cell_width.as_f32(); + let mut x: f32 = origin.x.as_f32(); + let dash: f32 = 3.0; + let gap: f32 = 2.0; + while x < end { + let width = f32::min(dash, end - x); + if width > 0.0 { + window.paint_quad(fill(Bounds::new(point(px(x), y), size(px(width), px(1.0))), color)); + } + x += dash + gap; + } +} + +#[allow(clippy::too_many_arguments)] +fn paint_link_preview( + window: &mut Window, + cx: &mut App, + preview: &str, + anchor_row: usize, + anchor_col: usize, + layout: &TermLayout, + bounds: &Bounds, + font: &gpui::Font, + font_size: Pixels, +) { + let line = if anchor_row + 1 < layout.rows { + anchor_row + 1 + } else { + anchor_row.saturating_sub(1) + }; + let label_size = px(font_size.as_f32() * 0.85); + let run = TextRun { + len: preview.len(), + font: font.clone(), + color: cx.theme().popover_foreground, + background_color: None, + underline: None, + strikethrough: None, + }; + let shaped = window.text_system().shape_line(SharedString::from(preview.to_owned()), label_size, &[run], None); + let pad = px(8.0); + let bubble_w = shaped.width + pad * 2.0; + let bubble_h = layout.line_height * 0.85 + pad; + let max_x = (bounds.origin.x + bounds.size.width - bubble_w).max(bounds.origin.x); + let x = (bounds.origin.x + layout.cell_width * anchor_col as f32).min(max_x); + let y = bounds.origin.y + layout.line_height * line as f32 + (layout.line_height - bubble_h) * 0.5; + let bubble = Bounds::new(point(x, y), size(bubble_w, bubble_h)); + window.paint_quad(fill(bubble, cx.theme().popover).corner_radii(px(6.0))); + window.paint_quad(outline(bubble, cx.theme().border, gpui::BorderStyle::Solid).corner_radii(px(6.0))); + let _ = shaped.paint(point(x + pad, y + (bubble_h - layout.line_height * 0.85) * 0.5), layout.line_height * 0.85, window, cx); +} + /// 唯一的 cell 文本落笔原语:单 cell 文本塑形后从调用方给定的整数 cell /// 原点起笔,不传 `force_width`(首字形天然在 x=0,位置只由列号决定)。 /// 网格、ghost、弹窗全部经由此处——定位合同只此一份,禁止绕开它直接 @@ -821,14 +918,9 @@ fn paint_completion_popup( ) { let columns = layout.cols; let screen_lines = layout.rows; - let Some(popup) = completion_popup_layout( - items, - selected, - cursor_row, - cursor_col, - screen_lines, - columns, - ) else { + let Some(popup) = + completion_popup_layout(items, selected, cursor_row, cursor_col, screen_lines, columns) + else { return; }; let tag = |kind: crate::display::NebulaCompletionKind| -> &'static str { @@ -910,14 +1002,11 @@ fn paint_completion_popup( // 拇指跟随可见窗口的 offset;Tab、hover、滚轮三条路径共享同一个 // 可视位置,不会出现键盘已翻页而滚动条仍停在顶部。 let track_h = layout.line_height * popup.rows as f32 - px(4.0); - let track_top = - bounds.origin.y + layout.line_height * popup.start_line as f32 + px(2.0); + let track_top = bounds.origin.y + layout.line_height * popup.start_line as f32 + px(2.0); let content_right = bounds.origin.x + layout.cell_width * (popup.start_col + popup.width) as f32; - let track_bounds = Bounds::new( - point(content_right + px(1.0), track_top), - size(px(2.0), track_h), - ); + let track_bounds = + Bounds::new(point(content_right + px(1.0), track_top), size(px(2.0), track_h)); window.paint_quad(fill(track_bounds, colors.scroll_track).corner_radii(px(1.0))); let visible_ratio = popup.rows as f32 / items.len() as f32; diff --git a/nebula_app/src/gpui_shell/terminal/keymap.rs b/nebula_app/src/gpui_shell/terminal/keymap.rs index b5a02e2..9958816 100644 --- a/nebula_app/src/gpui_shell/terminal/keymap.rs +++ b/nebula_app/src/gpui_shell/terminal/keymap.rs @@ -87,8 +87,26 @@ fn ctrl_char(c: char) -> Option { /// 一旦子进程要过 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) + mode.contains(TermMode::WIN32_INPUT_MODE) && !mode.intersects(TermMode::KITTY_KEYBOARD_PROTOCOL) +} + +/// 无修饰的字母/数字必须交给 IME / `TranslateMessage`,不能编进 PTY。 +/// +/// GPUI 的 Windows 后端:`on_key_down` 一旦 `stop_propagation`,就不会再 +/// `TranslateMessage`。IME 组字(微软拼音)是 TranslateMessage 喂进去的; +/// 把 `n`/`i` 编成 KEY_EVENT_RECORD 等于把拼音当英文写进 shell,中文永远 +/// 起不来。旧壳对应合同是 `keyboard.rs`:`ime.preedit()` 期间直接 return。 +#[cfg(windows)] +fn win32_encodes_keystroke(ks: &Keystroke) -> bool { + if ks.modifiers.control || ks.modifiers.alt || ks.modifiers.platform { + return true; + } + let key = ks.key.as_str(); + let mut chars = key.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) if c.is_ascii_alphanumeric() => false, + _ => true, + } } /// GPUI 键名 → Win32 虚拟键码。 @@ -227,11 +245,7 @@ fn kitty_escape(ks: &Keystroke, mode: &TermMode) -> Option> { return None; } let param = modifier_param(ks); - Some(if param == 1 { - b"\x1b[27u".to_vec() - } else { - format!("\x1b[27;{param}u").into_bytes() - }) + Some(if param == 1 { b"\x1b[27u".to_vec() } else { format!("\x1b[27;{param}u").into_bytes() }) } /// 返回 `None` 表示这次按键不由编码器处理(交给 IME/文本输入路径)。 @@ -244,7 +258,7 @@ pub fn encode(ks: &Keystroke, mode: &TermMode) -> Option> { // OpenConsole 的翻译层反译成 KEY_EVENT_RECORD,而它会丢掉 uChar=0 的 // VK_ESCAPE——读字节流的应用(Claude Code)因此收不到 Esc。 #[cfg(windows)] - if use_win32_input_mode(mode) { + if use_win32_input_mode(mode) && win32_encodes_keystroke(ks) { if let Some(record) = win32_press_and_release(ks) { return Some(record); } @@ -358,10 +372,8 @@ mod tests { 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 … _"); + 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]); @@ -380,6 +392,22 @@ mod tests { assert_eq!(up[5], "1"); } + /// 无修饰字母必须交给 IME:Win32 模式也不能把拼音编进 PTY,否则 GPUI + /// 当按键已处理、不再 TranslateMessage,中文组字起不来。 + #[cfg(windows)] + #[test] + fn unmodified_letters_stay_on_the_ime_path_in_win32_mode() { + let mode = TermMode::WIN32_INPUT_MODE; + assert_eq!(encode(&keystroke("n"), &mode), None); + assert_eq!(encode(&keystroke("a"), &mode), None); + assert_eq!(encode(&keystroke("1"), &mode), None); + // Ctrl+C 仍走记录,不能为了 IME 把快捷键也放掉。 + let mut ctrl_c = keystroke("c"); + ctrl_c.modifiers.control = true; + assert!(encode(&ctrl_c, &mode).is_some()); + assert!(encode(&keystroke("escape"), &mode).is_some()); + } + /// kitty 键盘标志是子进程明确要过的线上合同,压过 DECSET 9001 /// (口径同旧壳 `use_win32_input_mode` / `kitty_disambiguate_escape_is_csi_27_u`)。 #[test] diff --git a/nebula_app/src/gpui_shell/terminal/math_overlay.rs b/nebula_app/src/gpui_shell/terminal/math_overlay.rs index 6a503f5..120d080 100644 --- a/nebula_app/src/gpui_shell/terminal/math_overlay.rs +++ b/nebula_app/src/gpui_shell/terminal/math_overlay.rs @@ -7,22 +7,25 @@ //! //! 与旧壳 `draw_pane` 相同的门控:alt screen / vi 模式 / 存在选区时整帧 //! 不覆盖(源码可正常选择复制);光标所在逻辑行的排除在 `scan_visible` -//! 内部完成。旧壳按 `RenderableCell` 背景 alpha 过滤围栏代码块,本壳的 -//! 等价数据源是快照的 `bg_runs`(默认背景不产生 run)。 +//! 内部完成。围栏过滤与前景色走同一条 `scan_visible(..., rendered_cells)` +//! 合同:从 term 网格构造等价 cell 列表(`bg_alpha` = 非默认 ANSI 背景或 +//! 反色),不再把空切片丢进去再另用 `bg_runs` 事后过滤。 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::grid::Dimensions as _; use nebula_terminal::index::{Column, Point}; use nebula_terminal::term::TermMode; +use nebula_terminal::term::cell::Flags; +use nebula_terminal::vte::ansi::{Color, NamedColor}; use crate::display::SizeInfo; use crate::display::color::Rgb; -use crate::display::terminal_math::{ - self, CoverageMask, OverlayDrawPlan, TerminalMathState, -}; +use crate::display::content::RenderableCell; +use crate::display::terminal_math::{self, CoverageMask, OverlayDrawPlan, TerminalMathState}; use crate::gpui_shell::math_view; /// 每 pane 一份的覆盖层状态(`TerminalView` 持有)。内部的持久锚点/缓存 @@ -58,50 +61,49 @@ impl MathFrame { impl MathOverlay { /// shell 集成上报的前台程序(AI CLI 名单解锁行内 `$…$`,与旧壳同门)。 + /// + /// 身份先经 `extract_program` + `AgentKind` 归一成 slug(`claude-code` → + /// `claude`),再交给共享的 `is_ai_cli`。CommandStart / `NEBULA|` / hook + /// 三条路只要把同一串交给这里即可。 pub fn observe_program(&mut self, program: Option<&str>) { - self.state.observe_program(program); + let normalized = program.map(normalize_ai_program); + self.state.observe_program(normalized.as_deref()); } /// 每帧的扫描 + 预编 + 几何计划。必须在 term 锁内调用。 /// - /// `size` 以网格左上角为原点(padding = 0);`bg_covered` 回答"该行的 - /// 列区间内是否存在非默认背景"(围栏代码过滤)。 - #[allow(clippy::too_many_arguments)] + /// `size` 以网格左上角为原点(padding = 0),行列必须与可见网格一致, + /// 这样 `TextGrid::from_term` 的 history lookback 仍按视口顶锚定。 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 { + // 旧壳 draw_pane:alt / vi / 选区整帧不覆盖。空选区 `to_range` 为 + // None,不会因为鼠标按下残留的零宽 Selection 把公式关掉。 + if term.mode().intersects(TermMode::ALT_SCREEN | TermMode::VI) + || term.selection.as_ref().and_then(|selection| selection.to_range(term)).is_some() + { 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( + let origin = term.viewport_origin_for(size.screen_lines()); + let cursor = nebula_terminal::term::point_to_viewport_from(origin, term.grid().cursor.point) + .filter(|point| point.line < size.screen_lines() && point.column.0 < size.columns()); + let rendered_cells = scan_cells_from_term(term, size, default_foreground); + let overlays = terminal_math::scan_visible( &mut self.state, term, size, - // 本壳不走 RenderableCell 管线:fallback 重画与围栏过滤各有 - // 等价实现(覆盖失败不跳格 / bg_runs 过滤)。 - &[], + &rendered_cells, 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, @@ -110,6 +112,9 @@ impl MathOverlay { font_pixel_size, pixels_per_point, ); + // 不调用 `update_projection`:GPUI 格子绘制不按 compact 公式左移 + // 后续字形;若这里重建投影,`plan_overlay_draw` 会把公式原点挪到 + // 源码左侧,叠到前一段正文上。 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| { @@ -135,6 +140,99 @@ impl MathOverlay { } } +/// 可见网格的 SizeInfo:padding = 0,行列与元素布局一致。 +/// +/// 半格余量是为了 `SizeInfo::assemble` 里 `(width / cell_width) as usize` +/// 不会把 `8.399994 * 120 / 8.399994 = 119.999` 截成少一列,从而丢掉行尾 +/// 的 `\]` / `\)`。 +pub(crate) fn grid_size_info( + columns: usize, + rows: usize, + cell_width: f32, + cell_height: f32, +) -> SizeInfo { + SizeInfo::new( + cell_width * columns as f32 + cell_width * 0.5, + cell_height * rows as f32 + cell_height * 0.5, + cell_width, + cell_height, + 0.0, + 0.0, + false, + ) +} + +fn normalize_ai_program(program: &str) -> String { + let extracted = + crate::display::extract_program(program).unwrap_or_else(|| program.trim().to_owned()); + crate::ai_agents::AgentKind::parse(&extracted) + .map(|agent| agent.slug().to_owned()) + .unwrap_or(extracted) +} + +/// 旧壳 `RenderableContent` 迭代器的 GPUI 等价物:给 `scan_visible` 填 +/// fallback / `bg_alpha` / 前景。`bg_alpha` 对齐 `compute_bg_alpha`(无 +/// transparent_background_colors):默认 Named 背景为 0,反色或其它 ANSI +/// 背景为 1。空格+默认背景跳过,与旧壳 `is_empty` 一致。 +fn scan_cells_from_term( + term: &Term, + size: &SizeInfo, + default_foreground: Rgb, +) -> Vec { + let grid = term.grid(); + let origin = term.viewport_origin_for(size.screen_lines()); + let columns = size.columns().min(grid.columns()); + let screen_lines = size + .screen_lines() + .min(((grid.bottommost_line() - origin).0.max(0) as usize).saturating_add(1)); + let mut cells = Vec::new(); + for row in 0..screen_lines { + let line = origin + row as i32; + for column in 0..columns { + let cell = &grid[line][Column(column)]; + if cell + .flags + .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) + { + continue; + } + let bg_alpha = if cell.flags.contains(Flags::INVERSE) { + 1.0 + } else if cell.bg == Color::Named(NamedColor::Background) { + 0.0 + } else { + 1.0 + }; + if bg_alpha == 0.0 + && cell.c == ' ' + && !cell.flags.intersects(Flags::ALL_UNDERLINES | Flags::STRIKEOUT) + { + continue; + } + let fg = cell_foreground(cell, default_foreground); + cells.push(RenderableCell { + character: cell.c, + point: Point::new(row, Column(column)), + fg, + bg: Rgb::default(), + bg_alpha, + underline: fg, + flags: cell.flags, + extra: None, + }); + } + } + cells +} + +fn cell_foreground(cell: &nebula_terminal::term::cell::Cell, default: Rgb) -> Rgb { + let color = if cell.flags.contains(Flags::INVERSE) { cell.bg } else { cell.fg }; + match color { + Color::Spec(rgb) => Rgb::new(rgb.r, rgb.g, rgb.b), + _ => default, + } +} + /// 按计划绘制一帧的公式(在格子文本之后调用)。`origin` 是网格左上角的 /// 窗口坐标;`cell` 是 (宽, 高),text ops 的"整字符可见才画"合同用它。 pub fn paint_frame( @@ -220,3 +318,210 @@ pub fn paint_frame( }); } } + +#[cfg(test)] +mod tests { + use nebula_terminal::event::VoidListener; + use nebula_terminal::grid::Dimensions; + use nebula_terminal::index::{Column, Line, Point}; + use nebula_terminal::selection::{Selection, SelectionType}; + use nebula_terminal::term::Config; + use nebula_terminal::vte::ansi::Color; + use nebula_terminal::{Term, index::Side}; + + use super::{MathOverlay, grid_size_info, normalize_ai_program, scan_cells_from_term}; + use crate::display::color::Rgb; + + struct TestSize { + cols: usize, + rows: usize, + } + + impl Dimensions for TestSize { + fn total_lines(&self) -> usize { + self.rows + } + + fn screen_lines(&self) -> usize { + self.rows + } + + fn columns(&self) -> usize { + self.cols + } + } + + fn term_with(cols: usize, rows: usize, lines: &[&str]) -> Term { + let mut term = Term::new(Config::default(), &TestSize { cols, rows }, VoidListener); + for (row, text) in lines.iter().enumerate() { + let mut col = 0usize; + for ch in text.chars() { + if col >= cols { + break; + } + let cell = &mut term.grid_mut()[Line(row as i32)][Column(col)]; + cell.c = ch; + col += 1; + } + } + // 公式行不能与光标逻辑行重合,否则 scan_visible 会整段排除。 + term.grid_mut().cursor.point = Point::new(Line(rows as i32 - 1), Column(0)); + term + } + + fn plan( + overlay: &mut MathOverlay, + term: &Term, + cols: usize, + rows: usize, + ) -> super::MathFrame { + let size = grid_size_info(cols, rows, 8.0, 16.0); + overlay.plan_frame(term, &size, Rgb::new(0xd6, 0xda, 0xea), 16.0, 1.0) + } + + #[test] + fn grid_size_info_does_not_drop_a_column_to_float_truncation() { + let cell_w = 8.399994; + let size = grid_size_info(120, 30, cell_w, 16.8); + assert_eq!(size.columns(), 120); + assert_eq!(size.screen_lines(), 30); + assert_eq!(size.padding_x(), 0.0); + assert_eq!(size.padding_y(), 0.0); + } + + #[test] + fn normalize_ai_program_maps_claude_code_alias_to_is_ai_cli_slug() { + assert_eq!(normalize_ai_program("claude-code"), "claude"); + assert_eq!(normalize_ai_program(r"D:\tools\Claude.EXE"), "claude"); + assert_eq!(normalize_ai_program("codex-cli"), "codex"); + } + + #[test] + fn scan_cells_keep_backslash_and_skip_default_background_spaces() { + let term = term_with(16, 3, &[r"\[x\]", "tail"]); + let size = grid_size_info(16, 3, 8.0, 16.0); + let cells = scan_cells_from_term(&term, &size, Rgb::new(255, 255, 255)); + let row0: String = + cells.iter().filter(|cell| cell.point.line == 0).map(|cell| cell.character).collect(); + assert_eq!(row0, r"\[x\]"); + assert!(cells.iter().all(|cell| cell.bg_alpha == 0.0)); + } + + #[test] + fn bracket_display_scans_without_inline_dollar() { + let mut overlay = MathOverlay::default(); + let term = term_with(16, 4, &[r"\[x\]", "", "prompt"]); + let frame = plan(&mut overlay, &term, 16, 4); + assert!(!frame.is_empty(), "\\[x\\] is display math and must overlay without AI CLI"); + assert!(frame.covers(0, 0)); + assert!(frame.covers(0, 4), "closing ] must be covered"); + assert_eq!(frame.formulas[0].source.as_ref(), "x"); + assert!(frame.formulas[0].plan.display_style, "\\[ must typeset in display style"); + } + + #[test] + fn parenthesized_inline_scans_without_ai_cli() { + let mut overlay = MathOverlay::default(); + let term = term_with(16, 4, &[r"\(x\)", "", "prompt"]); + let frame = plan(&mut overlay, &term, 16, 4); + assert!(!frame.is_empty(), "\\(x\\) is inline and does not need inline_dollar"); + assert!(frame.covers(0, 0)); + assert_eq!(frame.formulas[0].source.as_ref(), "x"); + assert!(!frame.formulas[0].plan.display_style); + } + + #[test] + fn inline_dollar_needs_ai_cli_identity() { + let mut overlay = MathOverlay::default(); + let term = term_with(16, 4, &["$x$", "", "prompt"]); + let frame = plan(&mut overlay, &term, 16, 4); + assert!(frame.is_empty(), "bare $x$ must stay literal without AI CLI"); + + overlay.observe_program(Some("claude-code")); + let frame = plan(&mut overlay, &term, 16, 4); + assert!(!frame.is_empty(), "claude-code must normalize to claude and unlock $"); + assert_eq!(frame.formulas[0].source.as_ref(), "x"); + assert!(!frame.formulas[0].plan.display_style); + } + + #[test] + fn confirmed_display_formula_unlocks_inline_dollar() { + let mut overlay = MathOverlay::default(); + let display = term_with(16, 4, &[r"\[x\]", "", "prompt"]); + let _ = plan(&mut overlay, &display, 16, 4); + + let inline = term_with(16, 4, &["$x$", "", "prompt"]); + let frame = plan(&mut overlay, &inline, 16, 4); + assert!(!frame.is_empty(), "a seen \\[ must unlock inline dollar like the old shell"); + } + + #[test] + fn ansi_background_fence_keeps_formula_source() { + let mut overlay = MathOverlay::default(); + let mut term = term_with(16, 4, &[r"\[x\]", "", "prompt"]); + for col in 0..5 { + term.grid_mut()[Line(0)][Column(col)].bg = Color::Indexed(4); + } + let frame = plan(&mut overlay, &term, 16, 4); + assert!(frame.is_empty(), "fenced ANSI background must not overlay, matching bg_alpha>0"); + } + + #[test] + fn empty_selection_does_not_disable_overlays() { + let mut overlay = MathOverlay::default(); + let mut term = term_with(16, 4, &[r"\[x\]", "", "prompt"]); + term.selection = Some(Selection::new( + SelectionType::Simple, + Point::new(Line(2), Column(0)), + Side::Left, + )); + let frame = plan(&mut overlay, &term, 16, 4); + assert!(!frame.is_empty(), "zero-width leftover selection must not gate the frame"); + } + + #[test] + fn alt_screen_disables_overlays() { + let mut overlay = MathOverlay::default(); + let mut term = term_with(16, 4, &[r"\[x\]", "", "prompt"]); + assert!(!plan(&mut overlay, &term, 16, 4).is_empty()); + term.swap_alt(); + assert!(plan(&mut overlay, &term, 16, 4).is_empty()); + } + + #[test] + fn bracket_display_can_cross_hard_rows() { + let mut overlay = MathOverlay::default(); + let term = term_with(16, 6, &[r"\[", "x", r"\]", "", "prompt"]); + let frame = plan(&mut overlay, &term, 16, 6); + assert!(!frame.is_empty(), "\\[ across hard rows must overlay"); + assert_eq!(frame.formulas[0].source.as_ref(), "x"); + assert!(frame.formulas[0].plan.display_style); + assert!(frame.covers(0, 0)); + assert!(frame.covers(2, 0)); + } + + #[test] + fn markdown_unescaped_bare_bracket_block_overlays() { + // AI CLI 的 markdown 渲染吃掉 `\[` `\]` `\,` 的反斜杠后屏幕上只剩 + // 裸 `[` 块(`\int`/`\frac` 幸存);全链路必须照常出覆盖层。 + let mut overlay = MathOverlay::default(); + let term = + term_with(40, 6, &["[", r"\int_0^1 x^2,dx = \frac{1}{3}", "]", "", "prompt"]); + let frame = plan(&mut overlay, &term, 40, 6); + assert!(!frame.is_empty(), "markdown-unescaped bare bracket block must overlay"); + assert_eq!(frame.formulas[0].source.as_ref(), r"\int_0^1 x^2,dx = \frac{1}{3}"); + assert!(frame.formulas[0].plan.display_style); + assert!(frame.covers(0, 0)); + assert!(frame.covers(2, 0)); + } + + #[test] + fn markdown_unescaped_bare_paren_inline_overlays() { + let mut overlay = MathOverlay::default(); + let term = term_with(40, 4, &[r"also (\sqrt{x^2+y^2}) works", "", "prompt"]); + let frame = plan(&mut overlay, &term, 40, 4); + assert!(!frame.is_empty(), "bare paren with whitelisted TeX command must overlay"); + assert_eq!(frame.formulas[0].source.as_ref(), r"\sqrt{x^2+y^2}"); + assert!(!frame.formulas[0].plan.display_style); + } +} diff --git a/nebula_app/src/gpui_shell/terminal/mod.rs b/nebula_app/src/gpui_shell/terminal/mod.rs index f606d78..9286f5d 100644 --- a/nebula_app/src/gpui_shell/terminal/mod.rs +++ b/nebula_app/src/gpui_shell/terminal/mod.rs @@ -7,11 +7,12 @@ pub mod colors; pub mod element; pub mod keymap; pub mod math_overlay; +mod osc_links; pub mod mouse_protocol; pub mod session; +mod ssh_connect_overlay; pub mod suggest; pub mod view; -mod ssh_connect_overlay; use gpui::{App, KeyBinding}; diff --git a/nebula_app/src/gpui_shell/terminal/osc_links.rs b/nebula_app/src/gpui_shell/terminal/osc_links.rs new file mode 100644 index 0000000..7caa6d8 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/osc_links.rs @@ -0,0 +1,133 @@ +//! GPUI 壳的 OSC 8 / 正则 hint 接线:虚线下划线、悬停预览、Ctrl+点击打开。 +//! +//! 匹配与动作全部复用旧壳 `display::hint` + `file_uri` + `daemon`,这里只做 +//! 视口坐标、GPUI 修饰键和打开入口。 + +use std::collections::HashSet; +use std::sync::Arc; + +use gpui::{App, ClipboardItem}; +use nebula_terminal::event::EventListener; +use nebula_terminal::index::Point; +use nebula_terminal::term::{point_to_viewport_from, Term}; +use unicode_width::UnicodeWidthChar; +use winit::keyboard::ModifiersState; + +use crate::config::UiConfig; +use crate::config::ui_config::{HintAction, HintInternalAction}; +use crate::display::hint::{self, HintMatch}; + +/// 悬停目标:旧壳 `highlighted_hint` + 已经解码好的预览文案。 +#[derive(Clone)] +pub(super) struct LinkHover { + pub hint: HintMatch, + pub preview: String, + pub anchor_row: u16, + pub anchor_col: u16, +} + +pub(super) fn hint_config() -> Arc { + Arc::new(UiConfig::default()) +} + +pub(super) fn winit_mouse_mods(mods: &gpui::Modifiers) -> ModifiersState { + let mut state = ModifiersState::empty(); + if mods.shift { + state |= ModifiersState::SHIFT; + } + if mods.control { + state |= ModifiersState::CONTROL; + } + if mods.alt { + state |= ModifiersState::ALT; + } + if mods.platform { + state |= ModifiersState::SUPER; + } + state +} + +/// 可见可点范围(OSC 8 + 正则 URL)映射到当前视口格子,供虚线下划线使用。 +pub(super) fn dashed_cells( + term: &Term, + config: &UiConfig, + rows: usize, + cols: usize, +) -> HashSet<(u16, u16)> { + let matches = hint::visible_clickable_matches(term, config); + if matches.is_empty() { + return HashSet::new(); + } + let origin = term.viewport_origin_for(rows); + let mut cells = HashSet::new(); + for indexed in term.grid().display_iter() { + if !matches.iter().any(|bounds| bounds.contains(&indexed.point)) { + continue; + } + let Some(vp) = point_to_viewport_from(origin, indexed.point) else { continue }; + if vp.line < rows && vp.column.0 < cols { + cells.insert((vp.line as u16, vp.column.0 as u16)); + } + } + cells +} + +pub(super) fn highlighted_at( + term: &Term, + config: &UiConfig, + point: Point, + mods: &gpui::Modifiers, +) -> Option { + hint::highlighted_at(term, config, point, winit_mouse_mods(mods)) +} + +pub(super) fn hover_from_hint( + term: &Term, + hint: HintMatch, + rows: usize, + cols: usize, +) -> Option { + let uri = hint + .hyperlink() + .map(|link| link.uri().to_owned()) + .or_else(|| hint.text(term).map(|text| text.into_owned()))?; + let origin = term.viewport_origin_for(rows); + let start = *hint.bounds().start(); + let vp = point_to_viewport_from(origin, start).filter(|vp| vp.line < rows && vp.column.0 < cols); + let (anchor_row, anchor_col) = vp + .map(|vp| (vp.line as u16, vp.column.0 as u16)) + .unwrap_or((0, 0)); + const HINT: &str = " · Ctrl+点击"; + let width = |s: &str| -> usize { s.chars().map(|c| c.width().unwrap_or(0)).sum() }; + let target = crate::display::strip_file_scheme(&uri); + let budget = cols.saturating_sub(width(HINT) + 1); + let target = crate::display::fit_tail(&target, budget); + Some(LinkHover { + hint, + preview: format!("{target}{HINT}"), + anchor_row, + anchor_col, + }) +} + +pub(super) fn open_hint(hint: &HintMatch, term: &Term, cx: &App) { + let Some(text) = hint.text(term) else { return }; + #[cfg(windows)] + if let Some(path) = crate::file_uri::file_uri_to_local_path(&text) { + let _ = crate::daemon::spawn_daemon("explorer.exe", &[path.as_os_str()]); + return; + } + match hint.action() { + HintAction::Command(command) => { + let mut args = command.args().to_vec(); + args.push(text.into_owned()); + let _ = crate::daemon::spawn_daemon(command.program(), &args); + }, + HintAction::Action(HintInternalAction::Copy) => { + cx.write_to_clipboard(ClipboardItem::new_string(text.into_owned())); + }, + HintAction::Action(HintInternalAction::Paste | HintInternalAction::Select | HintInternalAction::MoveViModeCursor) => { + // 默认 hint 走 Command(打开 URI)。这三项仍由旧壳键盘 hint 使用。 + }, + } +} 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 b8c7c74..dd1e296 100644 --- a/nebula_app/src/gpui_shell/terminal/ssh_connect_overlay.rs +++ b/nebula_app/src/gpui_shell/terminal/ssh_connect_overlay.rs @@ -9,12 +9,12 @@ //! - 等宽 cell 度量(`SizeInfo`)→ UI 基准字号(同一设置源) use gpui::{ - canvas, div, fill, hsla, point, px, size, AnyElement, Bounds, Context, Hsla, - InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, SharedString, - StatefulInteractiveElement as _, Styled as _, + AnyElement, Bounds, Context, Hsla, InteractiveElement as _, IntoElement, MouseButton, + ParentElement as _, SharedString, StatefulInteractiveElement as _, Styled as _, canvas, div, + fill, hsla, point, px, size, }; -use crate::display::ssh_connect::{self, stage_labels, stage_message, SshConnectState}; +use crate::display::ssh_connect::{self, SshConnectState, stage_labels, stage_message}; use crate::gpui_shell::prelude::*; /// UiLanguage 由设置折算(两壳同源)。 diff --git a/nebula_app/src/gpui_shell/terminal/suggest.rs b/nebula_app/src/gpui_shell/terminal/suggest.rs index 672a0ff..c730d31 100644 --- a/nebula_app/src/gpui_shell/terminal/suggest.rs +++ b/nebula_app/src/gpui_shell/terminal/suggest.rs @@ -70,8 +70,7 @@ pub fn commit_line(state: &mut NebulaPaneState) { // 旧壳 `nebula_commit_line` 同一条:OSC 133;C 到达时 PTY 已把行缓冲清 // 空,程序身份(侧栏 tab 图标)必须在 Enter 这一刻从屏幕真值捕获。 // grid 读失败时退回按键镜像——取首 token 做身份已足够。 - state.last_committed = - if line.is_empty() { state.line_buf.trim().to_owned() } else { line }; + state.last_committed = if line.is_empty() { state.line_buf.trim().to_owned() } else { line }; crate::display::Display::nebula_clear_line(state); } diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index 018fc22..ecf9469 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -4,8 +4,8 @@ use gpui::{ App, Bounds, ClipboardItem, Context, EventEmitter, FocusHandle, Focusable, Font, FontFeatures, FontStyle, FontWeight, Hsla, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render, - ScrollWheelEvent, SharedString, Size, Styled as _, TextRun, UTF16Selection, Window, div, point, - px, + ScrollWheelEvent, SharedString, Size, StatefulInteractiveElement as _, Styled as _, TextRun, + UTF16Selection, Window, div, point, px, }; use gpui_component::{PixelsExt as _, WindowExt as _}; use nebula_terminal::event::{Event as TermEvent, Notify as _, OnResize as _, WindowSize}; @@ -28,6 +28,7 @@ use super::suggest; use super::{KEY_CONTEXT, TerminalBackTab, TerminalTab}; use crate::gpui_shell::config::Settings; use crate::gpui_shell::prelude::{DialogButtonProps, center_confirm_dialog}; +use crate::config::UiConfig; use futures::StreamExt as _; @@ -94,6 +95,10 @@ pub enum TerminalViewEvent { FocusRequested, /// 连接卡片的取消/关闭(旧壳 TabRequest::Close 的对应物)。 RequestClose, + /// Ctrl+滚轮改了终端字号:宿主应对所有 pane 热应用并写盘。 + FontSizeChanged, + /// BEL(`^G`)。后台 tab 记铃点;本 tab 的闪烁/声音由视图自己处理。 + Bell, } /// 会话种类:本地 shell 或 SSH 直连(russh,共享旧壳业务层)。 @@ -108,7 +113,19 @@ pub enum TerminalLaunch { /// 只用于欢迎屏选择 PowerShell/Bash 形态,不参与 PTY 启动。 shell_name: Option, }, - Ssh { destination: String }, + Ssh { + destination: String, + }, +} + +pub(crate) fn last_path_component(path: &str) -> Option { + let name = path + .trim() + .trim_end_matches(['/', '\\']) + .rsplit(['/', '\\']) + .next() + .filter(|s| !s.is_empty())?; + Some(name.to_owned()) } /// 侧边栏只消费稳定的枚举态,不直接窥探终端/SSH 的内部错误字段。 @@ -209,6 +226,10 @@ pub struct TerminalView { resize_epoch: u64, scroll_px: f32, selecting: bool, + /// OSC 8 / 正则 URL:虚线下划线、悬停预览、Ctrl+点击打开。 + pub(super) hint_config: Arc, + pub(super) link_hover: Option, + pending_link_open: bool, /// 选中即复制(旧壳 `copy_on_select`);关闭时复制交给右键路径。 copy_on_select: bool, /// 鼠标模式下最后上报的单元格:move 事件按"进入新单元格"去重。 @@ -231,6 +252,13 @@ pub struct TerminalView { ghost_enabled: bool, accept: crate::display::AcceptKey, completion_style: crate::display::CompletionStyle, + /// BEL 后暂停侧栏转圈,直到用户再往 PTY 打字(旧壳 `awaiting_input`)。 + awaiting_input: bool, + /// BEL 视觉闪烁:整 pane 盖一层前景 12%,约 150ms。 + pub(super) bell_flash: bool, + bell_flash_epoch: u64, + /// 失焦时再递系统 toast / 驻留横幅(render 里才有 Window)。 + pending_bell_notify: Option<(String, String)>, } impl TerminalView { @@ -324,22 +352,14 @@ impl TerminalView { /// 即触发一次 ConPTY resize(DA 探询与回显竞态的温床)。 pub fn cell_metrics(window: &Window, cx: &App) -> (Pixels, Pixels) { let (family, font_size, mode, offset_x, offset_y) = match cx.try_global::() { - Some(settings) => { - ( - settings.font_family.as_str(), - settings.font_size_px, - settings.cell_width_mode, - settings.font_offset_x, - settings.font_offset_y, - ) - }, - None => ( - "Cascadia Mono", - 15.0, - nebula_settings::CellWidthModeName::Compact, - 0.0, - 0.0, + Some(settings) => ( + settings.font_family.as_str(), + settings.font_size_px, + settings.cell_width_mode, + settings.font_offset_x, + settings.font_offset_y, ), + None => ("Cascadia Mono", 15.0, nebula_settings::CellWidthModeName::Compact, 0.0, 0.0), }; Self::measure_cell_metrics(window, family, px(font_size), mode, offset_x, offset_y) } @@ -355,13 +375,7 @@ impl TerminalView { settings.font_offset_x, settings.font_offset_y, ), - None => ( - "Cascadia Mono", - 15.0, - nebula_settings::CellWidthModeName::Compact, - 0.0, - 0.0, - ), + None => ("Cascadia Mono", 15.0, nebula_settings::CellWidthModeName::Compact, 0.0, 0.0), }; Self::measure_cell_metrics(window, family, px(font_size), mode, offset_x, offset_y) } @@ -388,42 +402,42 @@ impl TerminalView { copy_on_select, shell, ) = match cx.try_global::() { - Some(settings) => ( - [ - settings.font_family.clone(), - settings.font_bold_family.clone(), - settings.font_italic_family.clone(), - settings.font_bold_italic_family.clone(), - ], - px(settings.font_size_px), - settings.cell_width_mode, - settings.font_offset_x, - settings.font_offset_y, - Arc::new(settings.palette.clone()), - settings.term_config(), - settings.copy_on_select, - // 设置选定的默认 shell(旧壳 default_shell_launch 同径: - // resolve 失败或 PTY 集成 id 落回引擎默认)。WSL id 在 - // 这里换上 bash 集成注入,cwd/git 分支经 NEBULA| 标题回流。 - settings - .shell_id - .as_deref() - .and_then(crate::shell_detect::resolve_id) - .map(|detected| detected.shell()), - ), - None => ( - std::array::from_fn(|_| String::from("Cascadia Mono")), - px(15.0), - nebula_settings::CellWidthModeName::Compact, - 0.0, - 0.0, - Arc::new(Palette::default()), - nebula_terminal::term::Config::default(), - // 旧壳的出厂默认即开。 - true, - None, - ), - }; + Some(settings) => ( + [ + settings.font_family.clone(), + settings.font_bold_family.clone(), + settings.font_italic_family.clone(), + settings.font_bold_italic_family.clone(), + ], + px(settings.font_size_px), + settings.cell_width_mode, + settings.font_offset_x, + settings.font_offset_y, + Arc::new(settings.palette.clone()), + settings.term_config(), + settings.copy_on_select, + // 设置选定的默认 shell(旧壳 default_shell_launch 同径: + // resolve 失败或 PTY 集成 id 落回引擎默认)。WSL id 在 + // 这里换上 bash 集成注入,cwd/git 分支经 NEBULA| 标题回流。 + settings + .shell_id + .as_deref() + .and_then(crate::shell_detect::resolve_id) + .map(|detected| detected.shell()), + ), + None => ( + std::array::from_fn(|_| String::from("Cascadia Mono")), + px(15.0), + nebula_settings::CellWidthModeName::Compact, + 0.0, + 0.0, + Arc::new(Palette::default()), + nebula_terminal::term::Config::default(), + // 旧壳的出厂默认即开。 + true, + None, + ), + }; let default_cursor_style = term_config.default_cursor_style; let (cell_w, line_h) = Self::cell_metrics(window, cx); // 像素口径与 viewport 上报一致(设备 px),避免首帧一次像素级差异。 @@ -434,6 +448,13 @@ impl TerminalView { cell_width: (cell_w.as_f32() * scale).round().max(1.0) as u16, cell_height: (line_h.as_f32() * scale).round().max(1.0) as u16, }; + let initial_cwd = match &launch { + TerminalLaunch::Local { cwd, .. } => cwd + .as_ref() + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_default(), + TerminalLaunch::Ssh { destination } => destination.clone(), + }; let (ssh_destination, initial_title, intro_shell_name, spawned) = match launch { TerminalLaunch::Local { cwd, shell: launch_shell, shell_name } => ( None, @@ -589,7 +610,7 @@ impl TerminalView { marked_text: None, ime_bounds: Bounds::default(), title: initial_title, - cwd: String::new(), + cwd: initial_cwd, branch: String::new(), running_program: None, command_running: false, @@ -618,6 +639,9 @@ impl TerminalView { resize_epoch: 0, scroll_px: 0.0, selecting: false, + hint_config: super::osc_links::hint_config(), + link_hover: None, + pending_link_open: false, copy_on_select, last_report_point: None, cursor_visible: true, @@ -628,6 +652,10 @@ impl TerminalView { ghost_enabled, accept, completion_style, + awaiting_input: false, + bell_flash: false, + bell_flash_epoch: 0, + pending_bell_notify: None, }; view.restart_cursor_blink(cx); view @@ -655,8 +683,10 @@ impl TerminalView { self.suggest.cwd = self.cwd.clone(); super::suggest::record_directory(&self.cwd); } + // 协议串不是窗口标题,更不是 tab 名。 + } else { + self.title = title; } - self.title = title; cx.emit(TerminalViewEvent::TitleChanged); cx.notify(); }, @@ -705,6 +735,10 @@ impl TerminalView { self.cwd = cwd; self.suggest.cwd = self.cwd.clone(); super::suggest::record_directory(&self.cwd); + // GPUI 没有旧壳每次 chrome 刷新都 `sync_chrome_tabs()` 的 + // 循环;侧栏标题画在 workspace 里,cwd 变了必须发 + // TitleChanged,`on_terminal_event` 才会 `cx.notify()` 重绘。 + cx.emit(TerminalViewEvent::TitleChanged); cx.notify(); } }, @@ -713,8 +747,8 @@ impl TerminalView { // 时捕获的命令行提取首 token,AgentKind 认得的归一成 slug。 // 这是侧栏 AI 图标对「直接敲 codex/grok 而没装 hook」的 // 会话也点亮的那条路——hook 信封只是更准的覆盖层。 - let identity = crate::display::extract_program(&self.suggest.last_committed) - .map(|program| { + let identity = + crate::display::extract_program(&self.suggest.last_committed).map(|program| { crate::ai_agents::AgentKind::parse(&program) .map(|agent| agent.slug().to_owned()) .unwrap_or(program) @@ -763,8 +797,9 @@ impl TerminalView { self.handle_ai_hook(&hook, cx); } }, + TermEvent::Bell => self.on_bell(cx), // 仍未接线:内联图片(要图片管线把 abs_line 锚到网格)、OSC 1337 - // UserVar(AI 查询拦截)、铃声。 + // UserVar(AI 查询拦截)。 _ => {}, } } @@ -794,9 +829,11 @@ impl TerminalView { return None; } let executable = crate::process_tree::busy_child(shell_pid)?; - Some(self.running_program.clone().unwrap_or_else(|| { - crate::process_tree::display_name(&executable) - })) + Some( + self.running_program + .clone() + .unwrap_or_else(|| crate::process_tree::display_name(&executable)), + ) } /// Apply one lifecycle event already routed to this pane by the workspace. @@ -959,14 +996,22 @@ impl TerminalView { crate::ai_agents::AgentKind::parse(&identity.source)?.fork_command(&identity.session_id) } + /// 冷恢复把快照里的 hook 身份种回 pane,右键分叉不必再等下一条事件。 + pub fn seed_ai_session(&mut self, source: String, session_id: String, cx: &mut Context) { + if session_id.is_empty() { + return; + } + self.ai_session = + Some(crate::display::AiSessionIdentity { source, session_id }); + cx.emit(TerminalViewEvent::TitleChanged); + cx.notify(); + } + pub fn sidebar_activity(&self) -> SidebarActivity { use crate::ai_agents::AgentStatus; if self.error.is_some() || self.exited.is_some() - || matches!( - self.ssh_stage.as_ref(), - Some(crate::ssh_session::SshStage::Failed(_)) - ) + || matches!(self.ssh_stage.as_ref(), Some(crate::ssh_session::SshStage::Failed(_))) { return SidebarActivity::Failed; } @@ -980,10 +1025,11 @@ impl TerminalView { 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) - }) + || (self.running_program.is_some() && !self.awaiting_input) + || self + .ssh_stage + .as_ref() + .is_some_and(|stage| !matches!(stage, crate::ssh_session::SshStage::Ready)) { SidebarActivity::Running } else { @@ -1010,6 +1056,7 @@ impl TerminalView { /// 输入后回到底部并请求重绘。 fn write_input(&mut self, bytes: Vec, cx: &mut Context) { + self.awaiting_input = false; if let Some(session) = &self.session { { let mut term = session.term.lock(); @@ -1064,9 +1111,53 @@ impl TerminalView { self.cursor_visible } + fn on_bell(&mut self, cx: &mut Context) { + let mode = nebula_settings::RuntimeSettings::load().bell; + if mode == nebula_settings::BellModeName::None { + return; + } + let audible_ok = if mode.audible() { Self::ring_audible() } else { false }; + if mode.visual() || (mode.audible() && !audible_ok) { + self.flash_bell(cx); + } + if self.running_program.is_some() { + self.awaiting_input = true; + } + let program = self.running_program.clone(); + self.pending_bell_notify = Some(match program { + Some(name) => (name, "任务完成,等待输入".to_owned()), + None => ("Nebula".to_owned(), "终端响铃".to_owned()), + }); + cx.emit(TerminalViewEvent::Bell); + cx.notify(); + } + + fn ring_audible() -> bool { + crate::platform::beep(); + cfg!(windows) + } + + fn flash_bell(&mut self, cx: &mut Context) { + self.bell_flash = true; + self.bell_flash_epoch = self.bell_flash_epoch.wrapping_add(1); + let epoch = self.bell_flash_epoch; + cx.notify(); + let executor = cx.background_executor().clone(); + cx.spawn(async move |this, cx| { + executor.timer(std::time::Duration::from_millis(150)).await; + let _ = this.update(cx, |view, cx| { + if view.bell_flash_epoch == epoch { + view.bell_flash = false; + cx.notify(); + } + }); + }) + .detach(); + } + /// 元素 prepaint 回写布局:内容矩形与度量交给渲染合同裁定网格。 /// 网格变化时同步 Term 与 ConPTY;行列不变但像素口径变化也上报 PTY - /// (Ghostty 规则);稳态帧 observe 返回 None,零额外开销。 + /// (应用可能关心像素度量);稳态帧 observe 返回 None,零额外开销。 pub fn set_layout( &mut self, origin: Point, @@ -1201,6 +1292,26 @@ impl TerminalView { .detach(); } + /// 旧壳 `change_font_size`:一步 1 逻辑 px,钳 4–64。写盘后通知宿主 + /// 给所有 pane 热应用,chrome 仍锚定 toml 字号。 + fn zoom_font_size(&mut self, step: f32, cx: &mut Context) { + let current = + cx.try_global::().map(|settings| settings.font_size_px).unwrap_or(15.0); + let next = (current.round() + step).clamp(4.0, 64.0); + if (next - current).abs() < f32::EPSILON { + return; + } + if let Err(err) = nebula_settings::persist_keys(&[("font_size", format!("{next:.2}"))]) { + eprintln!("[nebula:gpui] failed to persist font size: {err}"); + return; + } + let theme = crate::gpui_shell::theme::effective_theme_name(cx); + cx.set_global(Settings::load(theme)); + self.apply_settings(cx); + cx.emit(TerminalViewEvent::FontSizeChanged); + cx.notify(); + } + /// 热应用运行时设置(设置页改动后由宿主调用)。默认光标样式只更新 /// `Term` 的 fallback;程序通过 DECSCUSR 设置的临时样式仍保持权威。 pub fn apply_settings(&mut self, cx: &mut Context) { @@ -1246,29 +1357,17 @@ impl TerminalView { cx.notify(); } - /// 侧栏 tab 标签(旧壳 `chrome_tab_label` 的移植):cwd 的末级目录名 - /// →(非 "shell" 的)标题 → 进程当前目录名 → "."。整条路径永远不显示, - /// 它会把侧栏行顶满、毁掉呼吸感。 + /// 侧栏 tab 标签:旧壳 `chrome_tab_label` 只认**路径末级名**。 + /// OSC 标题(脚本名、`NEBULA|…` 整串)只属于窗口标题,绝不能当标签, + /// 否则跑脚本时侧栏会变成 `foo.ps1`,cwd 上报失败时还会拼出 + /// `.tmp-stay-launch.tmp-stay-launch` 这种重复段。 pub fn tab_label(&self) -> String { - let cwd = self.cwd.trim(); - if !cwd.is_empty() { - let name = cwd - .trim_end_matches(['/', '\\']) - .rsplit(['/', '\\']) - .next() - .filter(|s| !s.is_empty()) - .unwrap_or(cwd); - return name.to_owned(); - } - - if self.title != "shell" && !self.title.trim().is_empty() { - return self.title.clone(); - } - - std::env::current_dir() - .ok() - .and_then(|path| path.file_name().map(|n| n.to_string_lossy().into_owned())) - .filter(|name| !name.trim().is_empty()) + last_path_component(&self.cwd) + .or_else(|| { + std::env::current_dir() + .ok() + .and_then(|path| last_path_component(&path.to_string_lossy())) + }) .unwrap_or_else(|| ".".to_owned()) } @@ -1296,7 +1395,7 @@ impl TerminalView { /// N 行」的确认 toast——键盘与右键共用这一条通知路径;copy_on_select /// 的选区存储刻意静默(旧壳 `copy_selection` 同合同,抬手一次就可能 /// 触发,弹起来会刷屏)。 - fn copy_selection(&mut self, notify: bool, window: &mut Window, cx: &mut Context) { + pub fn copy_selection(&mut self, notify: bool, window: &mut Window, cx: &mut Context) { if let Some(session) = &self.session { let text = session.term.lock().selection_to_string(); if let Some(text) = text.filter(|t| !t.is_empty()) { @@ -1322,7 +1421,7 @@ impl TerminalView { /// 读剪贴板并粘贴。体量达到 [`PASTE_CONFIRM_LINES`] 时先弹确认模态,确认 /// 后才走 [`Self::paste_now`];小段粘贴一路直通,不打断手感。 - fn paste(&mut self, window: &mut Window, cx: &mut Context) { + pub fn paste(&mut self, window: &mut Window, cx: &mut Context) { let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else { return }; let lines = paste_line_count(&text); if lines >= PASTE_CONFIRM_LINES { @@ -1480,6 +1579,12 @@ impl TerminalView { if self.exited.is_some() { return; } + // 旧壳 `keyboard.rs`:IME 组合中不编码、不拦截。GPUI Windows 在 + // `stop_propagation` 后会跳过 `TranslateMessage`,组合中若把按键 + // 吃掉,候选窗和退格都会坏。 + if self.marked_text.is_some() { + return; + } let ks = &event.keystroke; let mods = &ks.modifiers; @@ -1498,7 +1603,7 @@ impl TerminalView { }, // 应用级快捷键(新建/关闭 Tab、折叠侧栏、分屏、缩放): // 不编码、不拦截,让按键冒泡到 workspace 的 action 绑定。 - "t" | "w" | "b" | "p" | "f" | "d" | "s" | "enter" => return, + "t" | "w" | "b" | "p" | "f" | "d" | "s" | "g" | "o" | "enter" => return, _ => {}, } } @@ -1507,6 +1612,10 @@ impl TerminalView { if mods.control && mods.alt && matches!(ks.key.as_str(), "left" | "right" | "up" | "down") { return; } + // 工作区切 tab(ctrl+tab / ctrl+shift+tab):不编码进 PTY。 + if mods.control && !mods.alt && ks.key.as_str() == "tab" { + return; + } let mode = self.term_mode(); @@ -1616,8 +1725,7 @@ impl TerminalView { ); // key_char 非空 = 平台判定它产生文本(随后从 IME 管道 // 到达并在那里镜像),不能在这里预清。 - let produces_text = - ks.key_char.as_deref().is_some_and(|text| !text.is_empty()); + let produces_text = ks.key_char.as_deref().is_some_and(|text| !text.is_empty()); if !is_modifier && !produces_text { crate::display::Display::nebula_clear_line(&mut self.suggest); } @@ -1784,6 +1892,59 @@ impl TerminalView { (point, side) } + fn selection_is_empty(&self) -> bool { + self.session.as_ref().is_none_or(|session| { + session.term.lock().selection.as_ref().is_none_or(|selection| selection.is_empty()) + }) + } + + fn update_link_hover( + &mut self, + position: Point, + mods: &gpui::Modifiers, + cx: &mut Context, + ) { + let next = if self.selecting { + None + } else { + self.session.as_ref().and_then(|session| { + let (point, _) = self.grid_point(position); + let term = session.term.lock(); + super::osc_links::highlighted_at(&term, &self.hint_config, point, mods) + .and_then(|hint| { + super::osc_links::hover_from_hint(&term, hint, self.rows, self.cols) + }) + }) + }; + let changed = match (&self.link_hover, &next) { + (None, None) => false, + (Some(prev), Some(new)) => { + prev.preview != new.preview + || prev.anchor_row != new.anchor_row + || prev.anchor_col != new.anchor_col + || prev.hint != new.hint + }, + _ => true, + }; + if changed { + self.link_hover = next; + cx.notify(); + } + } + + fn clear_link_hover(&mut self, cx: &mut Context) { + if self.link_hover.take().is_some() { + cx.notify(); + } + } + + fn try_open_hovered_link(&self, cx: &Context) { + let Some(hover) = self.link_hover.as_ref() else { return }; + let Some(session) = self.session.as_ref() else { return }; + let term = session.term.lock(); + super::osc_links::open_hint(&hover.hint, &term, cx); + } + /// 应用是否接管了鼠标(vim/htop 等)。Shift 按住时强制旁路——这是 /// 终端的通用逃生门:应用吃鼠标时用户仍能选择/复制。 fn mouse_mode_active(&self, mods: &gpui::Modifiers) -> bool { @@ -1846,7 +2007,8 @@ impl TerminalView { if len <= popup.rows { return None; } - let content_right = self.origin.x + self.cell_width * (popup.start_col + popup.width) as f32; + let content_right = + self.origin.x + self.cell_width * (popup.start_col + popup.width) as f32; let top = self.origin.y + self.line_height * popup.start_line as f32; let height = self.line_height * popup.rows as f32; if position.x < content_right @@ -1856,8 +2018,7 @@ impl TerminalView { { return None; } - let progress = - ((position.y - top).as_f32() / height.as_f32().max(1.0)).clamp(0.0, 1.0); + let progress = ((position.y - top).as_f32() / height.as_f32().max(1.0)).clamp(0.0, 1.0); Some((progress * (len - 1) as f32).round() as usize) } @@ -1935,6 +2096,23 @@ impl TerminalView { ); return; } + if event.modifiers.control && event.click_count == 1 { + let (point, _) = self.grid_point(event.position); + let hit = self.session.as_ref().is_some_and(|session| { + let term = session.term.lock(); + super::osc_links::highlighted_at(&term, &self.hint_config, point, &event.modifiers) + .is_some() + }); + if hit { + if let Some(session) = &self.session { + session.term.lock().selection = None; + } + self.selecting = false; + self.pending_link_open = true; + self.update_link_hover(event.position, &event.modifiers, cx); + return; + } + } let (point, side) = self.grid_point(event.position); let ty = match event.click_count { 1 => SelectionType::Simple, @@ -1999,33 +2177,39 @@ impl TerminalView { cx.notify(); return; } - if !self.mouse_mode_active(&event.modifiers) { + if self.mouse_mode_active(&event.modifiers) { + self.clear_link_hover(cx); + // 鼠标模式的移动上报:拖动 = 按钮码+32(需 DRAG 或 MOTION 任一), + // 无按键纯移动 = 35(仅 MOTION);同一单元格内的移动不重报。 + let mode = self.term_mode(); + if !mode.intersects(TermMode::MOUSE_DRAG | TermMode::MOUSE_MOTION) { + return; + } + let button = match event.pressed_button { + Some(MouseButton::Left) => { + mouse_protocol::BUTTON_LEFT + mouse_protocol::DRAG_OFFSET + }, + Some(MouseButton::Middle) => { + mouse_protocol::BUTTON_MIDDLE + mouse_protocol::DRAG_OFFSET + }, + Some(MouseButton::Right) => { + mouse_protocol::BUTTON_RIGHT + mouse_protocol::DRAG_OFFSET + }, + _ => { + if !mode.contains(TermMode::MOUSE_MOTION) { + return; + } + mouse_protocol::MOTION_ONLY + }, + }; + let (point, _) = self.grid_point(event.position); + if self.last_report_point == Some(point) { + return; + } + self.send_mouse_report(event.position, button, true, &event.modifiers); return; } - // 鼠标模式的移动上报:拖动 = 按钮码+32(需 DRAG 或 MOTION 任一), - // 无按键纯移动 = 35(仅 MOTION);同一单元格内的移动不重报。 - let mode = self.term_mode(); - if !mode.intersects(TermMode::MOUSE_DRAG | TermMode::MOUSE_MOTION) { - return; - } - let button = match event.pressed_button { - Some(MouseButton::Left) => mouse_protocol::BUTTON_LEFT + mouse_protocol::DRAG_OFFSET, - Some(MouseButton::Middle) => { - mouse_protocol::BUTTON_MIDDLE + mouse_protocol::DRAG_OFFSET - }, - Some(MouseButton::Right) => mouse_protocol::BUTTON_RIGHT + mouse_protocol::DRAG_OFFSET, - _ => { - if !mode.contains(TermMode::MOUSE_MOTION) { - return; - } - mouse_protocol::MOTION_ONLY - }, - }; - let (point, _) = self.grid_point(event.position); - if self.last_report_point == Some(point) { - return; - } - self.send_mouse_report(event.position, button, true, &event.modifiers); + self.update_link_hover(event.position, &event.modifiers, cx); } fn on_mouse_up(&mut self, event: &MouseUpEvent, window: &mut Window, cx: &mut Context) { @@ -2043,12 +2227,15 @@ impl TerminalView { return; } self.selecting = false; - // 选中即复制:在抬手时一次性入剪贴板(旧壳同样在 release 复制, - // 避免拖动过程刷爆剪贴板)。空选区在 copy_selection 内自然短路; - // 选区存储静默——显式复制才有确认 toast。 - if self.copy_on_select { + let open_link = (self.pending_link_open || event.modifiers.control) + && self.selection_is_empty() + && self.link_hover.is_some(); + if open_link { + self.try_open_hovered_link(cx); + } else if self.copy_on_select { self.copy_selection(false, window, cx); } + self.pending_link_open = false; cx.notify(); } @@ -2151,6 +2338,15 @@ impl TerminalView { cx: &mut Context, ) { let delta_y = event.delta.pixel_delta(self.line_height).y.as_f32(); + // 旧壳 `mouse_wheel_input`:Ctrl+滚轮先于一切滚动消费者,一步 1 + // 逻辑像素,钳在 4–64。设置页步进会写盘;这里同样写 `font_size=`, + // 让下次启动跟上(旧壳只在其它设置落盘时顺便带走当前字号)。 + if event.modifiers.control && !event.modifiers.alt && delta_y != 0.0 { + let step = if delta_y > 0.0 { 1.0 } else { -1.0 }; + self.zoom_font_size(step, cx); + cx.stop_propagation(); + return; + } if suggest::popup_active(&self.suggest) && (self.completion_popup_hit(event.position).is_some() || self.completion_popup_scrollbar_target(event.position).is_some()) @@ -2330,6 +2526,20 @@ impl Render for TerminalView { }); } } + if let Some((title, body)) = self.pending_bell_notify.take() { + if !window.is_window_active() { + #[cfg(windows)] + crate::notify::toast(&title, &body); + window.defer(cx, move |window, cx| { + crate::gpui_shell::toast::banner( + window, + cx, + crate::display::ToastKind::Info, + format!("{title} · {body}"), + ); + }); + } + } let mut root = div() .id("nebula-terminal") .key_context(KEY_CONTEXT) @@ -2358,7 +2568,12 @@ impl Render for TerminalView { .on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up)) .on_mouse_up(MouseButton::Right, cx.listener(Self::on_right_up)) .on_mouse_up(MouseButton::Middle, cx.listener(Self::on_middle_up)) - .on_scroll_wheel(cx.listener(Self::on_scroll)); + .on_scroll_wheel(cx.listener(Self::on_scroll)) + .on_hover(cx.listener(|this, hovered: &bool, _, cx| { + if !*hovered { + this.clear_link_hover(cx); + } + })); if let Some(error) = &self.error { root = root.child(div().p_4().text_color(gpui::red()).child(error.clone())); diff --git a/nebula_app/src/gpui_shell/theme.rs b/nebula_app/src/gpui_shell/theme.rs index bae921a..082170a 100644 --- a/nebula_app/src/gpui_shell/theme.rs +++ b/nebula_app/src/gpui_shell/theme.rs @@ -1,6 +1,4 @@ -use gpui::{ - App, Bounds, Hsla, Pixels, Rgba as GpuiRgba, Window, fill, hsla, point, px, size, -}; +use gpui::{App, Bounds, Hsla, Pixels, Rgba as GpuiRgba, Window, fill, hsla, point, px, size}; use gpui_component::{ActiveTheme as _, Theme, ThemeMode}; use crate::display::color::Rgb; @@ -113,11 +111,8 @@ pub(crate) fn sidebar_spinner_colors(cx: &App, active: bool) -> (GpuiRgba, GpuiR let palette = chrome.palette(); let sk = chrome.skin(); let shell = Rgba::new(palette.shell_bg.r, palette.shell_bg.g, palette.shell_bg.b, 255); - let base = if active { - crate::display::ui::surface::over(sk.accent_soft, shell) - } else { - shell - }; + let base = + if active { crate::display::ui::surface::over(sk.accent_soft, shell) } else { shell }; let track = crate::display::ui::surface::over(sk.hairline, base); let head = crate::display::ui::surface::over( Rgba::new(sk.ink_dim.r, sk.ink_dim.g, sk.ink_dim.b, 255), @@ -205,7 +200,7 @@ pub fn card_content_bg(cx: &App) -> Hsla { .try_global::() .map(|s| s.palette.background.into()) .unwrap_or_else(|| cx.theme().background); - bg.a *= crate::gpui_shell::wallpaper::window_opacity(cx); + bg.a *= crate::gpui_shell::wallpaper::chrome_surface_opacity(cx); bg } @@ -231,10 +226,7 @@ pub fn paint_shell_around_card(bounds: Bounds, window: &mut Window, cx: let card_h = (height - inset * 2.0).max(0.0); let paint = |window: &mut Window, x: f32, y: f32, w: f32, h: f32| { if w > 0.0 && h > 0.0 { - window.paint_quad(fill( - Bounds::new(point(px(x), px(y)), size(px(w), px(h))), - color, - )); + window.paint_quad(fill(Bounds::new(point(px(x), px(y)), size(px(w), px(h))), color)); } }; @@ -259,18 +251,11 @@ pub fn paint_shell_around_card(bounds: Bounds, window: &mut Window, cx: let row_h = step.min(radius - row_top).max(0.0); let sample_y = (row_top + row_h * 0.5).min(radius); let dy = radius - sample_y; - let outside = - (radius - (radius * radius - dy * dy).max(0.0).sqrt()).clamp(0.0, radius); + let outside = (radius - (radius * radius - dy * dy).max(0.0).sqrt()).clamp(0.0, radius); paint(window, card_x, card_y + row_top, outside, row_h); paint(window, card_right - outside, card_y + row_top, outside, row_h); paint(window, card_x, card_bottom - row_top - row_h, outside, row_h); - paint( - window, - card_right - outside, - card_bottom - row_top - row_h, - outside, - row_h, - ); + paint(window, card_right - outside, card_bottom - row_top - row_h, outside, row_h); } } @@ -340,6 +325,8 @@ pub fn apply_chrome_theme(cx: &mut App) { // 视效(模糊/透明度/壁纸)与主题同一时机刷新:设置热应用、系统外观 // 变化都会走到这里,窗口级效果与 token 保持同帧一致。 crate::gpui_shell::wallpaper::refresh(cx); + // 托盘与 chrome 同一热应用节拍:启动 / 设置变更 / 系统外观。 + crate::gpui_shell::apply_tray_setting(); let chrome = chrome_theme(effective_theme_name(cx)); let mode = if chrome.skin().is_light { ThemeMode::Light } else { ThemeMode::Dark }; @@ -349,7 +336,7 @@ pub fn apply_chrome_theme(cx: &mut App) { // 一体化外壳(对齐旧壳 draw_chrome):窗口背景、侧栏、顶栏是同一块 // 壳色,各自的分隔线取同色隐形;唯一的结构分界是内容区那张圆角卡。 // 壳色带用户透明度(文字 token 不带——对比度不塌,旧壳裁定)。 - let opacity = crate::gpui_shell::wallpaper::window_opacity(cx); + let opacity = crate::gpui_shell::wallpaper::chrome_surface_opacity(cx); let mut shell = shell_color(chrome); shell.a *= opacity; let theme = Theme::global_mut(cx); @@ -464,14 +451,8 @@ mod tests { #[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, 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 @@ -485,7 +466,10 @@ mod tests { 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)) + ( + "background", + nebula_settings::format_hex_rgb(ThemeName::LinenLight.term_theme().background) + ) ); } } diff --git a/nebula_app/src/gpui_shell/toast.rs b/nebula_app/src/gpui_shell/toast.rs index ef15e4f..46d7061 100644 --- a/nebula_app/src/gpui_shell/toast.rs +++ b/nebula_app/src/gpui_shell/toast.rs @@ -17,8 +17,8 @@ use std::sync::Mutex; use std::time::{Duration, Instant}; use gpui::{AnyElement, App, IntoElement as _, ParentElement as _, Styled as _, Window, div, px}; -use gpui_component::{Root, WindowExt as _}; use gpui_component::notification::Notification; +use gpui_component::{Root, WindowExt as _}; use crate::gpui_shell::prelude::v_flex; diff --git a/nebula_app/src/gpui_shell/wallpaper.rs b/nebula_app/src/gpui_shell/wallpaper.rs index d4e212d..0940e62 100644 --- a/nebula_app/src/gpui_shell/wallpaper.rs +++ b/nebula_app/src/gpui_shell/wallpaper.rs @@ -17,8 +17,7 @@ use std::sync::{Arc, Mutex}; use std::time::SystemTime; use gpui::{ - App, Bounds, ContentMask, Corners, Pixels, RenderImage, Window, WindowBackgroundAppearance, - point, px, size, + App, Bounds, ContentMask, Corners, Pixels, RenderImage, Window, WindowBackgroundAppearance, px, }; use image::{Frame, RgbaImage, imageops}; @@ -52,6 +51,8 @@ struct Wallpaper { /// 当前窗口只显示一张正文卡;保留最近一次尺寸即可覆盖拖拽 resize, /// 又不会让连续缩放积累大量 GPU 纹理。 card_cache: Mutex>, + /// 铺满整窗时的窗口级合成图。绝不能每帧把原图像素丢给 GPU。 + window_cache: Mutex>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -107,6 +108,7 @@ pub fn refresh(cx: &mut App) { mtime, baked_opacity: opacity, card_cache: Mutex::new(None), + window_cache: Mutex::new(None), }); } } @@ -124,6 +126,7 @@ pub fn refresh(cx: &mut App) { mtime, baked_opacity: opacity, card_cache: Mutex::new(None), + window_cache: Mutex::new(None), }) }); @@ -132,31 +135,40 @@ pub fn refresh(cx: &mut App) { } /// 当前窗口透明度(无全局时视为不透明)。 +#[allow(dead_code)] pub fn window_opacity(cx: &App) -> f32 { cx.try_global::().map(|v| v.opacity).unwrap_or(1.0) } -/// 开窗参数用:按设置决定窗口背景外观。透明/模糊窗口必须在创建时就 -/// 声明成非 Opaque,否则合成器不给 alpha 通道。 -pub fn initial_background_appearance() -> WindowBackgroundAppearance { - let rt = nebula_settings::RuntimeSettings::load(); - if rt.blur || rt.opacity < 1.0 { - WindowBackgroundAppearance::Transparent - } else { - WindowBackgroundAppearance::Opaque +/// 壳/卡要透出亚克力或铺满壁纸时,不能停在用户滑块的 1.0: +/// 旧壳注释写的是「半透明壳浮在壁纸上」,但滑块默认 100%,实心壳会把 +/// 效果全部盖掉,开关看起来像坏了。模糊开着时顶到 0.88,铺满 chrome +/// 再顶到 0.78,文字仍可读。 +pub fn chrome_surface_opacity(cx: &App) -> f32 { + let Some(effects) = cx.try_global::() else { + return 1.0; + }; + let mut alpha = effects.opacity.clamp(0.0, 1.0); + if effects.blur { + alpha = alpha.min(0.88); } + if effects.wallpaper.as_ref().is_some_and(|wp| wp.cover_chrome) { + alpha = alpha.min(0.78); + } + alpha +} + +/// 开窗参数用。Windows 上必须一开始就声明 Transparent:GPUI 创建后再 +/// 改 appearance,合成器往往不补 alpha 通道,后开的模糊/铺满壁纸会失效。 +pub fn initial_background_appearance() -> WindowBackgroundAppearance { + WindowBackgroundAppearance::Transparent } /// 把窗口层效果应用到所有窗口:gpui 背景外观(alpha 合成通道)+ /// DWM 系统 backdrop(真正的模糊;旧壳同款,关闭写 NONE 不写 AUTO)。 fn apply_window_effects(cx: &mut App) { - let (blur, opacity) = - cx.try_global::().map(|v| (v.blur, v.opacity)).unwrap_or((false, 1.0)); - let appearance = if blur || opacity < 1.0 { - WindowBackgroundAppearance::Transparent - } else { - WindowBackgroundAppearance::Opaque - }; + let blur = cx.try_global::().map(|v| v.blur).unwrap_or(false); + let appearance = WindowBackgroundAppearance::Transparent; for handle in cx.windows() { let _ = handle.update(cx, |_, window, _| { window.set_background_appearance(appearance); @@ -207,6 +219,15 @@ fn decode( ) -> Option<(Arc, Arc, u32, u32)> { let bytes = std::fs::read(path).ok()?; let mut rgba = image::load_from_memory(&bytes).ok()?.into_rgba8(); + // 4K+ 原图若每帧按窗口重采样会卡死设置页开关。先压到长边 2560。 + const MAX_EDGE: u32 = 2560; + let (src_w, src_h) = rgba.dimensions(); + if src_w.max(src_h) > MAX_EDGE { + let scale = MAX_EDGE as f32 / src_w.max(src_h) as f32; + let w = (src_w as f32 * scale).round().max(1.0) as u32; + let h = (src_h as f32 * scale).round().max(1.0) as u32; + rgba = imageops::resize(&rgba, w, h, imageops::FilterType::Triangle); + } let (width, height) = rgba.dimensions(); let factor = opacity.clamp(0.0, 1.0); for pixel in rgba.chunks_exact_mut(4) { @@ -218,37 +239,6 @@ fn decode( Some((image, source, width, height)) } -/// fit/alignment 布局数学(与旧壳 `renderer::image` 一致):以 `anchor` -/// 为定位空间求壁纸目标矩形。native 模式按设备像素 1:1(逻辑尺寸 = -/// 像素 / 缩放),与旧壳"native pixel size"语义相同。 -fn layout_target(anchor: Bounds, wp: &Wallpaper, scale: f32) -> Option> { - let bw = f32::from(anchor.size.width); - let bh = f32::from(anchor.size.height); - if bw <= 1.0 || bh <= 1.0 { - return None; - } - let iw = wp.width.max(1) as f32; - let ih = wp.height.max(1) as f32; - - let (tw, th) = match wp.fit { - BackgroundImageFit::Fill => (bw, bh), - BackgroundImageFit::Uniform => { - let s = (bw / iw).min(bh / ih); - (iw * s, ih * s) - }, - BackgroundImageFit::UniformToFill => { - let s = (bw / iw).max(bh / ih); - (iw * s, ih * s) - }, - BackgroundImageFit::None => (iw / scale, ih / scale), - }; - let (fx, fy) = wp.alignment.factors(); - Some(Bounds::new( - point(anchor.origin.x + px((bw - tw) * fx), anchor.origin.y + px((bh - th) * fy)), - size(px(tw), px(th)), - )) -} - /// 终端卡的壁纸层(卡底色之上、内容之下;由卡容器的 canvas 调用, /// 覆盖整卡含内边距带)。 /// @@ -395,9 +385,39 @@ pub fn paint_wallpaper_under_chrome(bounds: Bounds, window: &mut Window, if !wp.cover_chrome { return; } - let scale = window.scale_factor().max(0.5); - let Some(target) = layout_target(bounds, wp, scale) else { return }; + let Some(image) = window_wallpaper(bounds, window, wp) else { return }; window.with_content_mask(Some(ContentMask { bounds }), |window| { - let _ = window.paint_image(target, Corners::all(px(0.0)), wp.image.clone(), 0, false); + let _ = window.paint_image(bounds, Corners::all(px(0.0)), image, 0, false); }); } + +/// 整窗壁纸:按视口物理像素合成一次并缓存。打开 cover 时绝不能把 +/// 原图丢给 GPU 每帧缩放——那是设置开关「一点就卡死」的根因。 +fn window_wallpaper( + bounds: Bounds, + window: &Window, + wp: &Wallpaper, +) -> Option> { + let scale = window.scale_factor().max(0.5); + let width = (f32::from(bounds.size.width) * scale).ceil().max(1.0) as u32; + let height = (f32::from(bounds.size.height) * scale).ceil().max(1.0) as u32; + let key = CardWallpaperKey { + card_width: width, + card_height: height, + anchor_width: width, + anchor_height: height, + crop_x: 0, + crop_y: 0, + }; + if let Some(image) = wp.window_cache.lock().ok().and_then(|cache| { + cache.as_ref().filter(|cached| cached.key == key).map(|c| c.image.clone()) + }) { + return Some(image); + } + let composed = compose_anchor(wp, width, height); + let image = Arc::new(RenderImage::new([Frame::new(composed)])); + if let Ok(mut cache) = wp.window_cache.lock() { + *cache = Some(CardWallpaper { key, image: image.clone() }); + } + Some(image) +} diff --git a/nebula_app/src/gpui_shell/widgets.rs b/nebula_app/src/gpui_shell/widgets.rs index c8d20df..27e98ae 100644 --- a/nebula_app/src/gpui_shell/widgets.rs +++ b/nebula_app/src/gpui_shell/widgets.rs @@ -1,9 +1,9 @@ //! 旧壳设置控件的 GPUI 形态。 //! -//! - [`NebulaSwitch`]:液态胶囊开关。几何、配色与动画通道逐项对照旧壳 -//! `display/ui/widgets.rs::push_toggle`(HTML 参考的 -//! `transition: all 0.3s ease`)。组件库 `Switch` 是 0.15s 线性、蓝底白点 -//! 的另一套语言,与旧壳中性胶囊在同一页面上会打架。 +//! - [`NebulaSwitch`]:液态胶囊开关。几何对照 `push_toggle`;四通道动画 +//! 对照 `SettingsToggleAnim`(position 400ms `LiquidToggle` 且不夹到 +//! 0/1,stretch 250ms `CssStandard`,color/hover 300ms `CssEase`)。 +//! 组件库 `Switch` 是 0.15s 线性、蓝底白点,禁止混用。 //! - [`NebulaButton`]:设置行动作按钮。几何对照 `row_action_rect` / HTML //! `.bt`,hover 色过渡对照 `.bt { transition: all .13s }`。组件库 `Button` //! 的 `.hover()` / `.active()` 是瞬时换色,没有这段动效。 @@ -14,12 +14,15 @@ use std::time::Duration; use gpui::prelude::FluentBuilder as _; use gpui::{ - Animation, AnimationExt as _, App, ClickEvent, ElementId, InteractiveElement as _, + Animation, AnimationExt as _, App, Bounds, ClickEvent, ElementId, Hsla, InteractiveElement as _, IntoElement, ParentElement as _, RenderImage, RenderOnce, SharedString, - StatefulInteractiveElement as _, Styled as _, Window, div, ease_in_out, px, + StatefulInteractiveElement as _, Styled as _, Window, canvas, div, ease_in_out, fill, point, px, + size, }; use image::Frame; +use crate::display::ui::widgets::ToggleMotion; +use crate::motion::{Easing, MotionClock, MotionPolicy, Tween}; use crate::renderer::ui::Rgba; /// Shell 的彩色品牌图标(`extra/shell-icons` 的 PNG)预缩放成与物理像素 @@ -55,13 +58,93 @@ 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); +const POSITION_MS: Duration = Duration::from_millis(400); +const STRETCH_MS: Duration = Duration::from_millis(250); +const COLOR_MS: Duration = Duration::from_millis(300); -/// 旧壳设置行的动画开关(液态胶囊)。 +/// 与旧壳 `display/mod.rs::SettingsToggleAnim` 同一套四通道。 +/// `position` 不夹到 0/1:`Easing::LiquidToggle` 的过冲就是灵动来源。 +struct SwitchAnim { + clock: MotionClock, + position: Tween, + stretch: Tween, + color: Tween, + hover: Tween, +} + +impl SwitchAnim { + fn new(on: bool) -> Self { + let value = if on { 1.0 } else { 0.0 }; + Self { + clock: MotionClock::default(), + position: Tween::new(value), + stretch: Tween::new(0.0), + color: Tween::new(value), + hover: Tween::new(0.0), + } + } + + fn step(&mut self, on: bool, pressed: bool, hovered: bool) -> ToggleMotion { + let position = if on { if pressed { 16.0 / 24.0 } else { 1.0 } } else { 0.0 }; + let color = if on { 1.0 } else { 0.0 }; + let stretch = if pressed { 1.0 } else { 0.0 }; + let hover = if hovered { 1.0 } else { 0.0 }; + if (self.position.target() - position).abs() > f32::EPSILON { + self.position.animate_to( + position, + POSITION_MS, + Easing::LiquidToggle, + MotionPolicy::Full, + ); + } + if (self.stretch.target() - stretch).abs() > f32::EPSILON { + self.stretch.animate_to(stretch, STRETCH_MS, Easing::CssStandard, MotionPolicy::Full); + } + if (self.color.target() - color).abs() > f32::EPSILON { + self.color.animate_to(color, COLOR_MS, Easing::CssEase, MotionPolicy::Full); + } + if (self.hover.target() - hover).abs() > f32::EPSILON { + self.hover.animate_to(hover, COLOR_MS, Easing::CssEase, MotionPolicy::Full); + } + let frame = self.clock.tick(); + self.position.step(frame); + self.stretch.step(frame); + self.color.step(frame); + self.hover.step(frame); + self.value() + } + + fn value(&self) -> ToggleMotion { + ToggleMotion { + position: self.position.value(), + stretch: self.stretch.value().clamp(0.0, 1.0), + color: self.color.value().clamp(0.0, 1.0), + hover: self.hover.value().clamp(0.0, 1.0), + } + } + + fn animating(&self, on: bool, pressed: bool, hovered: bool) -> bool { + let position = if on { if pressed { 16.0 / 24.0 } else { 1.0 } } else { 0.0 }; + let color = if on { 1.0 } else { 0.0 }; + let stretch = if pressed { 1.0 } else { 0.0 }; + let hover = if hovered { 1.0 } else { 0.0 }; + [ + (self.position, position), + (self.stretch, stretch), + (self.color, color), + (self.hover, hover), + ] + .into_iter() + .any(|(tween, target)| tween.is_active() || (tween.value() - target).abs() > 0.004) + } +} + +/// 旧壳设置行的液态胶囊开关。 /// -/// - 胶囊 48×26(圆角 13),knob 20×20、inset 2,行程 24px; -/// - 按住时 knob 拉伸 +8px(开态从右缘回弹,旧壳「liquid recoil」); -/// - 状态切换 = 0.3s ease 同步过渡位置与颜色(border/track/knob 三通道)。 +/// 几何对照 `push_toggle`:胶囊 48×26 圆角 13,knob 20×20、inset 2, +/// `kx = inset + 24*position`,`knob_w = 20 + 8*stretch`。四通道走 +/// [`crate::motion::Tween`] + `LiquidToggle` / `CssEase` / `CssStandard`, +/// 不是组件库 `Switch`,也不是单次 0.3s ease_in_out。 #[derive(IntoElement)] pub struct NebulaSwitch { key: SharedString, @@ -124,7 +207,16 @@ fn over_rgba(overlay: Rgba, base: Rgba) -> Rgba { 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) + Rgba::new( + channel(overlay.r, base.r), + channel(overlay.g, base.g), + channel(overlay.b, base.b), + base.a, + ) +} + +fn to_hsla(color: gpui::Rgba) -> Hsla { + color.into() } impl RenderOnce for NebulaSwitch { @@ -133,116 +225,101 @@ impl RenderOnce for NebulaSwitch { 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 hover_id = ElementId::Name(format!("nebula-switch-hover-{}", self.key).into()); + let hovered = window.use_keyed_state(hover_id, cx, |_, _| false); + let anim_id = ElementId::Name(format!("nebula-switch-anim-{}", self.key).into()); + let anim = window.use_keyed_state(anim_id, cx, |_, _| SwitchAnim::new(checked)); + let is_pressed = !disabled && *pressed.read(cx); + let is_hovered = !disabled && *hovered.read(cx); + let motion = anim.update(cx, |anim, _| anim.step(checked, is_pressed, is_hovered)); + if !disabled && anim.read(cx).animating(checked, is_pressed, is_hovered) { + window.request_animation_frame(); + } - 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 hover_ink = Rgba::new(sk.icon_hover.r, sk.icon_hover.g, sk.icon_hover.b, 255); + let border = mix(sk.toggle_border_off, sk.toggle_border_on, motion.color); + let border = mix( + Rgba::new( + (border.r * 255.0).round() as u8, + (border.g * 255.0).round() as u8, + (border.b * 255.0).round() as u8, + (border.a * 255.0).round() as u8, + ), + hover_ink, + motion.hover * 0.14, + ); + let track = mix(sk.toggle_track_off, sk.toggle_track_on, motion.color); + let track = over( + Rgba::new(sk.icon_hover.r, sk.icon_hover.g, sk.icon_hover.b, (motion.hover * 12.0) as u8), + track, + ); + let knob = mix(sk.knob_off, sk.knob_on, motion.color); + let knob_w = KNOB + STRETCH * motion.stretch; + let kx = INSET + TRAVEL * motion.position; + let ky = (TRACK_H - KNOB) / 2.0; + let border_hsla = to_hsla(border); + let track_hsla = to_hsla(track); + let knob_hsla = to_hsla(knob); - 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() - }; + let face = canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let ox = f32::from(bounds.origin.x); + let oy = f32::from(bounds.origin.y); + let mut quad = |x: f32, y: f32, w: f32, h: f32, r: f32, bg: Hsla| { + window.paint_quad( + fill( + Bounds::new( + point(px(ox + x), px(oy + y)), + size(px(w.max(0.0)), px(h.max(0.0))), + ), + bg, + ) + .corner_radii(px(r.max(0.0))), + ); + }; + // 发丝描边:整胶囊 border,再 inset 1px 铺轨道(旧壳 push_stroke)。 + quad(0.0, 0.0, TRACK_W, TRACK_H, TRACK_H * 0.5, border_hsla); + quad(1.0, 1.0, TRACK_W - 2.0, TRACK_H - 2.0, (TRACK_H - 2.0) * 0.5, track_hsla); + quad(kx, ky, knob_w, KNOB, KNOB * 0.5, knob_hsla); + }, + ) + .w(px(TRACK_W)) + .h(px(TRACK_H)); div() .id(ElementId::Name(format!("nebula-switch-{}", self.key).into())) .flex_shrink_0() + .w(px(TRACK_W)) + .h(px(TRACK_H)) .rounded(px(TRACK_H / 2.0)) - .cursor_pointer() - .child(track) + .when(disabled, |wrapper| wrapper.opacity(0.45).cursor_default()) + .when(!disabled, |wrapper| wrapper.cursor_pointer()) + .child(face) .when(!disabled, |wrapper| { let pressed_down = pressed.clone(); let pressed_up = pressed; + let hover_state = hovered; wrapper - .on_mouse_down(gpui::MouseButton::Left, move |_, _, cx| { - cx.stop_propagation(); - let _ = pressed_down.update(cx, |state, _| *state = true); + .on_hover(move |hovered, _, cx| { + let _ = hover_state.update(cx, |state, _| *state = *hovered); + }) + .on_mouse_down(gpui::MouseButton::Left, { + let on_click = self.on_click.clone(); + move |_, window, cx| { + cx.stop_propagation(); + let _ = pressed_down.update(cx, |state, _| *state = true); + if let Some(on_click) = on_click.as_ref() { + on_click(&!checked, window, cx); + } + } }) .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); - }) - }) }) } } @@ -356,12 +433,12 @@ fn button_palette( 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) - } + }, } } @@ -418,10 +495,7 @@ impl RenderOnce for NebulaButton { .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() + face.bg(bg_of(t)).border_color(border_of(t)).text_color(fg_of(t)).into_any_element() }; div() diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index 3664ccc..801b875 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -40,9 +40,14 @@ use crate::gpui_shell::settings_pane::{SettingsPane, SettingsPaneEvent}; use crate::gpui_shell::terminal::view::{SidebarActivity, TerminalView, TerminalViewEvent}; use gpui_component::Root; use gpui_component::input::InputEvent; -use gpui_component::menu::PopupMenuItem; use nebula_split::{DIVIDER_GAP, HIT_SLOP, RemoveOutcome, SplitDirection, SplitNav, SplitTree}; +mod file_tree; +mod key_actions; +mod residency; +mod tab_menu; +mod tab_scroll; + gpui::actions!( nebula_workspace, [ @@ -56,6 +61,7 @@ gpui::actions!( CommandPaletteUp, CommandPaletteDown, ToggleFileTree, + ToggleGitPanel, SplitRight, SplitDown, RenameActiveTab, @@ -63,7 +69,16 @@ gpui::actions!( FocusPaneLeft, FocusPaneRight, FocusPaneUp, - FocusPaneDown + FocusPaneDown, + SelectNextTab, + SelectPreviousTab, + IncreaseFontSize, + DecreaseFontSize, + ResetFontSize, + CopySelection, + PasteClipboard, + ToggleFullscreen, + OpenQuickJump ] ); @@ -90,22 +105,33 @@ const STATIC_DEFAULT_COMBOS: &[&str] = &[ "ctrl-alt-right", "ctrl-alt-up", "ctrl-alt-down", + "ctrl-tab", + "ctrl-shift-tab", + "ctrl-shift-g", + "ctrl-=", + "ctrl-+", + "ctrl--", + "ctrl-0", + "ctrl-shift-c", + "ctrl-shift-v", + "alt-enter", + "ctrl-shift-o", ]; /// `keybind=` 自定义表(两壳共读)中 config::Action → GPUI 工作区动作的 -/// 映射。映射不到的动作(prompt 跳转、复制粘贴、字号……)GPUI 壳尚未实 -/// 装,编辑器仍可读写(旧壳消费),这里跳过不注入。 +/// 映射。仍跳过未接线的动作(prompt 跳转、搜索、新建窗口):编辑器可读写, +/// 这里不注入。`CreateNewWindow` 旧壳是同进程 `CreateWindow`,不是再 spawn +/// 一份 `nebula --gpui`;GPUI `run_shell` 单窗(一份 AI hook + 会话保存)。 fn custom_workspace_binding(combo: &str, action: &crate::config::Action) -> Option { use crate::config::Action; let combo = gpui_binding_combo(combo); match action { - Action::ToggleCommandPalette => { - Some(KeyBinding::new(&combo, ToggleCommandPalette, None)) - }, + 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)), + Action::ToggleGitPanel => Some(KeyBinding::new(&combo, ToggleGitPanel, None)), Action::SplitRight => Some(KeyBinding::new(&combo, SplitRight, None)), Action::SplitDown => Some(KeyBinding::new(&combo, SplitDown, None)), Action::ToggleZoom => Some(KeyBinding::new(&combo, ToggleZoom, None)), @@ -113,6 +139,15 @@ fn custom_workspace_binding(combo: &str, action: &crate::config::Action) -> Opti Action::FocusPaneRight => Some(KeyBinding::new(&combo, FocusPaneRight, None)), Action::FocusPaneUp => Some(KeyBinding::new(&combo, FocusPaneUp, None)), Action::FocusPaneDown => Some(KeyBinding::new(&combo, FocusPaneDown, None)), + Action::SelectNextTab => Some(KeyBinding::new(&combo, SelectNextTab, None)), + Action::SelectPreviousTab => Some(KeyBinding::new(&combo, SelectPreviousTab, None)), + Action::IncreaseFontSize => Some(KeyBinding::new(&combo, IncreaseFontSize, None)), + Action::DecreaseFontSize => Some(KeyBinding::new(&combo, DecreaseFontSize, None)), + Action::ResetFontSize => Some(KeyBinding::new(&combo, ResetFontSize, None)), + Action::Copy => Some(KeyBinding::new(&combo, CopySelection, None)), + Action::Paste => Some(KeyBinding::new(&combo, PasteClipboard, None)), + Action::ToggleFullscreen => Some(KeyBinding::new(&combo, ToggleFullscreen, None)), + Action::OpenQuickJump => Some(KeyBinding::new(&combo, OpenQuickJump, None)), // `none` 禁用键:gpui 的 NoAction 绑定在最高优先级命中时吞掉按键, // 与旧壳 keybind=combo:none 的语义一致。 Action::None => Some(KeyBinding::new(&combo, gpui::NoAction, None)), @@ -121,10 +156,16 @@ fn custom_workspace_binding(combo: &str, action: &crate::config::Action) -> Opti } /// 存储格式 combo(`ctrl+shift+t`)→ gpui 绑定串(`ctrl-shift-t`)。键名 -/// 两套体系同构(小写命名键 + 单字符),只有 digitN 是旧壳 scancode 专用 -/// 记法,折回数字字符。 +/// 两套体系同构(小写命名键 + 单字符);digitN 折回数字,plus/minus 折回 +/// `+`/`-`(`+` 是存储分隔符,必须先占位再替换)。 fn gpui_binding_combo(combo: &str) -> String { - combo.replace('+', "-").replace("digit", "") + combo + .replace("plus", "\u{1}") + .replace("minus", "\u{2}") + .replace('+', "-") + .replace("digit", "") + .replace('\u{1}', "+") + .replace('\u{2}', "-") } /// 注册工作区快捷键;在 `gpui_component::init` 之后调用一次。 @@ -151,6 +192,17 @@ pub fn init(cx: &mut App) { KeyBinding::new("ctrl-alt-right", FocusPaneRight, None), KeyBinding::new("ctrl-alt-up", FocusPaneUp, None), KeyBinding::new("ctrl-alt-down", FocusPaneDown, None), + KeyBinding::new("ctrl-tab", SelectNextTab, None), + KeyBinding::new("ctrl-shift-tab", SelectPreviousTab, None), + KeyBinding::new("ctrl-shift-g", ToggleGitPanel, None), + KeyBinding::new("ctrl-=", IncreaseFontSize, None), + KeyBinding::new("ctrl-+", IncreaseFontSize, None), + KeyBinding::new("ctrl--", DecreaseFontSize, None), + KeyBinding::new("ctrl-0", ResetFontSize, None), + KeyBinding::new("ctrl-shift-c", CopySelection, None), + KeyBinding::new("ctrl-shift-v", PasteClipboard, None), + KeyBinding::new("alt-enter", ToggleFullscreen, None), + KeyBinding::new("ctrl-shift-o", OpenQuickJump, None), ]); } @@ -265,11 +317,7 @@ fn sidebar_logo_images( let mut images = HashMap::new(); if let Some(image) = - decode_sidebar_logo( - include_bytes!("../../../extra/logo/ai_claude.png"), - None, - target_size, - ) + decode_sidebar_logo(include_bytes!("../../../extra/logo/ai_claude.png"), None, target_size) { images.insert((AiLogo::Claude, true), image.clone()); images.insert((AiLogo::Claude, false), image); @@ -290,22 +338,18 @@ fn sidebar_logo_images( images.insert((logo, false), image); } } - if let Some(image) = - decode_sidebar_logo( - include_bytes!("../../../extra/logo/ai_grok_light.png"), - None, - target_size, - ) - { + if let Some(image) = decode_sidebar_logo( + include_bytes!("../../../extra/logo/ai_grok_light.png"), + None, + target_size, + ) { images.insert((AiLogo::Grok, true), image); } - if let Some(image) = - decode_sidebar_logo( - include_bytes!("../../../extra/logo/ai_grok_dark.png"), - None, - target_size, - ) - { + if let Some(image) = decode_sidebar_logo( + include_bytes!("../../../extra/logo/ai_grok_dark.png"), + None, + target_size, + ) { images.insert((AiLogo::Grok, false), image); } images @@ -342,9 +386,9 @@ fn dock_tree(target: SplitTree, source: SplitTree, nav: SplitNav) -> S } /// 侧栏 tab 行高与行距(与 `render_sidebar` 的 `h(px(TAB_ROW_H))`、 -/// `gap_1`(4px) 同源);受约束拖拽按此步距换算让位槽位。 -const TAB_ROW_H: f32 = 34.0; -const TAB_ROW_PITCH: f32 = TAB_ROW_H + 8.0; +/// `gap_2`(8px) 同源);受约束拖拽按此步距换算让位槽位。 +pub(super) const TAB_ROW_H: f32 = 34.0; +pub(super) const TAB_ROW_PITCH: f32 = TAB_ROW_H + 8.0; const SIDE_PANEL_SLOT_W: f32 = 328.0; /// 侧栏 tab 行的关闭按钮边长。旧壳 `chrome_tab_layout` 取 @@ -429,6 +473,8 @@ struct WorkspacePaletteRow { /// 行首的彩色品牌图标(只有 shell/配置档行有)。旧壳的 shell 菜单同样 /// 是「图标 + 名字 + 灰色命令行」三段式,光靠文字分不清 pwsh 与 5.1。 icon: Option>, + /// SSH 主机行用 Nerd Font 码位(旧壳 `os_icons`),不是品牌 PNG。 + icon_glyph: Option, } /// 精确 pane id 必须严格路由:已关闭 pane 的迟到事件不能污染当前活跃 @@ -512,6 +558,7 @@ fn ai_session_palette_rows( search: format!("恢复 resume {search}"), action: WorkspacePaletteAction::RunAiSession { command, cwd: cwd.clone() }, icon: None, + icon_glyph: None, }); } if let Some(command) = session.fork_command() { @@ -523,6 +570,7 @@ fn ai_session_palette_rows( search: format!("分叉 fork {search}"), action: WorkspacePaletteAction::RunAiSession { command, cwd: cwd.clone() }, icon: None, + icon_glyph: None, }); } } @@ -549,11 +597,7 @@ fn shell_palette_rows( 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() - }, + 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(), @@ -562,6 +606,7 @@ fn shell_palette_rows( SHELL_ICON_PX, scale_factor, ), + icon_glyph: None, action: WorkspacePaletteAction::LaunchShell(shell), } }) @@ -570,18 +615,43 @@ fn shell_palette_rows( 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, + let ssh_icons = ssh_host_icon_ids(); + rows.extend(ssh_hosts.into_iter().map(|host| { + let glyph = crate::display::ui::os_icons::resolve(ssh_icons.get(&host).map(String::as_str)) + .glyph; + 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, + icon_glyph: Some(glyph), + } })); rows } +fn ssh_host_icon_ids() -> std::collections::HashMap { + let mut icons = crate::ssh_profiles::SshProfiles::load( + &crate::display::nebula_data_dir().join("ssh_profiles.json"), + ) + .map(|profiles| profiles.icons()) + .unwrap_or_default(); + if std::env::var_os("NEBULA_CONFIG_DIR").is_some() { + if let Some(appdata) = std::env::var_os("APPDATA") { + let user = std::path::PathBuf::from(appdata).join("Nebula").join("ssh_profiles.json"); + if let Ok(profiles) = crate::ssh_profiles::SshProfiles::load(&user) { + for (host, icon) in profiles.icons() { + icons.entry(host).or_insert(icon); + } + } + } + } + icons +} + /// 标签的用户可编辑元数据:重命名与色标(旧壳 `TabEntry::custom_name` / /// `custom_color` 的对应物,字段与共享 session v4 的 `TabSession` 同名同义, /// 所以导出/恢复不需要转换层)。 @@ -603,17 +673,31 @@ struct TabMeta { /// 启动方式”。共享 session v4 已有完整 schema,GPUI 只需把它保留下来, /// 不能在快照时把所有本地 Tab 都降级成 `None`。 launch: Option, + /// 后台 tab 响过 BEL(旧壳 `has_bell`)。激活即清。 + has_bell: bool, } /// 侧栏行内重命名的活动状态(旧壳 `nebula_tab_rename` 同形态:被编辑的那 -/// 一行原地变输入框,而不是弹一个对话框)。Enter 提交、Esc 取消、失焦提交; -/// 提交空串 = 恢复自动标签名。 +/// 一行原地变输入框,而不是弹一个对话框)。Enter 提交;Esc / 失焦取消 +/// (对照 `input/chrome.rs` 点在框外 = `CancelRename`);提交空串 = 恢复 +/// 自动标签名。 struct TabRename { ix: usize, input: Entity, _subscription: Subscription, } +/// 旧壳 `TabRequest::CommitRename`(`window_context.rs` ~871-880): +/// trim;空串 → `custom_name = None`(恢复自动名);非空 → `Some(trimmed)`。 +fn apply_commit_rename(meta: &mut TabMeta, buffer: &str) { + let trimmed = buffer.trim(); + meta.custom_name = if trimmed.is_empty() { None } else { Some(trimmed.to_owned()) }; +} + +/// 旧壳 `TabRequest::CancelRename`(`window_context.rs` ~896-901): +/// 丢掉重命名缓冲,`custom_name` 保持进入编辑前的值。 +fn apply_cancel_rename(_meta: &mut TabMeta) {} + pub struct NebulaWorkspace { tabs: Vec, /// 与 `tabs` 同下标的用户元数据,见 [`TabMeta`]。 @@ -632,6 +716,14 @@ pub struct NebulaWorkspace { sidebar_fold_armed: bool, /// 同理,tab 列表的折叠动画也只在首次手动切换后启用。 tabs_fold_armed: bool, + /// 旧壳 `nebula_tabs_scroll`:TABS 溢出时的整行窗口起点。 + tabs_scroll: usize, + /// 列表视口(逻辑 px),由 canvas 回写;0 表示尚未量到。 + tabs_viewport_h: f32, + tabs_list_width: f32, + tabs_list_origin: gpui::Point, + tabs_scroll_grab: Option, + tabs_list_hot: bool, /// 开窗时反推的目标网格(含小屏收拢);首个终端按它 spawn。 initial_grid: (u16, u16), /// 进行中的 tab 拖拽(含未过阈值的待命态);见 [`TabDrag`]。 @@ -669,6 +761,8 @@ pub struct NebulaWorkspace { side_panel: crate::display::side_panel::SidePanel, side_panel_polling: bool, side_panel_anim_armed: bool, + /// 文件树右键:画在 workspace 根上,不进抽屉子孙树。见 `file_tree.rs`。 + file_tree_menu: Option, /// 复用旧壳随包分发的 AI 品牌图,不用近似字体图标替代。 sidebar_logo_images: HashMap<(crate::display::AiLogo, bool), Arc>, /// 品牌图缓存对应的整数物理像素边长;窗口跨 DPI 显示器时据此重建。 @@ -686,6 +780,12 @@ pub struct NebulaWorkspace { last_saved_session: Option, /// 系统关闭按钮可能连续送来多次 should-close;确认框在场时只保留一份。 window_close_confirm_open: bool, + /// `keep_session` 关窗后 HWND 已隐藏、PTY 仍在;托盘 / mux ATTACH 用来捞回。 + window_hidden: bool, + /// 开窗时记下,mux `tab.new` 需要从 pump 拿到 `&mut Window`。 + window_handle: gpui::AnyWindowHandle, + /// runtime API 里必须等窗口的命令(目前是 `tab.new`)。 + runtime_pending: Vec>, } impl NebulaWorkspace { @@ -695,6 +795,7 @@ impl NebulaWorkspace { let at = new_tab_insert_index(position, self.active, self.tabs.len()); self.insert_tab_at(at, tab, TabMeta::default()); self.active = at; + self.reveal_active_tab(); } /// `tabs` + `tab_meta` 的唯一插入口(见 [`TabMeta`] 的同下标合同)。 @@ -702,6 +803,7 @@ impl NebulaWorkspace { let at = at.min(self.tabs.len()); self.tabs.insert(at, tab); self.tab_meta.insert(at, meta); + self.clamp_tabs_scroll(); } /// `tabs` + `tab_meta` 的唯一移除口;返回被摘的 tab 供调用方回收会话。 @@ -713,6 +815,7 @@ impl NebulaWorkspace { // 长度不齐时(理论上不会)宁可给默认元数据,也不要 panic。 let meta = if ix < self.tab_meta.len() { self.tab_meta.remove(ix) } else { TabMeta::default() }; + self.clamp_tabs_scroll(); Some((tab, meta)) } @@ -724,6 +827,7 @@ impl NebulaWorkspace { pub fn new( window: &mut Window, ai_events: std::sync::mpsc::Receiver, + shell_events: std::sync::mpsc::Receiver, cx: &mut Context, ) -> Self { // 启动相关设置只取样一次:本次开窗的恢复决策不能被恢复过程中的 @@ -739,8 +843,7 @@ impl NebulaWorkspace { }); let command_palette_input = cx.new(|cx| InputState::new(window, cx).placeholder("搜索命令…")); - let git_commit_input = - cx.new(|cx| InputState::new(window, cx).placeholder("提交信息…")); + let git_commit_input = cx.new(|cx| InputState::new(window, cx).placeholder("提交信息…")); let command_palette_subscription = cx.subscribe_in( &command_palette_input, window, @@ -774,6 +877,12 @@ impl NebulaWorkspace { sidebar_width, sidebar_fold_armed: false, tabs_fold_armed: false, + tabs_scroll: 0, + tabs_viewport_h: 0.0, + tabs_list_width: 0.0, + tabs_list_origin: gpui::point(px(0.0), px(0.0)), + tabs_scroll_grab: None, + tabs_list_hot: false, initial_grid, tab_drag: None, split_drag: None, @@ -792,6 +901,7 @@ impl NebulaWorkspace { side_panel: crate::display::side_panel::SidePanel::new(), side_panel_polling: false, side_panel_anim_armed: false, + file_tree_menu: None, sidebar_logo_images: sidebar_logo_images(sidebar_logo_target_px), sidebar_logo_target_px, _appearance_sub: appearance_sub, @@ -800,6 +910,9 @@ impl NebulaWorkspace { spinner_last: std::time::Instant::now(), last_saved_session: None, window_close_confirm_open: false, + window_hidden: false, + window_handle: window.window_handle(), + runtime_pending: Vec::new(), }; // 配置装载错误的驻留横幅(消息栏层:用户要去修文件,必须看见)。 // 只在开窗时呈现一次;设置页 persist 的重载不重复弹。 @@ -823,6 +936,7 @@ impl NebulaWorkspace { } Self::start_ai_hook_pump(ai_events, cx); Self::start_agent_screen_watchdog(cx); + Self::start_shell_event_pump(shell_events, cx); this.start_session_autosave(cx); this.apply_custom_keybinds(cx); let workspace = cx.entity().downgrade(); @@ -915,9 +1029,7 @@ impl NebulaWorkspace { use crate::session::LaunchSession; match launch { - LaunchSession::Default => { - TerminalLaunch::Local { cwd, shell: None, shell_name: None } - }, + LaunchSession::Default => TerminalLaunch::Local { cwd, shell: None, shell_name: None }, LaunchSession::Shell { name, program, args } => TerminalLaunch::Local { cwd, shell: Some(nebula_terminal::tty::Shell::new(program.clone(), args.clone())), @@ -938,9 +1050,7 @@ impl NebulaWorkspace { shell_name: profile.shell_id.clone().or_else(|| Some(profile.name.clone())), } }, - LaunchSession::Ssh { host } => { - TerminalLaunch::Ssh { destination: host.clone() } - }, + LaunchSession::Ssh { host } => TerminalLaunch::Ssh { destination: host.clone() }, } } @@ -1009,6 +1119,7 @@ impl NebulaWorkspace { } crate::gpui_shell::theme::apply_chrome_theme(cx); self.sidebar_width = nebula_settings::RuntimeSettings::load().sidebar_width; + self.reveal_if_tray_disabled(cx); cx.notify(); } @@ -1110,13 +1221,10 @@ impl NebulaWorkspace { self.insert_tab_at( at, tab, - TabMeta { - shell_tag, - launch: Some(launch_session), - ..TabMeta::default() - }, + TabMeta { shell_tag, launch: Some(launch_session), ..TabMeta::default() }, ); self.active = at; + self.reveal_active_tab(); self.focus_active(window, cx); cx.notify(); } @@ -1158,13 +1266,12 @@ impl NebulaWorkspace { tab, TabMeta { shell_tag: Some("ssh".into()), - launch: Some(crate::session::LaunchSession::Ssh { - host: destination, - }), + launch: Some(crate::session::LaunchSession::Ssh { host: destination }), ..TabMeta::default() }, ); self.active = at; + self.reveal_active_tab(); self.focus_active(window, cx); cx.notify(); } @@ -1281,6 +1388,9 @@ impl NebulaWorkspace { /// GPUI 的 should-close 回调必须同步返回:无繁忙进程时直接允许系统关闭; /// 有繁忙进程时先返回 false,再由对话框确认回调显式移除窗口。 fn should_close_window(&mut self, window: &mut Window, cx: &mut Context) -> bool { + if self.keep_session_on_close(window, cx) { + return false; + } let Some(process) = self.busy_process_in_window(cx) else { self.save_clean_window_session(cx); return true; @@ -1360,12 +1470,7 @@ impl NebulaWorkspace { }); } - fn request_close_tab( - &mut self, - tab_ix: usize, - window: &mut Window, - cx: &mut Context, - ) { + fn request_close_tab(&mut self, tab_ix: usize, window: &mut Window, cx: &mut Context) { let Some(process) = self.busy_process_in_tab(tab_ix, None, cx) else { self.close_tab(tab_ix, window, cx); return; @@ -1515,10 +1620,8 @@ impl NebulaWorkspace { ) -> bool { use crate::session::{LaunchSession, LayoutSession}; - let layout = tab.layout.clone().unwrap_or(LayoutSession::Pane { - cwd: tab.cwd.clone(), - agent: None, - }); + let layout = + tab.layout.clone().unwrap_or(LayoutSession::Pane { cwd: tab.cwd.clone(), agent: None }); // v1-v3 / 早期 GPUI 快照没有 launch,按共享 schema 回退 Default; // v4 的 Shell/Profile/Ssh 必须原样用于首 Pane,不能再次读取当前默认。 let saved_launch = tab.launch.clone().unwrap_or(LaunchSession::Default); @@ -1527,10 +1630,7 @@ impl NebulaWorkspace { for (index, leaf) in layout.leaves().into_iter().enumerate() { let LayoutSession::Pane { cwd, agent } = leaf else { continue }; let launch = if index == 0 { - Self::terminal_launch_from_session( - &saved_launch, - crate::session::valid_dir(cwd), - ) + Self::terminal_launch_from_session(&saved_launch, crate::session::valid_dir(cwd)) } else { // 共享 v4 与旧壳只把 Tab 的 launch 赋给首 Pane;其它叶子没有 // 独立启动身份,保持既有 Default 恢复语义。 @@ -1541,7 +1641,15 @@ impl NebulaWorkspace { } }; let command = restored_agent_command(resume_ai, agent.as_ref()); - panes.push(self.new_pane(grid, launch, command, window, cx)); + let pane = self.new_pane(grid, launch, command, window, cx); + // 冷恢复已经知道这段对话的 hook 身份:种回 view,右键「分叉 + // AI 会话」不必再等下一条带 session_id 的 hook。 + if let Some((source, session_id)) = agent.as_ref().and_then(|agent| { + Some((agent.source.clone(), agent.session_id.clone()?)) + }) { + pane.view.update(cx, |view, cx| view.seed_ai_session(source, session_id, cx)); + } + panes.push(pane); } if panes.is_empty() { return false; @@ -1563,6 +1671,7 @@ impl NebulaWorkspace { color: tab.color, shell_tag: Self::launch_shell_tag(&saved_launch), launch: Some(saved_launch), + has_bell: false, }, ); true @@ -1596,9 +1705,7 @@ impl NebulaWorkspace { .or_else(|| { view.running_program .as_deref() - .filter(|program| { - crate::ai_agents::AgentKind::parse(program).is_some() - }) + .filter(|program| crate::ai_agents::AgentKind::parse(program).is_some()) .map(|program| AgentSession { source: program.to_owned(), session_id: None, @@ -1606,8 +1713,7 @@ impl NebulaWorkspace { }); (view.cwd.clone(), agent) }; - let layout = - crate::gpui_shell::session_restore::layout_from_tree(tree, &leaf_data); + let layout = crate::gpui_shell::session_restore::layout_from_tree(tree, &leaf_data); let cwd = panes .iter() .find(|pane| pane.id == *focused) @@ -1625,8 +1731,7 @@ impl NebulaWorkspace { .map(|host| LaunchSession::Ssh { host }) .unwrap_or(LaunchSession::Default) }); - let active_pane = - tree.leaves().iter().position(|id| id == focused).unwrap_or(0); + let active_pane = tree.leaves().iter().position(|id| id == focused).unwrap_or(0); tabs.push(TabSession { cwd, custom_name: meta.custom_name, @@ -1717,6 +1822,7 @@ impl NebulaWorkspace { for view in views { view.update(cx, |view, cx| view.refresh_agent_screen_state(cx)); } + workspace.publish_tray_agents(cx); }); if alive.is_err() { return; @@ -1808,9 +1914,11 @@ impl NebulaWorkspace { color, shell_tag, launch: Some(launch_session), + has_bell: false, }, ); self.active = at; + self.reveal_active_tab(); self.focus_active(window, cx); cx.notify(); } @@ -1843,18 +1951,28 @@ impl NebulaWorkspace { } /// 文件路由(旧壳 `input/chrome.rs` 双击合同):图片 → 图片 tab; - /// 可读文本 → 文档 tab;其余交系统处理器。 + /// Markdown → 文档 tab(TextView 富渲染);其余可读文本(txt/log/json + /// 与源码)→ 代码 tab(行号 + 行级虚拟化,用户裁定 txt 同代码一样); + /// 都不认的交系统处理器。 fn open_document_path( &mut self, path: std::path::PathBuf, window: &mut Window, cx: &mut Context, ) { + let is_markdown = path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + matches!(extension.to_ascii_lowercase().as_str(), "md" | "markdown") + }); if crate::display::image_viewer::viewable_file(&path) { self.open_image_tab(path, window, cx); - } else if crate::display::markdown_view::viewable_file(&path) { + } else if is_markdown { self.open_doc_tab(path, window, cx); - } else if crate::gpui_shell::code_tab::viewable_file(&path) { + } else if crate::gpui_shell::code_tab::viewable_file(&path) + || crate::display::markdown_view::viewable_file(&path) + { self.open_code_tab(path, window, cx); } else { open_in_file_manager(&path); @@ -1868,9 +1986,9 @@ impl NebulaWorkspace { window: &mut Window, cx: &mut Context, ) { - if let Some(ix) = self.tabs.iter().position(|tab| { - matches!(tab, WorkspaceTab::Image { view } if view.read(cx).path == path) - }) { + if let Some(ix) = self.tabs.iter().position( + |tab| matches!(tab, WorkspaceTab::Image { view } if view.read(cx).path == path), + ) { if let Some(WorkspaceTab::Image { view }) = self.tabs.get(ix) { view.clone().update(cx, |view, cx| view.reload(cx)); } @@ -1889,9 +2007,9 @@ impl NebulaWorkspace { window: &mut Window, cx: &mut Context, ) { - if let Some(ix) = self.tabs.iter().position(|tab| { - matches!(tab, WorkspaceTab::Document { view } if view.read(cx).path == path) - }) { + if let Some(ix) = self.tabs.iter().position( + |tab| matches!(tab, WorkspaceTab::Document { view } if view.read(cx).path == path), + ) { if let Some(WorkspaceTab::Document { view }) = self.tabs.get(ix) { view.clone().update(cx, |view, cx| { view.reload(); @@ -1913,9 +2031,9 @@ impl NebulaWorkspace { 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(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)); } @@ -1966,6 +2084,17 @@ impl NebulaWorkspace { self.request_close_pane(tab_ix, pane_id, window, cx); } }, + TerminalViewEvent::FontSizeChanged => self.apply_runtime_settings(cx), + TerminalViewEvent::Bell => { + if let Some((tab_ix, _)) = self.locate_pane(view.entity_id()) + && tab_ix != self.active + { + if let Some(meta) = self.tab_meta.get_mut(tab_ix) { + meta.has_bell = true; + } + cx.notify(); + } + }, } } @@ -2017,6 +2146,7 @@ impl NebulaWorkspace { if self.active >= self.tabs.len() { self.active = self.tabs.len() - 1; } + self.reveal_active_tab(); self.focus_active(window, cx); cx.notify(); } @@ -2024,6 +2154,10 @@ impl NebulaWorkspace { fn activate_tab(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { if ix < self.tabs.len() && ix != self.active { self.active = ix; + if let Some(meta) = self.tab_meta.get_mut(ix) { + meta.has_bell = false; + } + self.reveal_active_tab(); self.focus_active(window, cx); cx.notify(); } @@ -2254,6 +2388,7 @@ impl NebulaWorkspace { ) { self.side_panel_anim_armed = true; self.side_panel.toggle(view); + self.file_tree_menu = None; if !self.side_panel.open { cx.notify(); return; @@ -2338,6 +2473,8 @@ impl NebulaWorkspace { | PaletteAction::SelectTheme(_) | PaletteAction::SplitRight | PaletteAction::SplitDown + | PaletteAction::ToggleGitPanel + | PaletteAction::ExportWorkspace ) } @@ -2377,21 +2514,30 @@ impl NebulaWorkspace { search: item.search.to_owned(), action: WorkspacePaletteAction::Shared(item.action.clone()), icon: None, + icon_glyph: None, } }) .collect(); // 启动器混排(旧壳 ⌘K 裁定):SSH 主机与命令同列,置顶/隐藏 // 次序由共享 merge 权威裁定。 + let ssh_icons = ssh_host_icon_ids(); rows.extend( crate::gpui_shell::ssh_hosts::SshHostLists::load().merged().into_iter().map( - |host| WorkspacePaletteRow { - group_order: usize::MAX, - group: language.pick("SSH 主机", "SSH HOSTS").to_owned(), - label: host.clone(), - hint: "SSH".to_owned(), - search: format!("{host} ssh host remote lianjie 连接").to_lowercase(), - action: WorkspacePaletteAction::LaunchSshHost(host), - icon: None, + |host| { + let glyph = crate::display::ui::os_icons::resolve( + ssh_icons.get(&host).map(String::as_str), + ) + .glyph; + WorkspacePaletteRow { + group_order: usize::MAX, + group: language.pick("SSH 主机", "SSH HOSTS").to_owned(), + label: host.clone(), + hint: "SSH".to_owned(), + search: format!("{host} ssh host remote lianjie 连接").to_lowercase(), + action: WorkspacePaletteAction::LaunchSshHost(host), + icon: None, + icon_glyph: Some(glyph), + } }, ), ); @@ -2495,6 +2641,7 @@ impl NebulaWorkspace { 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_open = true; self.command_palette_selected = 0; self.command_palette_input.update(cx, |input, cx| { input.set_value("", window, cx); @@ -2550,7 +2697,9 @@ impl NebulaWorkspace { cx.notify(); } - fn launcher_chip_counts(&self) -> [(crate::display::command_palette::LauncherFilter, usize); 3] { + 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 @@ -2561,7 +2710,11 @@ impl NebulaWorkspace { .iter() .filter(|row| matches!(row.action, WorkspacePaletteAction::LaunchSshHost(_))) .count(); - [(LauncherFilter::All, shell + ssh), (LauncherFilter::Ssh, ssh), (LauncherFilter::Shell, shell)] + [ + (LauncherFilter::All, shell + ssh), + (LauncherFilter::Ssh, ssh), + (LauncherFilter::Shell, shell), + ] } /// 从弹窗选中的 shell 起一个新终端。走共享 v4 launch 身份,因此冷恢复 @@ -2647,6 +2800,11 @@ impl NebulaWorkspace { self.toggle_file_tree(cx); self.focus_active(window, cx); }, + PaletteAction::ToggleGitPanel => { + self.toggle_git_tree(cx); + self.focus_active(window, cx); + }, + PaletteAction::ExportWorkspace => self.export_workspace(window, cx), PaletteAction::ToggleGhost => { let value = !nebula_settings::RuntimeSettings::load().ghost; let _ = nebula_settings::persist_keys(&[("ghost", (value as u8).to_string())]); @@ -2764,6 +2922,19 @@ impl NebulaWorkspace { gpui::ObjectFit::Contain, )) }) + .when_some(item.icon_glyph, |row, glyph| { + row.child( + div() + .size(px(22.0)) + .flex_shrink_0() + .flex() + .items_center() + .justify_center() + .font_family(crate::font_install::REQUIRED_FONT_FAMILY) + .text_size(px(16.0)) + .child(glyph.to_string()), + ) + }) .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)) @@ -2893,6 +3064,7 @@ impl NebulaWorkspace { if self.side_panel.view == view { return; } + self.file_tree_menu = None; self.side_panel.toggle(view); let cwd = self.active_local_cwd(cx); self.side_panel.sync(cwd); @@ -2934,184 +3106,6 @@ impl NebulaWorkspace { ) } - fn render_file_tree(&mut self, cx: &mut Context) -> gpui::AnyElement { - let view_switch = self.render_side_panel_switch(cx).into_any_element(); - let theme = cx.theme(); - let muted = theme.muted_foreground; - let hover = theme.list_hover; - let selected_bg = theme.list_active; - let mono_family: SharedString = cx - .try_global::() - .map(|settings| settings.font_family.clone()) - .unwrap_or_else(|| String::from("Maple Mono Normal NF CN")) - .into(); - let selected_path = self.side_panel.selected.clone(); - let scroll = self.side_panel.scroll; - let root = self - .side_panel - .root() - .map(|path| path.display().to_string()) - .unwrap_or_else(|| "等待终端上报工作目录…".to_owned()); - let rows: Vec<_> = self.side_panel.file_rows().iter().skip(scroll).cloned().collect(); - - let row_elements = rows.into_iter().enumerate().map(|(visible_ix, row)| { - let path = row.path.clone(); - let open_path = path.clone(); - let is_dir = row.is_dir; - let is_parent = row.is_parent; - let selected = selected_path.as_ref() == Some(&path); - let fg = if row.ignored { muted } else { theme.foreground }; - let _chevron = if row.is_dir && !row.is_parent { - if row.expanded { "⌄" } else { "›" } - } else { - "" - }; - let file_glyph = - (!row.is_dir).then(|| crate::display::side_panel::file_type_icon(&row.name)); - let legacy_chevron = row - .is_dir - .then(|| { - (!row.is_parent).then(|| crate::display::side_panel::chevron_icon(row.expanded)) - }) - .flatten(); - - h_flex() - .id(SharedString::from(format!("file-tree-row-{visible_ix}"))) - .h(px(30.0)) - .w_full() - .items_center() - .pr_2() - .pl(px(8.0 + row.depth as f32 * 16.0)) - .gap_1() - .rounded_md() - .text_color(fg) - .when(selected, |item| item.bg(selected_bg)) - .hover(|item| item.bg(hover)) - .child( - div() - .w(px(12.0)) - .flex_shrink_0() - .font_family(mono_family.clone()) - .text_sm() - .text_color(muted) - .child(legacy_chevron.unwrap_or("")), - ) - .when(is_dir, |item| { - item.child( - div() - .w(px(16.0)) - .flex_shrink_0() - .font_family(mono_family.clone()) - .text_sm() - .text_color(if row.ignored { muted } else { theme.foreground }) - .child(crate::display::side_panel::folder_icon(row.expanded)), - ) - }) - .when_some(file_glyph, |item, glyph| { - item.child( - div() - .w(px(16.0)) - .flex_shrink_0() - .font_family(mono_family.clone()) - .text_sm() - .child(glyph), - ) - }) - .child( - div() - .flex_1() - .min_w_0() - .text_sm() - .overflow_hidden() - .whitespace_nowrap() - .child(row.name), - ) - .on_click(cx.listener(move |this, _, _, cx| { - if is_dir { - if this.side_panel.click_row(visible_ix) { - cx.notify(); - } - } else { - this.side_panel.selected = Some(path.clone()); - cx.notify(); - } - })) - .when(!is_dir && !is_parent, |item| { - item.on_double_click(cx.listener(move |this, _, window, cx| { - // 与旧壳 chrome 同合同:应用内能读的(图片/可读文本) - // 开查看 tab,其余交系统处理器。 - this.open_document_path(open_path.clone(), window, cx); - })) - }) - }); - - v_flex() - .h_full() - .w(px(320.0)) - .flex_shrink_0() - .my_2() - .mr_2() - .p_2() - .gap_2() - .rounded_lg() - .border_1() - .border_color(theme.border) - .bg(theme.popover) - .shadow_lg() - .occlude() - .child(view_switch) - .child( - h_flex() - .h(px(30.0)) - .items_center() - .gap_1() - .child( - div() - .flex_1() - .min_w_0() - .text_xs() - .text_color(muted) - .overflow_hidden() - .whitespace_nowrap() - .child(root), - ) - .child( - Button::new("file-tree-refresh") - .icon(IconName::Redo2) - .ghost() - .xsmall() - .tooltip("刷新目录树") - .on_click(cx.listener(|this, _, _, cx| { - this.side_panel.request_refresh(); - let cwd = this.active_local_cwd(cx); - this.side_panel.sync(cwd); - cx.notify(); - })), - ) - .child( - Button::new("file-tree-close") - .icon(IconName::Close) - .ghost() - .xsmall() - .tooltip("关闭目录树 (Ctrl+Shift+F)") - .on_click(cx.listener(|this, _, _, cx| { - this.toggle_file_tree(cx); - })), - ), - ) - .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() - .overflow_y_scrollbar() - .child(v_flex().w_full().gap_1().children(row_elements)), - ) - .into_any_element() - } - fn render_git_tree(&mut self, cx: &mut Context) -> gpui::AnyElement { let view_switch = self.render_side_panel_switch(cx).into_any_element(); let theme = cx.theme(); @@ -3258,9 +3252,7 @@ impl NebulaWorkspace { .min_w_0() .gap_1() .items_center() - .child( - div().flex_shrink_0().text_sm().child(file_name.clone()), - ) + .child(div().flex_shrink_0().text_sm().child(file_name.clone())) .when(!parent.is_empty(), |line| { line.child( div() @@ -3295,18 +3287,20 @@ impl NebulaWorkspace { 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(); - })), + .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| { @@ -3320,11 +3314,13 @@ impl NebulaWorkspace { .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(); - })), + .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| { @@ -3338,11 +3334,13 @@ impl NebulaWorkspace { .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.vcs_discard_confirm = None; + this.side_panel.git_unstage_path(&unstage_path); + cx.notify(); + }, + )), ) }) .on_click(cx.listener(move |this, _, _, cx| { @@ -3472,7 +3470,8 @@ impl NebulaWorkspace { .border_1() .border_color(theme.border) .bg(theme.popover) - .shadow_lg() + // 与文件树抽屉同一套紧凑投影,避免右侧抽屉语言再出现 shadow_lg。 + .shadow(gpui_component::popover_shadow(theme.is_dark())) .occlude() .child(view_switch) .when_some(summary, |panel, summary| panel.child(summary)) @@ -3568,50 +3567,79 @@ impl NebulaWorkspace { } } - /// 进入行内重命名:输入框预填当前显示名(自定义名优先,否则自动标签), - /// 焦点直接落进去。已在编辑别的行时先提交前一行。 + /// 进入行内重命名:对照旧壳 `TabRequest::BeginRename` + /// (`window_context.rs` ~854-868)。预填 `custom_name`,否则 + /// `chrome_tab_label`(cwd 末级,不含分屏后缀)。已在编辑别的行时丢掉 + /// 前一次缓冲(不提交),与旧壳覆盖 `nebula_tab_rename` 同合同。 fn begin_rename(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { if ix >= self.tabs.len() { return; } - self.commit_rename(window, cx); - let current = self - .meta(ix) - .custom_name - .unwrap_or_else(|| self.tab_title(ix, cx).to_string()); - let input = cx.new(|cx| InputState::new(window, cx).default_value(current)); + // 覆盖前一次编辑:只丢缓冲,不走 Commit(否则会把当时显示的目录名 + // 冻成 custom_name)。不要调用 `cancel_rename`——它会 `focus_active` + // 把焦点延迟抢回终端,紧接着的输入框 focus 会被下一帧冲掉。 + let _ = self.tab_rename.take(); + let current = self.meta(ix).custom_name.unwrap_or_else(|| self.rename_prefill(ix, cx)); + let input = cx.new(|cx| InputState::new(window, cx)); let subscription = cx.subscribe_in( &input, window, |this: &mut Self, _: &Entity, event: &InputEvent, window, cx| { match event { InputEvent::PressEnter { .. } => this.commit_rename(window, cx), - // 点走 = 提交(旧壳空串语义:清空即恢复自动名),不静默丢弃。 - InputEvent::Blur => this.commit_rename(window, cx), + // 点在框外 = CancelRename(`input/chrome.rs` ~359-368), + // 不是 Commit。Blur 提交会把自动目录名冻成 custom_name。 + InputEvent::Blur => this.cancel_rename(window, cx), _ => {}, } }, ); - input.read(cx).focus_handle(cx).focus(window); + // 旧壳 `nebula_tab_rename_select_all = true`:set_value 后全选再 focus。 + // `InputState::select_all` 是 `pub(super)`,对外走公开的 `SelectAll` action。 + input.update(cx, |state, cx| { + state.set_value(current, window, cx); + state.focus(window, cx); + }); self.tab_rename = Some(TabRename { ix, input, _subscription: subscription }); + cx.on_next_frame(window, |this, window, cx| { + let Some(rename) = this.tab_rename.as_ref() else { return }; + if !rename.input.read(cx).focus_handle(cx).is_focused(window) { + return; + } + window.dispatch_action(Box::new(gpui_component::input::SelectAll), cx); + }); cx.notify(); } + /// BeginRename 预填:有 custom 用 custom,否则终端用聚焦 pane 的 + /// `tab_label()`(cwd 末级,对齐 `chrome_tab_label`),其它 tab 用标题。 + fn rename_prefill(&self, ix: usize, cx: &App) -> String { + match self.tabs.get(ix) { + Some(tab @ WorkspaceTab::Terminal { .. }) => tab + .focused_view() + .map(|view| view.read(cx).tab_label()) + .unwrap_or_else(|| self.tab_title(ix, cx).to_string()), + _ => self.tab_title(ix, cx).to_string(), + } + } + fn commit_rename(&mut self, window: &mut Window, cx: &mut Context) { let Some(rename) = self.tab_rename.take() else { return }; - let name = rename.input.read(cx).value().trim().to_owned(); + let name = rename.input.read(cx).value(); if let Some(meta) = self.tab_meta.get_mut(rename.ix) { - meta.custom_name = (!name.is_empty()).then_some(name); + apply_commit_rename(meta, &name); } self.focus_active(window, cx); cx.notify(); } fn cancel_rename(&mut self, window: &mut Window, cx: &mut Context) { - if self.tab_rename.take().is_some() { - self.focus_active(window, cx); - cx.notify(); + let Some(rename) = self.tab_rename.take() else { return }; + if let Some(meta) = self.tab_meta.get_mut(rename.ix) { + apply_cancel_rename(meta); } + self.focus_active(window, cx); + cx.notify(); } /// 标签色标:同色再点一次即取消(旧壳菜单里选中的那枚色块 = 当前色)。 @@ -3622,6 +3650,15 @@ impl NebulaWorkspace { } } + /// 导出整个窗口为工作区文件(旧壳 `export_workspace(None)`)。 + fn export_workspace(&mut self, window: &mut Window, cx: &mut Context) { + let export = self.snapshot_session(cx); + if export.tabs.is_empty() { + return; + } + self.prompt_save_workspace(export, "workspace", window, cx); + } + /// 导出单个标签为工作区文件(旧壳 `export_workspace(Some(ix))` 同合同: /// 只导出可恢复的终端标签,文件名取标签名,扩展名 `.nebula-workspace.json`, /// 落盘走共享 v4 schema)。 @@ -3642,6 +3679,17 @@ impl NebulaWorkspace { tab.cwd.rsplit(['/', '\\']).find(|part| !part.is_empty()).map(str::to_owned) }) .unwrap_or_else(|| "tab".to_owned()); + let export = crate::session::Session::new(0, vec![tab]); + self.prompt_save_workspace(export, &stem, window, cx); + } + + fn prompt_save_workspace( + &self, + export: crate::session::Session, + stem: &str, + window: &mut Window, + cx: &mut Context, + ) { let stem: String = stem .chars() .map(|c| { @@ -3652,7 +3700,6 @@ impl NebulaWorkspace { } }) .collect(); - let export = crate::session::Session::new(0, vec![tab]); let directory = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); let prompt = cx.prompt_for_new_path(&directory, Some(&format!("{stem}.nebula-workspace.json"))); @@ -3669,8 +3716,6 @@ impl NebulaWorkspace { Err(error) => crate::gpui_shell::toast::toast( window, cx, - // 失败即时可重试(换个目录再来一次),不占消息栏的 - // "待办动作"层级——分三层的判据是有没有待办,不是严重度。 crate::display::ToastKind::Warning, format!("工作区导出失败:{error}"), ), @@ -3679,191 +3724,10 @@ impl NebulaWorkspace { .detach(); } - /// 标签行右键与三点按钮共用一份命令表,避免两个入口继续漂移。 - /// 条目、分组与键帽对齐旧壳 `display::context_menu` 的 Tab 目标。 - fn tab_popup_menu( - mut menu: PopupMenu, - workspace: gpui::WeakEntity, - ix: usize, - terminal: bool, - ai_fork: bool, - color: Option, - ) -> PopupMenu { - if ai_fork { - let target = workspace.clone(); - menu = menu.item(PopupMenuItem::new("分叉 AI 会话").icon(IconName::Copy).on_click( - move |_, window, cx| { - if let Some(workspace) = target.upgrade() { - workspace.update(cx, |workspace, cx| { - workspace.fork_ai_session(ix, window, cx); - }); - } - }, - )); - } - if terminal { - let duplicate = workspace.clone(); - let export = workspace.clone(); - let split_right = workspace.clone(); - let split_down = workspace.clone(); - menu = menu - .item(PopupMenuItem::new("复制标签页").icon(IconName::Copy).on_click( - move |_, window, cx| { - if let Some(workspace) = duplicate.upgrade() { - workspace.update(cx, |workspace, cx| { - workspace.duplicate_tab(ix, window, cx); - }); - } - }, - )) - .item(PopupMenuItem::new("导出为工作区…").icon(IconName::Inbox).on_click( - move |_, window, cx| { - if let Some(workspace) = export.upgrade() { - workspace.update(cx, |workspace, cx| { - workspace.export_tab(ix, window, cx); - }); - } - }, - )) - .separator() - // `action` 只用来渲染键帽:handler 存在时组件不会 dispatch - // 它(见 PopupMenu::confirm),所以命令仍然作用在 `ix` 上, - // 而不是"活动标签"——右键别的标签也不会打错对象。 - .item( - PopupMenuItem::new("左右分屏") - .icon(IconName::PanelRight) - .action(Box::new(SplitRight)) - .on_click(move |_, window, cx| { - if let Some(workspace) = split_right.upgrade() { - workspace.update(cx, |workspace, cx| { - workspace.activate_tab(ix, window, cx); - workspace - .split_focused(SplitDirection::LeftRight, window, cx); - }); - } - }), - ) - .item( - PopupMenuItem::new("上下分屏") - .icon(IconName::PanelBottom) - .action(Box::new(SplitDown)) - .on_click(move |_, window, cx| { - if let Some(workspace) = split_down.upgrade() { - workspace.update(cx, |workspace, cx| { - workspace.activate_tab(ix, window, cx); - workspace - .split_focused(SplitDirection::TopBottom, window, cx); - }); - } - }), - ); - } - let rename = workspace.clone(); - let close = workspace.clone(); - menu = menu - .separator() - .item( - PopupMenuItem::new("重命名") - .icon(IconName::ALargeSmall) - .action(Box::new(RenameActiveTab)) - .on_click(move |_, window, cx| { - if let Some(workspace) = rename.upgrade() { - workspace.update(cx, |workspace, cx| { - workspace.begin_rename(ix, window, cx); - }); - } - }), - ) - .item( - PopupMenuItem::new("关闭") - .icon(IconName::Close) - .action(Box::new(CloseActiveTerminal)) - .on_click(move |_, window, cx| { - if let Some(workspace) = close.upgrade() { - workspace.update(cx, |workspace, cx| { - workspace.request_close_tab(ix, window, cx); - }); - } - }), - ); - Self::tab_color_items(menu, workspace, ix, color) - } - - /// 标签颜色行(旧壳菜单尾部的色板):首槽 `A` = 无色,其后是 7 枚品牌 - /// 色。当前色带一圈选中环,再点一次同色即取消。 - /// - /// 整行是一个 `ElementItem`:色块自己吃 mouse_down 落色,外层的 click - /// 没有 handler,只负责收起菜单(见 `PopupMenu::confirm`)。 - fn tab_color_items( - menu: PopupMenu, - workspace: gpui::WeakEntity, - ix: usize, - current: Option, - ) -> PopupMenu { - menu.separator().item(PopupMenuItem::label("标签颜色")).item(PopupMenuItem::element( - move |_, cx| { - let swatches = std::iter::once(None) - .chain(crate::display::context_menu::TAB_COLORS.into_iter().map(Some)); - let mut row = h_flex().gap_1().py_1(); - for (slot, color) in swatches.enumerate() { - let selected = color == current; - let target = workspace.clone(); - // 无色槽用主题 accent 打底并压一个 "A",与旧壳一致: - // 它是"自动",不是第八种颜色。 - let fill = color - .map(|color| gpui::Rgba { - r: color.r as f32 / 255.0, - g: color.g as f32 / 255.0, - b: color.b as f32 / 255.0, - a: 1.0, - }) - .unwrap_or_else(|| cx.theme().primary.into()); - row = row.child( - div() - .id(("tab-color", slot)) - .size(px(20.0)) - .rounded(px(5.0)) - .flex() - .items_center() - .justify_center() - .bg(fill) - .cursor_pointer() - .when(selected, |swatch| { - swatch.border_2().border_color(cx.theme().foreground) - }) - .when(color.is_none(), |swatch| { - swatch - .text_size(px(11.0)) - .text_color(cx.theme().primary_foreground) - .child("A") - }) - .on_mouse_down( - MouseButton::Left, - move |_, _, cx| { - if let Some(workspace) = target.upgrade() { - workspace.update(cx, |workspace, cx| { - let next = if selected { None } else { color }; - workspace.set_tab_color(ix, next, cx); - }); - } - }, - ), - ); - } - row - }, - )) - } - /// 侧栏等宽标签的 cell 宽:与终端元素同一套度量法(塑形一个 "M" 取 /// advance),列数换算与省略号都建立在它上面。字体缺失时回落 0.6em, /// 只影响截断位置、不会画错。 - fn sidebar_cell_width( - &self, - window: &mut Window, - family: &SharedString, - size_px: f32, - ) -> f32 { + fn sidebar_cell_width(&self, window: &mut Window, family: &SharedString, size_px: f32) -> f32 { let shaped = window.text_system().shape_line( SharedString::new_static("M"), px(size_px), @@ -3924,10 +3788,7 @@ impl NebulaWorkspace { // `icons::push_spinner` 的几何一致。 window.paint_quad( gpui::fill( - Bounds::new( - gpui::point(px(x0), px(y0)), - size(px(stroke), px(stroke)), - ), + Bounds::new(gpui::point(px(x0), px(y0)), size(px(stroke), px(stroke))), c, ) .corner_radii(px(stroke * 0.5)), @@ -3980,12 +3841,19 @@ impl NebulaWorkspace { // 本次渲染里是否有「运行中」行(spinner 帧循环的开关)。 let items_running = std::cell::Cell::new(false); - let items = (0..self.tabs.len()).map(|ix| { + let (tabs_scroll, tabs_show) = if self.tabs_section_collapsed { + (0, self.tabs.len()) + } else { + self.tabs_visible_window() + }; + let items = (0..self.tabs.len()) + .filter(|&ix| tab_scroll::index_visible(ix, tabs_scroll, tabs_show)) + .map(|ix| { let active = ix == self.active; let title = self.tab_title(ix, cx); let is_settings = self.tabs[ix].is_settings(); let is_terminal = self.tabs[ix].is_terminal(); - let (program, activity, ai_fork) = self.tabs[ix] + let (program, activity) = self.tabs[ix] .focused_view() .map(|entity| { let view = entity.read(cx); @@ -3996,9 +3864,15 @@ impl NebulaWorkspace { view.ai_session.as_ref().map(|identity| identity.source.clone()) }) .or_else(|| view.ssh_destination.as_ref().map(|_| "ssh".to_owned())); - (program, view.sidebar_activity(), view.ai_fork_command().is_some()) + (program, view.sidebar_activity()) }) - .unwrap_or((None, SidebarActivity::Idle, false)); + .unwrap_or((None, SidebarActivity::Idle)); + let activity = if !active && self.meta(ix).has_bell && activity == SidebarActivity::Idle + { + SidebarActivity::Done + } else { + activity + }; // GPUI 自己持有同一组嵌入 PNG,不应被旧 OpenGL 渲染器的 `png` // feature 门控挡住;映射仍复用旧壳的 program 归一化规则。 let logo = program.as_deref().and_then(crate::display::ai_logo_for_program); @@ -4012,12 +3886,12 @@ impl NebulaWorkspace { // 文档/图片/代码 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), - ), + 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)) + }, _ => None, }); let workspace = cx.entity().downgrade(); @@ -4064,13 +3938,9 @@ impl NebulaWorkspace { }, // 回合完成、等下一条指令:旧壳蓝点语义——不转圈,留一个 // 「有结果没看」的痕迹。 - SidebarActivity::Done => Some( - div() - .size(px(6.0)) - .rounded_full() - .bg(theme.primary) - .into_any_element(), - ), + SidebarActivity::Done => { + Some(div().size(px(6.0)).rounded_full().bg(theme.primary).into_any_element()) + }, // 停在授权/提问上:比「完成」更强,必须换形状而不是换色 // (旧壳教训:两态共用圆点在界面上根本分不出来)。 SidebarActivity::Attention => Some( @@ -4148,6 +4018,18 @@ impl NebulaWorkspace { }); }), ) + .on_mouse_down( + MouseButton::Middle, + cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.request_close_tab(ix, window, cx); + }), + ) + .on_double_click(cx.listener(move |this, _, window, cx| { + // 旧壳 `ChromeHit::Tab` + DoubleClick → BeginRename。 + cx.stop_propagation(); + this.begin_rename(ix, window, cx); + })) .when_some(strip, |row, color| { row.child( div() @@ -4197,6 +4079,9 @@ impl NebulaWorkspace { Some(input) => div() .flex_1() .min_w_0() + .h_full() + .flex() + .items_center() .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { if event.keystroke.key == "escape" { @@ -4204,7 +4089,12 @@ impl NebulaWorkspace { this.cancel_rename(window, cx); } })) - .child(Input::new(&input).xsmall()) + .child( + Input::new(&input) + .w_full() + .text_size(px(label_px)) + .font_family(mono_family.clone()), + ) .into_any_element(), None => div() .flex_1() @@ -4258,15 +4148,8 @@ impl NebulaWorkspace { ), ), ) - .context_menu(move |menu, _window, _| { - Self::tab_popup_menu( - menu.external_link_icon(false), - context_workspace.clone(), - ix, - is_terminal, - ai_fork, - tab_color, - ) + .context_menu(move |menu, window, cx| { + Self::tab_context_menu(menu, context_workspace.clone(), ix, window, cx) }); if dragged { // 骑指针 + 提到最上层画(deferred 只延后绘制、不动布局), @@ -4292,7 +4175,8 @@ impl NebulaWorkspace { } else { row.into_any_element() } - }); + }) + .collect::>(); let header_group: SharedString = "sidebar-tabs-header-hover".into(); let count: SharedString = self.tabs.len().to_string().into(); @@ -4338,21 +4222,23 @@ impl NebulaWorkspace { .items_center() .pr_1() .child( - // 箭头与标题同一条 mono 文本 run(旧壳 codicon - // 字位 \u{eab4}/\u{eab6} + Regular),不用细线 SVG—— - // 保持旧壳的常规字重。 - div() + // 折叠三角用组件库线性 Chevron(lucide 细线), + // 不再用 Nerd Font 实心字位——后者在侧栏标题 + // 上偏重、和右侧 +/⋯ 的 SVG 不一套语言。 + h_flex() .w(px(tabs_disclosure_slot_w)) + .h_full() .flex_shrink_0() - .font_family(mono_family.clone()) - .text_size(px(label_px * SIDEBAR_TITLE_SCALE)) - .font_weight(FontWeight::NORMAL) - .text_color(muted) - .child(if self.tabs_section_collapsed { - "\u{eab6}" - } else { - "\u{eab4}" - }), + .items_center() + .child( + Icon::new(if self.tabs_section_collapsed { + IconName::ChevronRight + } else { + IconName::ChevronDown + }) + .xsmall() + .text_color(muted), + ), ) .child( div() @@ -4442,7 +4328,7 @@ impl NebulaWorkspace { ), ), ) - .child(self.render_tabs_section(items)); + .child(self.render_tabs_section(items, cx)); // spinner 帧循环(旧壳 motion frame 的对应物):有任何行在 // 「运行中」时推进相位;notify → 下一次 render 再续帧。 if items_running.get() { @@ -4450,8 +4336,7 @@ impl NebulaWorkspace { let now = std::time::Instant::now(); let dt = now - this.spinner_last; this.spinner_last = now; - this.spinner_phase = - (this.spinner_phase + dt.as_secs_f32() / 0.8).rem_euclid(1.0); + this.spinner_phase = (this.spinner_phase + dt.as_secs_f32() / 0.8).rem_euclid(1.0); cx.notify(); }); } @@ -4462,18 +4347,16 @@ impl NebulaWorkspace { /// ease-out 曲线,`overflow_hidden` 负责动画期间的裁剪,观感是卷帘而不是 /// 瞬间闪现。首次手动切换后才启用——启动帧必须静止落位。 /// - /// 内容高算得出来(`TAB_ROW_H` + `gap_2` 都是常量),不用等布局回写;插 - /// `max_h` 而不是 `h` 是为了保住列表原本的 `flex_1`:tab 多到超出可用空间 - /// 时上限不生效,压缩行为与折叠前一致。 - fn render_tabs_section(&self, items: I) -> gpui::AnyElement + /// 展开后列表走旧壳整行窗口 + 覆盖滚动条;折叠动画仍按全部行高度收放。 + fn render_tabs_section(&self, items: I, cx: &mut Context) -> gpui::AnyElement where I: IntoIterator, I::Item: IntoElement, { let collapsed = self.tabs_section_collapsed; - let list = v_flex().w_full().flex_1().gap_2().children(items); + let list = self.wrap_tabs_scroll_list(items.into_iter().map(|item| item.into_any_element()), cx); if !self.tabs_fold_armed { - return if collapsed { div().into_any_element() } else { list.into_any_element() }; + return if collapsed { div().into_any_element() } else { list }; } let rows = self.tabs.len().max(1) as f32; let content_h = rows * TAB_ROW_H + (rows - 1.0) * (TAB_ROW_PITCH - TAB_ROW_H); @@ -4833,21 +4716,20 @@ impl Render for NebulaWorkspace { && self.side_panel.view == crate::display::side_panel::PanelView::Files; let git_active = self.side_panel.open && self.side_panel.view == crate::display::side_panel::PanelView::Git; - let settings_active = matches!(self.tabs.get(self.active), Some(WorkspaceTab::Settings { .. })); + let settings_active = + matches!(self.tabs.get(self.active), Some(WorkspaceTab::Settings { .. })); // dock 预览:被拖 tab 悬于终端区时高亮目标半区(松手即挂到那侧)。 - let dock_preview = self - .tab_drag - .as_ref() - .filter(|drag| drag.active) - .and_then(|drag| drag.dock) - .and_then(|nav| { - self.active_terminal_area().map(|area| match nav { - SplitNav::Left => (area.x, area.y, area.w * 0.5, area.h), - SplitNav::Right => (area.x + area.w * 0.5, area.y, area.w * 0.5, area.h), - SplitNav::Up => (area.x, area.y, area.w, area.h * 0.5), - SplitNav::Down => (area.x, area.y + area.h * 0.5, area.w, area.h * 0.5), - }) - }); + let dock_preview = + self.tab_drag.as_ref().filter(|drag| drag.active).and_then(|drag| drag.dock).and_then( + |nav| { + self.active_terminal_area().map(|area| match nav { + SplitNav::Left => (area.x, area.y, area.w * 0.5, area.h), + SplitNav::Right => (area.x + area.w * 0.5, area.y, area.w * 0.5, area.h), + SplitNav::Up => (area.x, area.y, area.w, area.h * 0.5), + SplitNav::Down => (area.x, area.y + area.h * 0.5, area.w, area.h * 0.5), + }) + }, + ); div() .size_full() @@ -4925,6 +4807,36 @@ impl Render for NebulaWorkspace { .on_action(cx.listener(|this, _: &FocusPaneDown, window, cx| { this.navigate_pane(SplitNav::Down, window, cx); })) + .on_action(cx.listener(|this, _: &SelectNextTab, window, cx| { + this.select_adjacent_tab(true, window, cx); + })) + .on_action(cx.listener(|this, _: &SelectPreviousTab, window, cx| { + this.select_adjacent_tab(false, window, cx); + })) + .on_action(cx.listener(|this, _: &ToggleGitPanel, _, cx| { + this.toggle_git_tree(cx); + })) + .on_action(cx.listener(|this, _: &IncreaseFontSize, _, cx| { + this.bump_font_size(1.0, cx); + })) + .on_action(cx.listener(|this, _: &DecreaseFontSize, _, cx| { + this.bump_font_size(-1.0, cx); + })) + .on_action(cx.listener(|this, _: &ResetFontSize, _, cx| { + this.bump_font_size(0.0, cx); + })) + .on_action(cx.listener(|this, _: &CopySelection, window, cx| { + this.copy_focused_terminal(window, cx); + })) + .on_action(cx.listener(|this, _: &PasteClipboard, window, cx| { + this.paste_focused_terminal(window, cx); + })) + .on_action(cx.listener(|this, _: &ToggleFullscreen, window, _cx| { + window.toggle_fullscreen(); + })) + .on_action(cx.listener(|this, _: &OpenQuickJump, window, cx| { + this.open_ai_session_palette(window, cx); + })) .child( // 壁纸"铺满整窗"底层(chrome 之下):卡外区域由这层负责, // 卡内切片由终端元素在卡底色之上重画(旧壳同一层模型)。 @@ -5008,7 +4920,7 @@ impl Render for NebulaWorkspace { .icon(IconName::GitHub) .ghost() .selected(git_active) - .tooltip("Git 状态") + .tooltip("Git 状态 (Ctrl+Shift+G)") .on_click(cx.listener(|this, _, _, cx| { this.toggle_git_tree(cx); })), @@ -5131,6 +5043,7 @@ impl Render for NebulaWorkspace { ), ) }) + .children(self.tabs_scrollbar_drag_overlay(cx)) .when(self.sidebar_resizing, |root| { // 侧栏拖宽罩层(同 split_drag 的指针捕获模式):移动实时改宽 // (夹在共享层的 170..420 之间),松手写盘 `sidebar_w`。 @@ -5194,6 +5107,9 @@ impl Render for NebulaWorkspace { .when(self.command_palette_open, |root| { root.child(self.render_command_palette(cx)) }) + .when_some(self.render_file_tree_context_menu(), |root, menu| { + root.child(menu) + }) // 组件库的模态/通知层不会自己上屏:`Root::render` 只画宿主视图, // dialog/notification 两层由宿主显式挂。挂在最外层链尾=盖住命令 // 面板和所有拖拽罩层;dialog 在下、notification 在上,确认框弹着 @@ -5207,211 +5123,4 @@ impl Render for NebulaWorkspace { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ai_hook_routing_never_falls_back_for_a_stale_exact_pane() { - let pane_ids = [7u64, 11]; - assert_eq!(ai_hook_target_pane(&pane_ids, Some(11), Some(7)), Some(11)); - assert_eq!(ai_hook_target_pane(&pane_ids, Some(99), Some(7)), None); - assert_eq!(ai_hook_target_pane(&pane_ids, None, Some(7)), Some(7)); - assert_eq!(ai_hook_target_pane(&pane_ids, None, None), None); - } - - #[test] - fn restored_agent_command_honors_resume_ai_without_changing_session_data() { - let agent = crate::session::AgentSession { - source: "claude".to_owned(), - session_id: Some("session-42".to_owned()), - }; - - assert_eq!( - restored_agent_command(true, Some(&agent)), - Some("claude --resume session-42".to_owned()) - ); - assert_eq!(restored_agent_command(false, Some(&agent)), None); - assert_eq!(restored_agent_command(true, None), None); - } - - #[test] - fn new_tab_position_uses_the_shared_runtime_semantics() { - use nebula_settings::NewTabPositionName::{AfterCurrent, End}; - - assert_eq!(new_tab_insert_index(AfterCurrent, 1, 4), 2); - assert_eq!(new_tab_insert_index(End, 1, 4), 4); - assert_eq!(new_tab_insert_index(AfterCurrent, 9, 2), 2); - assert_eq!(new_tab_insert_index(AfterCurrent, 0, 0), 0); - } - - #[test] - fn default_launch_tag_does_not_follow_later_default_shell_changes() { - 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("ps".to_owned()) - ); - } - - /// 分屏生命周期合同的树侧不变式:split 后叶集扩张、关到最后一个叶 - /// 判 WasRoot(宿主关整 tab)、中途摘叶塌缩并移交焦点。 - #[test] - fn split_tree_lifecycle_matches_pane_contract() { - let mut tree = SplitTree::leaf(1u64); - assert!(tree.split_leaf(1, 2, SplitDirection::LeftRight, 0.5)); - assert!(tree.split_leaf(2, 3, SplitDirection::TopBottom, 0.5)); - assert_eq!(tree.leaves(), vec![1, 2, 3]); - - // 摘中间叶:父节点塌缩,焦点移交幸存子树首叶。 - assert_eq!(tree.remove_leaf(2), RemoveOutcome::Collapsed(3)); - assert_eq!(tree.leaves(), vec![1, 3]); - // 摘到只剩一个:WasRoot=宿主应关整个 tab,树不再可用。 - assert_eq!(tree.remove_leaf(1), RemoveOutcome::Collapsed(3)); - assert_eq!(tree.remove_leaf(3), RemoveOutcome::WasRoot); - // 不存在的叶子不产生副作用。 - let mut single = SplitTree::leaf(9u64); - assert_eq!(single.remove_leaf(4), RemoveOutcome::NotFound); - } - - #[test] - fn dock_tree_places_the_source_tree_on_the_nav_side() { - let docked = dock_tree(SplitTree::leaf(1u64), SplitTree::leaf(9u64), SplitNav::Left); - assert_eq!(docked.leaves(), vec![9, 1], "Left:source 树进 first 槽"); - match &docked { - SplitTree::Split { direction: SplitDirection::LeftRight, ratio, .. } => { - assert!((ratio - 0.5).abs() < f32::EPSILON, "dock 恒为 50/50 根分割"); - }, - _ => panic!("dock 应产生根级左右分割"), - } - - // 多叶源树整树挂入,叶序保持源树内部顺序。 - let mut source = SplitTree::leaf(7u64); - assert!(source.split_leaf(7, 8, SplitDirection::TopBottom, 0.5)); - let docked = dock_tree(SplitTree::leaf(1u64), source, SplitNav::Down); - assert_eq!(docked.leaves(), vec![1, 7, 8], "Down:source 树进 second 槽"); - match &docked { - SplitTree::Split { direction: SplitDirection::TopBottom, .. } => {}, - _ => panic!("Down 应产生上下分割"), - } - } - - #[test] - fn cwd_palette_actions_are_available_only_for_local_terminal_tabs() { - use crate::display::command_palette::PaletteAction; - - assert!(!NebulaWorkspace::palette_action_available(&PaletteAction::CopyCwd, false,)); - assert!(!NebulaWorkspace::palette_action_available(&PaletteAction::RevealCwd, false,)); - assert!(NebulaWorkspace::palette_action_available(&PaletteAction::CopyCwd, true,)); - assert!(NebulaWorkspace::palette_action_available(&PaletteAction::RevealCwd, true,)); - assert!(NebulaWorkspace::palette_action_available(&PaletteAction::NewTab, false)); - } - - /// 新建终端弹窗:默认 shell 必须占首行,因为「开弹窗 → 回车」是旧壳 - /// 点 "+" 后最常走的一条路,它必须等于「开一个默认终端」。 - #[test] - 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", - "Fix resize", - ), - crate::ai_sessions::AiSession::test_session( - crate::ai_agents::AgentKind::Aider, - "aider-42", - "Unsupported", - ), - ]; - let rows = ai_session_palette_rows(sessions); - assert_eq!(rows.len(), 2, "Claude supports resume and fork; Aider supports neither"); - assert!(matches!( - &rows[0].action, - WorkspacePaletteAction::RunAiSession { command, .. } - if command == "claude --resume claude-42" - )); - assert!(matches!( - &rows[1].action, - WorkspacePaletteAction::RunAiSession { command, .. } - if command == "claude --resume claude-42 --fork-session" - )); - } -} +mod tests; diff --git a/nebula_app/src/gpui_shell/workspace/file_tree.rs b/nebula_app/src/gpui_shell/workspace/file_tree.rs new file mode 100644 index 0000000..22908a3 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/file_tree.rs @@ -0,0 +1,374 @@ +//! 侧栏文件树渲染(从 workspace.rs 拆出以守行数预算)。 + +use std::path::PathBuf; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + ClipboardItem, Context, Corner, DismissEvent, Entity, Focusable as _, InteractiveElement as _, + IntoElement as _, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Point, SharedString, + StatefulInteractiveElement as _, Styled as _, Subscription, Window, anchored, deferred, div, + px, +}; +use gpui_component::menu::PopupMenuItem; + +use crate::gpui_shell::prelude::*; + +use super::NebulaWorkspace; + +/// 文件树右键菜单的宿主:必须挂在 workspace 根上,不能当抽屉行的 child。 +/// +/// `ContextMenuExt` 会把 `deferred(anchored(PopupMenu))` 挂回触发行;行在带 +/// `shadow` 的抽屉里面。菜单翻到抽屉左缘时,抽屉投影会垫在菜单周围,看起来 +/// 比侧栏 Tab 右键厚一截。 +pub(super) struct FileTreeContextMenu { + menu: Entity, + position: Point, + _subscription: Subscription, +} + +impl NebulaWorkspace { + pub(super) fn render_file_tree(&mut self, cx: &mut Context) -> gpui::AnyElement { + let view_switch = self.render_side_panel_switch(cx).into_any_element(); + let theme = cx.theme(); + let muted = theme.muted_foreground; + let hover = theme.list_hover; + let selected_bg = theme.list_active; + let mono_family: SharedString = cx + .try_global::() + .map(|settings| settings.font_family.clone()) + .unwrap_or_else(|| String::from("Maple Mono Normal NF CN")) + .into(); + let selected_path = self.side_panel.selected.clone(); + let scroll = self.side_panel.scroll; + let root = self + .side_panel + .root() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "等待终端上报工作目录…".to_owned()); + let rows: Vec<_> = self.side_panel.file_rows().iter().skip(scroll).cloned().collect(); + + let row_elements = rows.into_iter().enumerate().map(|(visible_ix, row)| { + let path = row.path.clone(); + let open_path = path.clone(); + let menu_path = path.clone(); + let is_dir = row.is_dir; + let is_parent = row.is_parent; + let selected = selected_path.as_ref() == Some(&path); + let fg = if row.ignored { muted } else { theme.foreground }; + let _chevron = if row.is_dir && !row.is_parent { + if row.expanded { "⌄" } else { "›" } + } else { + "" + }; + let file_glyph = + (!row.is_dir).then(|| crate::display::side_panel::file_type_icon(&row.name)); + let legacy_chevron = row + .is_dir + .then(|| { + (!row.is_parent).then(|| crate::display::side_panel::chevron_icon(row.expanded)) + }) + .flatten(); + + h_flex() + .id(SharedString::from(format!("file-tree-row-{visible_ix}"))) + .h(px(30.0)) + .w_full() + .items_center() + .pr_2() + .pl(px(8.0 + row.depth as f32 * 16.0)) + .gap_1() + .rounded_md() + .text_color(fg) + .when(selected, |item| item.bg(selected_bg)) + .hover(|item| item.bg(hover)) + .child( + div() + .w(px(12.0)) + .flex_shrink_0() + .font_family(mono_family.clone()) + .text_sm() + .text_color(muted) + .child(legacy_chevron.unwrap_or("")), + ) + .when(is_dir, |item| { + item.child( + div() + .w(px(16.0)) + .flex_shrink_0() + .font_family(mono_family.clone()) + .text_sm() + .text_color(if row.ignored { muted } else { theme.foreground }) + .child(crate::display::side_panel::folder_icon(row.expanded)), + ) + }) + .when_some(file_glyph, |item, glyph| { + item.child( + div() + .w(px(16.0)) + .flex_shrink_0() + .font_family(mono_family.clone()) + .text_sm() + .child(glyph), + ) + }) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .overflow_hidden() + .whitespace_nowrap() + .child(row.name), + ) + .on_click(cx.listener(move |this, _, _, cx| { + if is_dir { + if this.side_panel.click_row(visible_ix) { + cx.notify(); + } + } else { + this.side_panel.selected = Some(path.clone()); + cx.notify(); + } + })) + .when(!is_dir && !is_parent, |item| { + item.on_double_click(cx.listener(move |this, _, window, cx| { + // 与旧壳 chrome 同合同:应用内能读的(图片/可读文本) + // 开查看 tab,其余交系统处理器。 + this.open_document_path(open_path.clone(), window, cx); + })) + }) + .when(!is_parent, |item| { + // 不在抽屉行上挂 `.context_menu()`:那会把 PopupMenu 挂回 + // 行的 child,菜单仍是带投影抽屉的子孙。右键只记锚点, + // 菜单由 workspace 根上的 `deferred(anchored)` 画。 + item.on_mouse_down( + MouseButton::Right, + cx.listener(move |this, event: &MouseDownEvent, window, cx| { + cx.stop_propagation(); + this.open_file_tree_context_menu( + menu_path.clone(), + is_dir, + event.position, + window, + cx, + ); + }), + ) + }) + }); + + v_flex() + .h_full() + .w(px(320.0)) + .flex_shrink_0() + .my_2() + .mr_2() + .p_2() + .gap_2() + .rounded_lg() + .border_1() + .border_color(theme.border) + .bg(theme.popover) + // 抽屉在窗口右侧;右键菜单常翻到左缘。Tailwind `shadow_lg` + //(10px 下偏移 + 15px 模糊,再加一层)会垫在菜单周围,比 Tab + // 右键的 `popover_shadow` 厚一截。抽屉本身也是 popover 面,跟 + // 菜单用同一套紧凑投影。 + .shadow(gpui_component::popover_shadow(theme.is_dark())) + .occlude() + .child(view_switch) + .child( + h_flex() + .h(px(30.0)) + .items_center() + .gap_1() + .child( + div() + .flex_1() + .min_w_0() + .text_xs() + .text_color(muted) + .overflow_hidden() + .whitespace_nowrap() + .child(root), + ) + .child( + Button::new("file-tree-refresh") + .icon(IconName::Redo2) + .ghost() + .xsmall() + .tooltip("刷新目录树") + .on_click(cx.listener(|this, _, _, cx| { + this.side_panel.request_refresh(); + let cwd = this.active_local_cwd(cx); + this.side_panel.sync(cwd); + cx.notify(); + })), + ) + .child( + Button::new("file-tree-close") + .icon(IconName::Close) + .ghost() + .xsmall() + .tooltip("关闭目录树 (Ctrl+Shift+F)") + .on_click(cx.listener(|this, _, _, cx| { + this.toggle_file_tree(cx); + })), + ), + ) + .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() + .overflow_y_scrollbar() + .child(v_flex().w_full().gap_1().children(row_elements)), + ) + .into_any_element() + } + + fn open_file_tree_context_menu( + &mut self, + path: PathBuf, + is_dir: bool, + position: Point, + window: &mut Window, + cx: &mut Context, + ) { + self.side_panel.selected = Some(path.clone()); + let workspace = cx.entity().downgrade(); + let menu = PopupMenu::build(window, cx, move |menu, window, _cx| { + file_tree_popup_menu(menu.external_link_icon(false), workspace, path, is_dir, window) + }); + menu.focus_handle(cx).focus(window); + let subscription = cx.subscribe_in(&menu, window, |this, _, _: &DismissEvent, _, cx| { + this.file_tree_menu = None; + cx.notify(); + }); + self.file_tree_menu = + Some(FileTreeContextMenu { menu, position, _subscription: subscription }); + cx.notify(); + } + + pub(super) fn render_file_tree_context_menu(&self) -> Option { + let state = self.file_tree_menu.as_ref()?; + Some( + deferred( + anchored() + .position(state.position) + .snap_to_window_with_margin(px(8.0)) + .anchor(Corner::TopLeft) + .child(state.menu.clone()), + ) + .with_priority(1) + .into_any_element(), + ) + } + + fn request_delete_file_tree_path( + &mut self, + path: PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + if !path.exists() { + self.side_panel.set_notice("路径已不存在".to_owned()); + cx.notify(); + return; + } + let is_dir = path.is_dir(); + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + let title: SharedString = + format!("删除 {}?", crate::display::truncate_tab_label(&name, 28)).into(); + let body: SharedString = if is_dir { + "文件夹及其全部内容会移入回收站。".into() + } else { + "文件会移入回收站。".into() + }; + let workspace = cx.entity().downgrade(); + window.open_dialog(cx, move |dialog, window, _cx| { + let workspace = workspace.clone(); + let path = path.clone(); + center_confirm_dialog(dialog, window) + .title(title.clone()) + .confirm() + .button_props( + DialogButtonProps::default() + .ok_text("删除") + .ok_variant(gpui_component::button::ButtonVariant::Danger) + .cancel_text("取消"), + ) + .child(body.clone()) + .on_ok(move |_, _, cx| { + let _ = workspace.update(cx, |this, cx| { + match crate::display::send_to_recycle_bin(&path) { + Ok(()) => { + this.side_panel.request_refresh(); + let cwd = this.active_local_cwd(cx); + this.side_panel.sync(cwd); + }, + Err(error) => this.side_panel.set_notice(format!("删除失败:{error}")), + } + cx.notify(); + }); + true + }) + }); + } +} + +fn file_tree_popup_menu( + menu: PopupMenu, + workspace: gpui::WeakEntity, + path: PathBuf, + is_dir: bool, + _window: &mut Window, +) -> PopupMenu { + let first = if is_dir { + let open_here = workspace.clone(); + let dir = path.clone(); + PopupMenuItem::new("在此处打开终端").icon(IconName::SquareTerminal).on_click( + move |_, window, cx| { + if let Some(workspace) = open_here.upgrade() { + workspace.update(cx, |this, cx| { + this.add_terminal_at(Some(dir.clone()), None, window, cx); + }); + } + }, + ) + } else { + let open = workspace.clone(); + let file = path.clone(); + PopupMenuItem::new("打开").icon(IconName::File).on_click(move |_, window, cx| { + if let Some(workspace) = open.upgrade() { + workspace.update(cx, |this, cx| { + this.open_document_path(file.clone(), window, cx); + }); + } + }) + }; + let reveal_path = path.clone(); + let copy_path = path.clone(); + let delete = workspace; + menu.item(first) + .item(PopupMenuItem::new("在资源管理器中显示").icon(IconName::FolderOpen).on_click( + move |_, _, _| { + super::open_in_file_manager(&reveal_path); + }, + )) + .item(PopupMenuItem::new("复制路径").icon(IconName::Copy).on_click(move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(copy_path.display().to_string())); + })) + .separator() + .item(PopupMenuItem::new("删除").icon(IconName::Delete).on_click(move |_, window, cx| { + if let Some(workspace) = delete.upgrade() { + workspace.update(cx, |this, cx| { + this.request_delete_file_tree_path(path.clone(), window, cx); + }); + } + })) +} diff --git a/nebula_app/src/gpui_shell/workspace/key_actions.rs b/nebula_app/src/gpui_shell/workspace/key_actions.rs new file mode 100644 index 0000000..2a17d81 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/key_actions.rs @@ -0,0 +1,52 @@ +//! Workspace 快捷键动作实现(从 `workspace.rs` 拆出以守行数预算)。 + +use gpui::{Context, Window}; + +use super::{NebulaWorkspace, WorkspaceTab}; + +impl NebulaWorkspace { + pub(super) fn select_adjacent_tab( + &mut self, + next: bool, + window: &mut Window, + cx: &mut Context, + ) { + let len = self.tabs.len(); + if len == 0 { + return; + } + let ix = if next { (self.active + 1) % len } else { (self.active + len - 1) % len }; + self.activate_tab(ix, window, cx); + } + + /// 设置步进同源:逻辑 px ±1,钳 4–64;`delta == 0` 回到 toml 基准字号。 + pub(super) fn bump_font_size(&mut self, delta: f32, cx: &mut Context) { + let settings = cx.try_global::(); + let current = settings.map(|s| s.font_size_px).unwrap_or(15.0); + let next = if delta == 0.0 { + settings.map(|s| s.base_font_size_px).unwrap_or(15.0) + } else { + (current.round() + delta).clamp(4.0, 64.0) + }; + if (next - current).abs() < f32::EPSILON { + return; + } + if let Err(err) = nebula_settings::persist_keys(&[("font_size", format!("{next:.2}"))]) { + eprintln!("[nebula:gpui] failed to persist font size: {err}"); + return; + } + self.apply_runtime_settings(cx); + } + + pub(super) fn copy_focused_terminal(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(view) = self.tabs.get(self.active).and_then(WorkspaceTab::focused_view) { + view.update(cx, |view, cx| view.copy_selection(true, window, cx)); + } + } + + pub(super) fn paste_focused_terminal(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(view) = self.tabs.get(self.active).and_then(WorkspaceTab::focused_view) { + view.update(cx, |view, cx| view.paste(window, cx)); + } + } +} diff --git a/nebula_app/src/gpui_shell/workspace/residency.rs b/nebula_app/src/gpui_shell/workspace/residency.rs new file mode 100644 index 0000000..f2a5292 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/residency.rs @@ -0,0 +1,279 @@ +//! GPUI 托盘与关窗驻留:旧壳 `tray::` / `keep_session` detach 的对应物。 +//! +//! 旧壳关窗是 `detach_panes`(PTY 仍在进程里)再销毁 HWND;ATTACH 时再 +//! `open_window` 把 pane 接回去。GPUI 用 **hide + mux ATTACH reveal** 对齐 +//! 用户可见行为:关窗不杀 PTY,第二次启动回到同一套会话。 + +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use gpui::{App, Context, Window}; +use serde_json::json; + +use super::{NebulaWorkspace, SidebarActivity, WorkspaceTab}; +use crate::gpui_shell::GpuiShellEvent; +use crate::runtime_api::{ApiError, RuntimeCommand, RuntimeDispatch}; + +/// 关窗处置。`tray` 故意不参与:旧壳无托盘也会 detach,GPUI 一律 hide, +/// 禁止「没托盘就 minimize」。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ResidencyCloseAction { + Hide, + Close, +} + +pub(super) fn residency_close_action( + keep_session: bool, + has_live_panes: bool, + tray: bool, +) -> ResidencyCloseAction { + let _ = tray; + if keep_session && has_live_panes { + ResidencyCloseAction::Hide + } else { + ResidencyCloseAction::Close + } +} + +impl NebulaWorkspace { + pub(super) fn start_shell_event_pump( + rx: std::sync::mpsc::Receiver, + cx: &mut Context, + ) { + let executor = cx.background_executor().clone(); + cx.spawn(async move |this, cx| { + loop { + executor.timer(Duration::from_millis(120)).await; + let mut events = Vec::new(); + while let Ok(event) = rx.try_recv() { + events.push(event); + } + if events.is_empty() { + continue; + } + + let handle = match this.update(cx, |workspace, cx| { + workspace.dispatch_shell_events(&events, cx); + workspace.window_handle + }) { + Ok(handle) => handle, + Err(_) => return, + }; + + let _ = handle.update(cx, |_, window, cx| { + let _ = this.update(cx, |workspace, cx| { + workspace.drain_runtime_commands(window, cx); + }); + }); + } + }) + .detach(); + } + + fn dispatch_shell_events(&mut self, events: &[GpuiShellEvent], cx: &mut Context) { + for event in events { + match event { + GpuiShellEvent::TrayFocus(pane) => self.handle_tray_focus(*pane, cx), + GpuiShellEvent::TrayQuit => { + self.quit_from_tray(cx); + return; + }, + GpuiShellEvent::MuxAttach => self.reveal_session(cx), + GpuiShellEvent::RuntimeControl(dispatch) => { + self.queue_or_answer_runtime(dispatch.clone(), cx); + }, + } + } + } + + fn queue_or_answer_runtime( + &mut self, + dispatch: Arc, + cx: &mut Context, + ) { + match &dispatch.command { + RuntimeCommand::NewTab { .. } => { + self.reveal_session(cx); + self.runtime_pending.push(dispatch); + }, + RuntimeCommand::Focus { pane_id, .. } => { + self.handle_tray_focus(*pane_id, cx); + dispatch.respond(Ok(json!({ "window_id": 1, "pane_id": pane_id }))); + }, + RuntimeCommand::NewWindow => { + self.reveal_session(cx); + dispatch.respond(Ok(json!({ "window_id": 1 }))); + }, + RuntimeCommand::Snapshot => { + dispatch.respond(Ok(json!({ + "revision": 0, + "process_id": std::process::id(), + "detached_windows": if self.window_hidden { 1 } else { 0 }, + "windows": [] + }))); + }, + RuntimeCommand::Split { .. } | RuntimeCommand::Prompt { .. } => { + dispatch.respond(Err(ApiError::new( + "method_not_found", + "GPUI shell does not implement this runtime method", + ))); + }, + } + } + + fn drain_runtime_commands(&mut self, window: &mut Window, cx: &mut Context) { + let pending = std::mem::take(&mut self.runtime_pending); + for dispatch in pending { + let RuntimeCommand::NewTab { cwd, .. } = &dispatch.command else { + dispatch.respond(Err(ApiError::new( + "runtime_unavailable", + "queued runtime command was not tab.new", + ))); + continue; + }; + self.add_terminal_at(cwd.clone(), None, window, cx); + let pane_id = match self.tabs.get(self.active) { + Some(WorkspaceTab::Terminal { focused, .. }) => *focused, + _ => 0, + }; + dispatch.respond(Ok(json!({ "window_id": 1, "pane_id": pane_id }))); + } + } + + fn handle_tray_focus(&mut self, pane: Option, cx: &mut Context) { + self.reveal_session(cx); + if let Some(pane_id) = pane + && let Some(tab_ix) = self.tab_of_pane(pane_id) + { + self.active = tab_ix; + if let Some(WorkspaceTab::Terminal { focused, .. }) = self.tabs.get_mut(tab_ix) { + *focused = pane_id; + } + } + cx.notify(); + } + + fn reveal_session(&mut self, cx: &mut Context) { + crate::gpui_shell::reveal_all_windows(cx); + self.window_hidden = false; + } + + fn quit_from_tray(&mut self, cx: &mut Context) { + self.save_clean_window_session(cx); + for tab in &self.tabs { + let WorkspaceTab::Terminal { panes, .. } = tab else { continue }; + for pane in panes { + pane.view.read(cx).shutdown(); + } + } + crate::tray::shutdown(); + cx.quit(); + } + + fn tab_of_pane(&self, pane_id: u64) -> Option { + self.tabs.iter().position(|tab| match tab { + WorkspaceTab::Terminal { panes, .. } => panes.iter().any(|pane| pane.id == pane_id), + _ => false, + }) + } + + pub(super) fn reveal_if_tray_disabled(&mut self, cx: &mut Context) { + if self.window_hidden && !nebula_settings::RuntimeSettings::load().tray { + self.reveal_session(cx); + } + } + + /// 旧壳 detach:关窗不杀 PTY、不弹忙进程确认。GPUI 用 hide 代替拆 pane。 + pub(super) fn keep_session_on_close( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let runtime = nebula_settings::RuntimeSettings::load(); + if residency_close_action( + runtime.keep_session, + self.has_live_terminal_panes(), + runtime.tray, + ) != ResidencyCloseAction::Hide + { + return false; + } + let snapshot = self.snapshot_session(cx); + crate::session::save(&snapshot); + self.last_saved_session = Some(snapshot); + crate::gpui_shell::hide_native_window(window); + self.window_hidden = true; + true + } + + fn has_live_terminal_panes(&self) -> bool { + self.tabs + .iter() + .any(|tab| matches!(tab, WorkspaceTab::Terminal { panes, .. } if !panes.is_empty())) + } + + pub(super) fn publish_tray_agents(&self, cx: &App) { + crate::tray::update(self.tray_agents(cx)); + } + + fn tray_agents(&self, cx: &App) -> Vec { + let window = winit::window::WindowId::dummy(); + let mut agents = Vec::new(); + for tab in &self.tabs { + let WorkspaceTab::Terminal { panes, .. } = tab else { continue }; + for pane in panes { + let view = pane.view.read(cx); + let Some(program) = view + .running_program + .as_deref() + .filter(|program| crate::ai_agents::AgentKind::parse(program).is_some()) + else { + continue; + }; + let place = Path::new(view.cwd.trim()) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + let label = if place.is_empty() { + program.to_owned() + } else { + format!("{program} · {place}") + }; + agents.push(crate::tray::TrayAgent { + window, + pane: pane.id, + label, + needs_attention: view.sidebar_activity() == SidebarActivity::Attention, + }); + } + } + agents + } +} + +#[cfg(test)] +mod tests { + use super::{residency_close_action, ResidencyCloseAction}; + + #[test] + fn keep_session_hides_even_when_tray_is_off() { + assert_eq!( + residency_close_action(true, true, false), + ResidencyCloseAction::Hide, + "tray=false must still hide, never minimize" + ); + assert_eq!( + residency_close_action(true, true, true), + ResidencyCloseAction::Hide + ); + assert_eq!( + residency_close_action(false, true, false), + ResidencyCloseAction::Close + ); + assert_eq!( + residency_close_action(true, false, true), + ResidencyCloseAction::Close + ); + } +} diff --git a/nebula_app/src/gpui_shell/workspace/tab_menu.rs b/nebula_app/src/gpui_shell/workspace/tab_menu.rs new file mode 100644 index 0000000..4e80dc3 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/tab_menu.rs @@ -0,0 +1,264 @@ +//! 侧栏标签右键菜单(旧壳 `display::context_menu` Tab 目标)。 +//! +//! 「分叉 AI 会话」必须在菜单打开当下按 hook 身份 + 官方 fork 语法重算, +//! 不能吃侧栏上一帧的快照——否则刚接到 session_id、或冷恢复种下身份后 +//! 右键仍看不到这一行。 + +use gpui::{ + App, InteractiveElement as _, MouseButton, ParentElement as _, Styled as _, Window, div, + prelude::FluentBuilder as _, px, +}; +use gpui_component::menu::PopupMenuItem; + +use crate::display::color::Rgb; +use crate::gpui_shell::prelude::*; +use crate::session::LaunchSession; +use nebula_split::SplitDirection; + +use super::{ + CloseActiveTerminal, NebulaWorkspace, RenameActiveTab, SplitDown, SplitRight, WorkspaceTab, +}; + +/// 旧壳 `sync_chrome_tabs`:只有 Default / 检测 Shell 的 tab 才允许分叉。 +/// Profile 可能直接启动 agent;SSH 会把命令打进认证提示。 +pub(super) fn tab_launch_allows_ai_fork(launch: Option<&LaunchSession>) -> bool { + matches!(launch, None | Some(LaunchSession::Default) | Some(LaunchSession::Shell { .. })) +} + +pub(super) fn tab_ai_fork_enabled(workspace: &NebulaWorkspace, ix: usize, cx: &App) -> bool { + if !tab_launch_allows_ai_fork(workspace.meta(ix).launch.as_ref()) { + return false; + } + workspace + .tabs + .get(ix) + .and_then(WorkspaceTab::focused_view) + .and_then(|view| view.read(cx).ai_fork_command()) + .is_some() +} + +impl NebulaWorkspace { + /// 右键打开当下现查 fork 资格,并先选中该行(旧壳 chrome 同惯例)。 + pub(super) fn tab_context_menu( + menu: PopupMenu, + workspace: gpui::WeakEntity, + ix: usize, + window: &mut Window, + cx: &mut App, + ) -> PopupMenu { + let (terminal, ai_fork, color) = workspace + .upgrade() + .map(|entity| { + entity.update(cx, |this, cx| { + if ix < this.tabs.len() { + this.activate_tab(ix, window, cx); + } + ( + this.tabs.get(ix).is_some_and(WorkspaceTab::is_terminal), + tab_ai_fork_enabled(this, ix, cx), + this.meta(ix).color, + ) + }) + }) + .unwrap_or((false, false, None)); + Self::tab_popup_menu( + menu.external_link_icon(false), + workspace, + ix, + terminal, + ai_fork, + color, + ) + } + + /// 标签行右键命令表。条目、分组与键帽对齐旧壳 Tab 目标。 + fn tab_popup_menu( + mut menu: PopupMenu, + workspace: gpui::WeakEntity, + ix: usize, + terminal: bool, + ai_fork: bool, + color: Option, + ) -> PopupMenu { + if ai_fork { + let target = workspace.clone(); + menu = menu + .item(PopupMenuItem::new("分叉 AI 会话").icon(IconName::Bot).on_click( + move |_, window, cx| { + if let Some(workspace) = target.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.fork_ai_session(ix, window, cx); + }); + } + }, + )) + .separator(); + } + if terminal { + let duplicate = workspace.clone(); + let export = workspace.clone(); + let split_right = workspace.clone(); + let split_down = workspace.clone(); + menu = menu + .item(PopupMenuItem::new("复制标签页").icon(IconName::Copy).on_click( + move |_, window, cx| { + if let Some(workspace) = duplicate.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.duplicate_tab(ix, window, cx); + }); + } + }, + )) + .item(PopupMenuItem::new("导出为工作区…").icon(IconName::Inbox).on_click( + move |_, window, cx| { + if let Some(workspace) = export.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.export_tab(ix, window, cx); + }); + } + }, + )) + .separator() + // `action` 只用来渲染键帽:handler 存在时组件不会 dispatch + // 它(见 PopupMenu::confirm),所以命令仍然作用在 `ix` 上。 + .item( + PopupMenuItem::new("左右分屏") + .icon(IconName::PanelRight) + .action(Box::new(SplitRight)) + .on_click(move |_, window, cx| { + if let Some(workspace) = split_right.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.activate_tab(ix, window, cx); + workspace.split_focused(SplitDirection::LeftRight, window, cx); + }); + } + }), + ) + .item( + PopupMenuItem::new("上下分屏") + .icon(IconName::PanelBottom) + .action(Box::new(SplitDown)) + .on_click(move |_, window, cx| { + if let Some(workspace) = split_down.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.activate_tab(ix, window, cx); + workspace.split_focused(SplitDirection::TopBottom, window, cx); + }); + } + }), + ); + } + let rename = workspace.clone(); + let close = workspace.clone(); + menu = menu + .separator() + .item( + PopupMenuItem::new("重命名") + .icon(IconName::ALargeSmall) + .action(Box::new(RenameActiveTab)) + .on_click(move |_, window, cx| { + if let Some(workspace) = rename.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.begin_rename(ix, window, cx); + }); + } + }), + ) + .item( + PopupMenuItem::new("关闭") + .icon(IconName::Close) + .action(Box::new(CloseActiveTerminal)) + .on_click(move |_, window, cx| { + if let Some(workspace) = close.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.request_close_tab(ix, window, cx); + }); + } + }), + ); + Self::tab_color_items(menu, workspace, ix, color) + } + + /// 标签颜色行(旧壳菜单尾部的色板):首槽 `A` = 无色,其后是 7 枚品牌 + /// 色。当前色带一圈选中环,再点一次同色即取消。 + fn tab_color_items( + menu: PopupMenu, + workspace: gpui::WeakEntity, + ix: usize, + current: Option, + ) -> PopupMenu { + menu.separator().item(PopupMenuItem::label("标签颜色")).item(PopupMenuItem::element( + move |_, cx| { + let swatches = std::iter::once(None) + .chain(crate::display::context_menu::TAB_COLORS.into_iter().map(Some)); + let mut row = h_flex().gap_1().py_1(); + for (slot, color) in swatches.enumerate() { + let selected = color == current; + let target = workspace.clone(); + let fill = color + .map(|color| gpui::Rgba { + r: color.r as f32 / 255.0, + g: color.g as f32 / 255.0, + b: color.b as f32 / 255.0, + a: 1.0, + }) + .unwrap_or_else(|| cx.theme().primary.into()); + row = row.child( + div() + .id(("tab-color", slot)) + .size(px(20.0)) + .rounded(px(5.0)) + .flex() + .items_center() + .justify_center() + .bg(fill) + .cursor_pointer() + .when(selected, |swatch| { + swatch.border_2().border_color(cx.theme().foreground) + }) + .when(color.is_none(), |swatch| { + swatch + .text_size(px(11.0)) + .text_color(cx.theme().primary_foreground) + .child("A") + }) + .on_mouse_down(MouseButton::Left, move |_, _, cx| { + if let Some(workspace) = target.upgrade() { + workspace.update(cx, |workspace, cx| { + let next = if selected { None } else { color }; + workspace.set_tab_color(ix, next, cx); + }); + } + }), + ); + } + row + }, + )) + } +} + +#[cfg(test)] +mod tests { + use super::tab_launch_allows_ai_fork; + use crate::session::LaunchSession; + + #[test] + fn tab_launch_allows_ai_fork_only_for_plain_shells() { + assert!(tab_launch_allows_ai_fork(None)); + assert!(tab_launch_allows_ai_fork(Some(&LaunchSession::Default))); + assert!(tab_launch_allows_ai_fork(Some(&LaunchSession::Shell { + name: "pwsh".into(), + program: "pwsh.exe".into(), + args: Vec::new(), + }))); + assert!(!tab_launch_allows_ai_fork(Some(&LaunchSession::Ssh { host: "box".into() }))); + assert!(!tab_launch_allows_ai_fork(Some(&LaunchSession::Profile { + name: "claude".into(), + command: "claude".into(), + args: Vec::new(), + cwd: None, + shell_id: None, + }))); + } +} diff --git a/nebula_app/src/gpui_shell/workspace/tab_scroll.rs b/nebula_app/src/gpui_shell/workspace/tab_scroll.rs new file mode 100644 index 0000000..52e1633 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/tab_scroll.rs @@ -0,0 +1,381 @@ +//! 侧栏 TABS 列表的整行滚动(旧壳 `chrome_tab_layout` + `nebula_tabs_scroll`)。 +//! +//! 标签多到超出侧栏剩余高度时,只画一个连续窗口,右侧贴覆盖式 thumb +//! (`overlay_scrollbar`):不占行宽、不压状态徽章。滚轮按整行步进;thumb +//! 仅在列表悬停或拖拽时绘制。 + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AppContext as _, Bounds, Context, InteractiveElement as _, IntoElement as _, MouseButton, + MouseDownEvent, ParentElement as _, Pixels, Point, ScrollWheelEvent, + StatefulInteractiveElement as _, Styled as _, Window, canvas, div, px, +}; + +use crate::display::ui::widgets::{self, OverlayScrollbar}; +use crate::gpui_shell::prelude::*; + +use super::{NebulaWorkspace, TAB_ROW_H, TAB_ROW_PITCH}; + +/// 行间距(pitch − 行高)。旧壳 `gap = s(8)`。 +const TAB_ROW_GAP: f32 = TAB_ROW_PITCH - TAB_ROW_H; +/// 旧壳 `tab_right_pad`:给覆盖式滚动条留的恒定沟槽,出现与否都不改行宽。 +const TAB_SCROLL_GUTTER: f32 = 9.0; + +/// n 行占的高度:间距只在行之间,末行后面不留。 +pub(super) fn rows_h(n: usize, pitch: f32, gap: f32) -> f32 { + if n == 0 { 0.0 } else { n as f32 * pitch - gap } +} + +/// 视口里能放下几行。尚未量到高度时先展示全部,避免首帧空列表。 +pub(super) fn visible_count(want: usize, avail_h: f32, pitch: f32, gap: f32) -> usize { + if want == 0 { + return 0; + } + if avail_h <= 1.0 { + return want; + } + let mut show = want; + while show > 0 && rows_h(show, pitch, gap) > avail_h + 0.5 { + show -= 1; + } + if show == 0 { 1 } else { show } +} + +pub(super) fn max_scroll(want: usize, show: usize) -> usize { + want.saturating_sub(show) +} + +pub(super) fn clamp_scroll(scroll: usize, max: usize) -> usize { + scroll.min(max) +} + +pub(super) fn index_visible(ix: usize, scroll: usize, show: usize) -> bool { + show > 0 && ix >= scroll && ix < scroll + show +} + +/// 激活项滚进窗口:在窗口上方则对齐窗口顶,在下方则对齐窗口底。 +pub(super) fn reveal_index(ix: usize, scroll: usize, show: usize) -> usize { + if show == 0 { + return 0; + } + if ix < scroll { + ix + } else if ix >= scroll + show { + ix + 1 - show + } else { + scroll + } +} + +/// 旧壳 `sidebar_wheel`:一次手势一行,符号与 winit 的 `-signum` 一致。 +pub(super) fn wheel_rows(delta_y: f32) -> i32 { + if delta_y > 0.0 { + -1 + } else if delta_y < 0.0 { + 1 + } else { + 0 + } +} + +pub(super) fn apply_wheel(scroll: usize, max: usize, rows: i32) -> usize { + (scroll as i32 + rows).clamp(0, max as i32) as usize +} + +fn point_in_rect((rx, ry, rw, rh): (f32, f32, f32, f32), x: f32, y: f32) -> bool { + x >= rx && y >= ry && x < rx + rw && y < ry + rh +} + +struct TabsWindow { + scroll: usize, + show: usize, + max: usize, + want: usize, +} + +impl NebulaWorkspace { + fn tabs_window(&self) -> TabsWindow { + let want = if self.tabs_section_collapsed { 0 } else { self.tabs.len() }; + let show = visible_count(want, self.tabs_viewport_h, TAB_ROW_PITCH, TAB_ROW_GAP); + let max = max_scroll(want, show); + let scroll = clamp_scroll(self.tabs_scroll, max); + TabsWindow { scroll, show, max, want } + } + + pub(super) fn tabs_visible_window(&self) -> (usize, usize) { + let window = self.tabs_window(); + (window.scroll, window.show) + } + + pub(super) fn clamp_tabs_scroll(&mut self) { + let max = self.tabs_window().max; + let next = clamp_scroll(self.tabs_scroll, max); + if next != self.tabs_scroll { + self.tabs_scroll = next; + } + } + + pub(super) fn reveal_active_tab(&mut self) { + let window = self.tabs_window(); + let next = reveal_index(self.active, window.scroll, window.show); + self.tabs_scroll = clamp_scroll(next, window.max); + } + + fn tabs_overlay_bar(&self) -> Option { + let window = self.tabs_window(); + if window.show == 0 || window.want <= window.show { + return None; + } + let viewport = rows_h(window.show, TAB_ROW_PITCH, TAB_ROW_GAP).max(1.0); + let content = rows_h(window.want, TAB_ROW_PITCH, TAB_ROW_GAP); + let width = self.tabs_list_width.max(1.0); + let height = self.tabs_viewport_h.max(viewport); + widgets::overlay_scrollbar( + (0.0, 0.0, width, height), + viewport, + content, + window.scroll as f32 * TAB_ROW_PITCH, + 1.0, + ) + } + + fn local_tabs_point(&self, position: Point) -> (f32, f32) { + (f32::from(position.x - self.tabs_list_origin.x), f32::from(position.y - self.tabs_list_origin.y)) + } + + fn press_tabs_scrollbar(&mut self, event: &MouseDownEvent) -> bool { + let Some(bar) = self.tabs_overlay_bar() else { return false }; + let (x, y) = self.local_tabs_point(event.position); + if !bar.hit_test(x, y) { + return false; + } + let grab = if point_in_rect(bar.thumb, x, y) { + y - bar.thumb.1 + } else { + bar.thumb.3 * 0.5 + }; + self.tabs_scroll_grab = Some(grab); + self.tabs_scroll = bar.target_offset(y, grab, self.tabs_window().max); + true + } + + fn drag_tabs_scrollbar_to(&mut self, position: Point) -> bool { + let Some(grab) = self.tabs_scroll_grab else { return false }; + let Some(bar) = self.tabs_overlay_bar() else { return false }; + let (_, y) = self.local_tabs_point(position); + let next = bar.target_offset(y, grab, self.tabs_window().max); + if next == self.tabs_scroll { + return false; + } + self.tabs_scroll = next; + true + } + + fn end_tabs_scrollbar_drag(&mut self) -> bool { + self.tabs_scroll_grab.take().is_some() + } + + fn on_tabs_wheel( + &mut self, + event: &ScrollWheelEvent, + _window: &mut Window, + cx: &mut Context, + ) { + let delta_y = f32::from(event.delta.pixel_delta(px(TAB_ROW_PITCH)).y); + let rows = wheel_rows(delta_y); + if rows == 0 { + return; + } + let max = self.tabs_window().max; + let next = apply_wheel(self.tabs_scroll, max, rows); + if next != self.tabs_scroll { + self.tabs_scroll = next; + cx.notify(); + } + cx.stop_propagation(); + } + + /// 列表槽:恒定右沟槽 + 覆盖 thumb + 滚轮。`items` 已是可见窗口。 + pub(super) fn wrap_tabs_scroll_list( + &self, + items: impl IntoIterator, + cx: &mut Context, + ) -> gpui::AnyElement { + let bar = self.tabs_overlay_bar(); + let dragging = self.tabs_scroll_grab.is_some(); + let show_thumb = bar.is_some() && (self.tabs_list_hot || dragging); + let workspace = cx.entity().downgrade(); + let thumb_color = { + let theme = cx.theme(); + let alpha = if dragging { + 0.78 + } else if self.tabs_list_hot { + 0.64 + } else { + 0.46 + }; + theme.scrollbar_thumb.opacity(alpha) + }; + + div() + .id("sidebar-tabs-list") + .flex_1() + .w_full() + .min_h_0() + .relative() + .overflow_hidden() + .pr(px(TAB_SCROLL_GUTTER)) + .on_scroll_wheel(cx.listener(Self::on_tabs_wheel)) + .on_hover(cx.listener(|this, hovered: &bool, _, cx| { + if this.tabs_list_hot != *hovered { + this.tabs_list_hot = *hovered; + cx.notify(); + } + })) + .child( + canvas( + move |bounds, _, cx| { + let _ = workspace.update(cx, |this, cx| { + this.remember_tabs_list_bounds(bounds, cx); + }); + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) + .child(v_flex().w_full().gap_2().children(items)) + .when_some(bar.filter(|_| show_thumb), |list, bar| { + list.child( + div() + .absolute() + .left(px(bar.thumb.0)) + .top(px(bar.thumb.1)) + .w(px(bar.thumb.2)) + .h(px(bar.thumb.3)) + .rounded_full() + .bg(thumb_color) + .occlude(), + ) + }) + .when_some(bar, |list, bar| { + list.child( + div() + .id("sidebar-tabs-scrollbar") + .absolute() + .left(px(bar.hit.0)) + .top(px(bar.hit.1)) + .w(px(bar.hit.2)) + .h(px(bar.hit.3)) + .cursor_pointer() + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, event: &MouseDownEvent, _, cx| { + if this.press_tabs_scrollbar(event) { + cx.stop_propagation(); + cx.notify(); + } + }), + ), + ) + }) + .into_any_element() + } + + fn remember_tabs_list_bounds(&mut self, bounds: Bounds, cx: &mut Context) { + let h = f32::from(bounds.size.height); + let w = f32::from(bounds.size.width); + let origin = bounds.origin; + let height_changed = (self.tabs_viewport_h - h).abs() > 0.5; + let width_changed = (self.tabs_list_width - w).abs() > 0.5; + self.tabs_list_origin = origin; + if !height_changed && !width_changed { + return; + } + self.tabs_viewport_h = h; + self.tabs_list_width = w; + self.clamp_tabs_scroll(); + cx.notify(); + } + + pub(super) fn tabs_scrollbar_drag_overlay( + &self, + cx: &mut Context, + ) -> Option { + if self.tabs_scroll_grab.is_none() { + return None; + } + Some( + div() + .absolute() + .inset_0() + .occlude() + .on_mouse_move(cx.listener(|this, event: &gpui::MouseMoveEvent, _, cx| { + if this.drag_tabs_scrollbar_to(event.position) { + cx.notify(); + } + })) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + if this.end_tabs_scrollbar_drag() { + cx.notify(); + } + }), + ) + .into_any_element(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::{apply_wheel, clamp_scroll, index_visible, max_scroll, reveal_index, rows_h, visible_count, wheel_rows}; + + const PITCH: f32 = 42.0; + const GAP: f32 = 8.0; + + #[test] + fn row_height_drops_the_trailing_gap() { + assert_eq!(rows_h(0, PITCH, GAP), 0.0); + assert_eq!(rows_h(1, PITCH, GAP), 34.0); + assert_eq!(rows_h(3, PITCH, GAP), 3.0 * PITCH - GAP); + } + + #[test] + fn visible_count_fits_whole_rows_into_the_band() { + // 100px 放下 2 行(76px),放不下 3 行(118px)。 + assert_eq!(visible_count(10, 100.0, PITCH, GAP), 2); + assert_eq!(visible_count(2, 100.0, PITCH, GAP), 2); + assert_eq!(visible_count(10, 0.0, PITCH, GAP), 10); + assert_eq!(visible_count(0, 100.0, PITCH, GAP), 0); + } + + #[test] + fn scrollbar_only_exists_when_the_window_overflows() { + assert_eq!(max_scroll(10, 10), 0); + assert_eq!(max_scroll(10, 3), 7); + assert_eq!(clamp_scroll(9, 7), 7); + assert!(!index_visible(0, 2, 3)); + assert!(index_visible(2, 2, 3)); + assert!(index_visible(4, 2, 3)); + assert!(!index_visible(5, 2, 3)); + } + + #[test] + fn activating_a_scrolled_out_tab_moves_the_window() { + assert_eq!(reveal_index(0, 2, 3), 0); + assert_eq!(reveal_index(3, 2, 3), 2); + assert_eq!(reveal_index(9, 0, 3), 7); + } + + #[test] + fn wheel_steps_one_row_and_clamps() { + assert_eq!(wheel_rows(12.0), -1); + assert_eq!(wheel_rows(-3.0), 1); + assert_eq!(wheel_rows(0.0), 0); + assert_eq!(apply_wheel(0, 5, -1), 0); + assert_eq!(apply_wheel(5, 5, 1), 5); + assert_eq!(apply_wheel(2, 5, 1), 3); + } +} diff --git a/nebula_app/src/gpui_shell/workspace/tests.rs b/nebula_app/src/gpui_shell/workspace/tests.rs new file mode 100644 index 0000000..01e156c --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/tests.rs @@ -0,0 +1,261 @@ +//! `workspace.rs` 单元测试。 + +use super::*; + +#[test] +fn gpui_binding_combo_maps_plus_minus_and_digits() { + assert_eq!(gpui_binding_combo("ctrl+plus"), "ctrl-+"); + assert_eq!(gpui_binding_combo("ctrl+minus"), "ctrl--"); + assert_eq!(gpui_binding_combo("ctrl+digit1"), "ctrl-1"); + assert!( + custom_workspace_binding("ctrl+shift+e", &crate::config::Action::CreateNewWindow).is_none() + ); +} + +#[test] +fn ai_hook_routing_never_falls_back_for_a_stale_exact_pane() { + let pane_ids = [7u64, 11]; + assert_eq!(ai_hook_target_pane(&pane_ids, Some(11), Some(7)), Some(11)); + assert_eq!(ai_hook_target_pane(&pane_ids, Some(99), Some(7)), None); + assert_eq!(ai_hook_target_pane(&pane_ids, None, Some(7)), Some(7)); + assert_eq!(ai_hook_target_pane(&pane_ids, None, None), None); +} + +#[test] +fn restored_agent_command_honors_resume_ai_without_changing_session_data() { + let agent = crate::session::AgentSession { + source: "claude".to_owned(), + session_id: Some("session-42".to_owned()), + }; + + assert_eq!( + restored_agent_command(true, Some(&agent)), + Some("claude --resume session-42".to_owned()) + ); + assert_eq!(restored_agent_command(false, Some(&agent)), None); + assert_eq!(restored_agent_command(true, None), None); +} + +#[test] +fn new_tab_position_uses_the_shared_runtime_semantics() { + use nebula_settings::NewTabPositionName::{AfterCurrent, End}; + + assert_eq!(new_tab_insert_index(AfterCurrent, 1, 4), 2); + assert_eq!(new_tab_insert_index(End, 1, 4), 4); + assert_eq!(new_tab_insert_index(AfterCurrent, 9, 2), 2); + assert_eq!(new_tab_insert_index(AfterCurrent, 0, 0), 0); +} + +#[test] +fn default_launch_tag_does_not_follow_later_default_shell_changes() { + 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("ps".to_owned()) + ); +} + +/// 分屏生命周期合同的树侧不变式:split 后叶集扩张、关到最后一个叶 +/// 判 WasRoot(宿主关整 tab)、中途摘叶塌缩并移交焦点。 +#[test] +fn split_tree_lifecycle_matches_pane_contract() { + let mut tree = SplitTree::leaf(1u64); + assert!(tree.split_leaf(1, 2, SplitDirection::LeftRight, 0.5)); + assert!(tree.split_leaf(2, 3, SplitDirection::TopBottom, 0.5)); + assert_eq!(tree.leaves(), vec![1, 2, 3]); + + // 摘中间叶:父节点塌缩,焦点移交幸存子树首叶。 + assert_eq!(tree.remove_leaf(2), RemoveOutcome::Collapsed(3)); + assert_eq!(tree.leaves(), vec![1, 3]); + // 摘到只剩一个:WasRoot=宿主应关整个 tab,树不再可用。 + assert_eq!(tree.remove_leaf(1), RemoveOutcome::Collapsed(3)); + assert_eq!(tree.remove_leaf(3), RemoveOutcome::WasRoot); + // 不存在的叶子不产生副作用。 + let mut single = SplitTree::leaf(9u64); + assert_eq!(single.remove_leaf(4), RemoveOutcome::NotFound); +} + +#[test] +fn dock_tree_places_the_source_tree_on_the_nav_side() { + let docked = dock_tree(SplitTree::leaf(1u64), SplitTree::leaf(9u64), SplitNav::Left); + assert_eq!(docked.leaves(), vec![9, 1], "Left:source 树进 first 槽"); + match &docked { + SplitTree::Split { direction: SplitDirection::LeftRight, ratio, .. } => { + assert!((ratio - 0.5).abs() < f32::EPSILON, "dock 恒为 50/50 根分割"); + }, + _ => panic!("dock 应产生根级左右分割"), + } + + // 多叶源树整树挂入,叶序保持源树内部顺序。 + let mut source = SplitTree::leaf(7u64); + assert!(source.split_leaf(7, 8, SplitDirection::TopBottom, 0.5)); + let docked = dock_tree(SplitTree::leaf(1u64), source, SplitNav::Down); + assert_eq!(docked.leaves(), vec![1, 7, 8], "Down:source 树进 second 槽"); + match &docked { + SplitTree::Split { direction: SplitDirection::TopBottom, .. } => {}, + _ => panic!("Down 应产生上下分割"), + } +} + +#[test] +fn cwd_palette_actions_are_available_only_for_local_terminal_tabs() { + use crate::display::command_palette::PaletteAction; + + assert!(!NebulaWorkspace::palette_action_available(&PaletteAction::CopyCwd, false,)); + assert!(!NebulaWorkspace::palette_action_available(&PaletteAction::RevealCwd, false,)); + assert!(NebulaWorkspace::palette_action_available(&PaletteAction::CopyCwd, true,)); + assert!(NebulaWorkspace::palette_action_available(&PaletteAction::RevealCwd, true,)); + assert!(NebulaWorkspace::palette_action_available(&PaletteAction::NewTab, false)); +} + +/// 新建终端弹窗:默认 shell 必须占首行,因为「开弹窗 → 回车」是旧壳 +/// 点 "+" 后最常走的一条路,它必须等于「开一个默认终端」。 +#[test] +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 palette_now_exposes_export_workspace_and_git_panel() { + use crate::display::command_palette::PaletteAction; + + assert!(NebulaWorkspace::palette_action_supported(&PaletteAction::ExportWorkspace)); + assert!(NebulaWorkspace::palette_action_supported(&PaletteAction::ToggleGitPanel)); +} + +#[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", + "Fix resize", + ), + crate::ai_sessions::AiSession::test_session( + crate::ai_agents::AgentKind::Aider, + "aider-42", + "Unsupported", + ), + ]; + let rows = ai_session_palette_rows(sessions); + assert_eq!(rows.len(), 2, "Claude supports resume and fork; Aider supports neither"); + assert!(matches!( + &rows[0].action, + WorkspacePaletteAction::RunAiSession { command, .. } + if command == "claude --resume claude-42" + )); + assert!(matches!( + &rows[1].action, + WorkspacePaletteAction::RunAiSession { command, .. } + if command == "claude --resume claude-42 --fork-session" + )); +} + +/// 旧壳 CommitRename:trim 后空串清掉 custom_name,恢复 cwd 自动名。 +#[test] +fn tab_chrome_commit_empty_clears_custom_name() { + let mut meta = TabMeta { custom_name: Some("manual".to_owned()), ..TabMeta::default() }; + apply_commit_rename(&mut meta, " \t "); + assert_eq!(meta.custom_name, None); + + apply_commit_rename(&mut meta, " my-tab "); + assert_eq!(meta.custom_name.as_deref(), Some("my-tab")); +} + +/// 旧壳 CancelRename:丢掉缓冲,custom_name 原样保留(含启动时写入的「分叉」)。 +#[test] +fn tab_chrome_cancel_leaves_custom_name() { + let mut named = TabMeta { + custom_name: Some("Claude 分叉".to_owned()), + ..TabMeta::default() + }; + apply_cancel_rename(&mut named); + assert_eq!(named.custom_name.as_deref(), Some("Claude 分叉")); + + let mut auto = TabMeta::default(); + apply_cancel_rename(&mut auto); + assert_eq!(auto.custom_name, None); +} + +/// 旧壳 `chrome_tab_label`:有 cwd 就用末级目录名,不用 OSC 标题 / `NEBULA|` 整串。 +#[test] +fn tab_chrome_label_uses_cwd_last_component() { + use crate::gpui_shell::terminal::view::last_path_component; + + assert_eq!(last_path_component(r"D:\work\nebula").as_deref(), Some("nebula")); + assert_eq!(last_path_component("/home/me/src/").as_deref(), Some("src")); + assert_eq!(last_path_component(r"C:\Users\me\").as_deref(), Some("me")); + assert_eq!(last_path_component("").as_deref(), None); + assert_eq!(last_path_component("///").as_deref(), None); +} diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index b55eb09..7993aa5 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -68,6 +68,7 @@ mod platform; mod polling; mod process_tree; mod renderer; +mod mux; mod runtime_api; mod scheduler; mod session; @@ -136,10 +137,27 @@ fn main() -> Result<(), Box> { // Load command line options. let options = Options::new(); + #[cfg(windows)] + if options.subcommands.is_none() && env::var_os("NEBULA_DETACHED_LAUNCH").is_some() { + // 必须在进任何消息循环之前脱离启动控制台。GPUI 以前在这条 + // FreeConsole 之前就 `return`,启动器(agent 作业对象)一退出 + // 窗口就被带走。旧壳注释同一合同。 + unsafe { + FreeConsole(); + } + } + // P3 主窗形态:GPUI 作为 nebula.exe 的 UI 层,从主线程直接进 GPUI // 消息循环,winit 旧壳完全不启动。三闸门在此形态复测。 + // + // mux probe 必须在 `run_shell` 之前:第二份 `nebula --gpui` 交给驻留 + // 实例(ATTACH),不能再拉一套 PTY。 #[cfg(feature = "gpui-shell")] if options.gpui { + #[cfg(windows)] + if options.subcommands.is_none() && try_hand_over_to_resident(&options) { + return Ok(()); + } gpui_shell::run_shell(); return Ok(()); } @@ -155,15 +173,6 @@ fn main() -> Result<(), Box> { .expect("spawn gpui-shell thread"); } - #[cfg(windows)] - if options.subcommands.is_none() && env::var_os("NEBULA_DETACHED_LAUNCH").is_some() { - // 独立验证实例必须在创建窗口和 ConPTY 前脱离短生命周期的启动控制台, - // 否则启动器退出会连带关闭窗口,只留下后台会话进程。 - unsafe { - FreeConsole(); - } - } - match options.subcommands { Some(Subcommands::Ctl(options)) => runtime_api::run_cli(options)?, #[cfg(unix)] @@ -256,29 +265,8 @@ fn nebula(mut options: Options) -> Result<(), Box> { // PTYs never stopped) or focuses its window. Explicit intent (-e, // --daemon) always starts a real instance. #[cfg(windows)] - { - let has_command = options.window_options.terminal_options.command().is_some(); - let plain_launch = !options.daemon - && options.window_options.terminal_options.working_directory.is_none() - && !has_command; - if plain_launch && runtime_api::try_attach_existing() { - return Ok(()); - } - // Explorer 右键「在 Nebula 中打开」带着 --working-directory 走到这里。 - // 此前它被一律判成"显式意图 → 独立实例",绕过 mux 交接:驻留进程里 - // detached 的标签接不回来,表现就是"之前的标签没了 + 多出一个独立 - // 窗口"。带目录、无 -e 命令的启动现在优先并入驻留实例——ATTACH 恢复 - // 窗口,再在其中打开定目录标签;没有驻留实例时照旧独立启动。 - let dir_launch = !options.daemon && !has_command; - if dir_launch { - if let Some(dir) = - options.window_options.terminal_options.resolved_working_directory() - { - if dir.is_dir() && runtime_api::try_open_directory_existing(&dir) { - return Ok(()); - } - } - } + if try_hand_over_to_resident(&options) { + return Ok(()); } boot_trace("mux probe done"); @@ -428,6 +416,32 @@ fn nebula(mut options: Options) -> Result<(), Box> { result } +/// Windows 单实例交接:普通启动 ATTACH 到驻留进程;带工作目录、无 `-e` +/// 则先 ATTACH 再 `tab.new`。GPUI 与 winit 共用,避免 `--gpui` 抢在 probe +/// 前面再拉一套 PTY。 +#[cfg(windows)] +fn try_hand_over_to_resident(options: &Options) -> bool { + let has_command = options.window_options.terminal_options.command().is_some(); + let plain_launch = !options.daemon + && options.window_options.terminal_options.working_directory.is_none() + && !has_command; + if plain_launch && runtime_api::try_attach_existing() { + return true; + } + // Explorer 右键「在 Nebula 中打开」带着 --working-directory 走到这里。 + // 带目录、无 -e 命令的启动优先并入驻留实例——ATTACH 恢复窗口,再在 + // 其中打开定目录标签;没有驻留实例时照旧独立启动。 + let dir_launch = !options.daemon && !has_command; + if dir_launch { + if let Some(dir) = options.window_options.terminal_options.resolved_working_directory() { + if dir.is_dir() && runtime_api::try_open_directory_existing(&dir) { + return true; + } + } + } + false +} + fn log_config_path(config: &UiConfig) { if config.config_paths.is_empty() { return; diff --git a/nebula_app/src/math/mod.rs b/nebula_app/src/math/mod.rs index 239b621..ea220b7 100644 --- a/nebula_app/src/math/mod.rs +++ b/nebula_app/src/math/mod.rs @@ -27,6 +27,14 @@ pub(crate) const MIN_READABLE_MATH_PX: f32 = 6.0; /// 阅读器)自动一致。 pub(crate) const OPTICAL_SCALE: f32 = 1.21; +/// 窗口 DPI scale → `compile_formula` 的 `pixels_per_point`。TeX 的 pt 是 +/// 1/72.27 英寸,逻辑分辨率按 96 dpi 计;该参数只决定 `\kern`/`\\[6pt]` +/// 这类显式长度的换算。两壳所有编译调用共用这一个公式,显式行距/间距 +/// 才不会因壳而异(GPUI 壳曾直接传 scale factor,显式长度偏小 1/3)。 +pub(crate) fn pixels_per_point(scale_factor: f32) -> f32 { + scale_factor * 96.0 / 72.27 +} + /// 上下标 / 分子分母的最小相对字号。Latin Modern 的 MATH 表给 70%,是为 /// 纸面阅读字号调的;终端正文本来就小,0.7 em 的分子在 20px 字号下只有 /// 14px,用户看到的就是"公式比正文小一圈"。这里抬到 0.8,代价是公式变高 diff --git a/nebula_app/src/mux.rs b/nebula_app/src/mux.rs index f7743fb..b180b9d 100644 --- a/nebula_app/src/mux.rs +++ b/nebula_app/src/mux.rs @@ -11,10 +11,15 @@ //! trying to be cryptography). The file always describes the newest server; //! a stale file (crashed process) fails the connect within the timeout and //! the next launch simply takes the file over — attach degrades, never hangs. +//! +//! Production GUI serving for both shells is [`crate::runtime_api::RuntimeServer`] +//! (`runtime.port`, which also speaks this legacy ATTACH/PING line protocol). +//! This module is the JSON-less callback path used by tests and as a fallback +//! writer of `mux.port`. use std::io::{BufRead, BufReader, Write}; use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::Duration; use log::{info, warn}; @@ -45,17 +50,17 @@ fn fresh_token() -> String { format!("{:016x}{:016x}", a.finish(), b.finish()) } -fn read_port_file() -> Option<(u16, String)> { - let data = std::fs::read_to_string(port_file()).ok()?; +fn read_port_file_at(path: &Path) -> Option<(u16, String)> { + let data = std::fs::read_to_string(path).ok()?; let mut parts = data.split_whitespace(); let port = parts.next()?.parse().ok()?; let token = parts.next()?.to_owned(); Some((port, token)) } -/// One request/response round-trip against the resident server, if any. -fn request(verb: &str) -> Option<()> { - let (port, token) = read_port_file()?; +/// One request/response round-trip against the server described by `path`. +pub(crate) fn request_at(path: &Path, verb: &str) -> Option<()> { + let (port, token) = read_port_file_at(path)?; let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); let mut stream = TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT).ok()?; stream.set_read_timeout(Some(IO_TIMEOUT)).ok()?; @@ -66,13 +71,6 @@ fn request(verb: &str) -> Option<()> { (line.trim() == "OK").then_some(()) } -/// Hand this launch over to the resident instance: it re-attaches its -/// detached tabs (or focuses an existing window). `true` means handled — the -/// caller should exit without starting a terminal. -pub fn try_attach_existing() -> bool { - request("ATTACH").is_some() -} - /// The serving side, owned by the resident instance. Dropping it removes the /// port file (clean exit); a killed process leaves a stale file, handled by /// the connect timeout on the next launch. @@ -83,22 +81,37 @@ pub struct MuxServer { impl MuxServer { /// Start serving attach requests, unless a live server already owns the /// port file (then this instance stays client-only and returns `None`). + #[allow(dead_code)] pub fn spawn(proxy: EventLoopProxy) -> Option { - if request("PING").is_some() { + Self::spawn_callback(move || { + let _ = proxy.send_event(Event::new(EventType::NebulaAttach, None)); + }) + } + + /// ATTACH/PING server that notifies via callback instead of a winit proxy. + /// Writes `mux.port` under [`crate::display::nebula_data_dir`]. + #[allow(dead_code)] + pub fn spawn_callback(on_attach: impl Fn() + Send + 'static) -> Option { + Self::spawn_callback_at(port_file(), on_attach) + } + + /// Same protocol as [`Self::spawn_callback`], with an injectable port path + /// so tests do not depend on `NEBULA_CONFIG_DIR` / `data_dir` OnceLock. + pub fn spawn_callback_at(path: PathBuf, on_attach: impl Fn() + Send + 'static) -> Option { + if request_at(&path, "PING").is_some() { info!("Mux server already running; this instance won't serve attach requests"); return None; } let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).ok()?; let port = listener.local_addr().ok()?.port(); let token = fresh_token(); - let path = port_file(); if std::fs::write(&path, format!("{port} {token}")).is_err() { warn!("Mux: cannot write {path:?}; window re-attach disabled"); return None; } let spawned = std::thread::Builder::new() .name("nebula-mux".into()) - .spawn(move || serve(listener, token, proxy)) + .spawn(move || serve_callback(listener, token, on_attach)) .is_ok(); spawned.then(|| Self { port_file: path }) } @@ -110,7 +123,7 @@ impl Drop for MuxServer { } } -fn serve(listener: TcpListener, token: String, proxy: EventLoopProxy) { +fn serve_callback(listener: TcpListener, token: String, on_attach: impl Fn()) { for stream in listener.incoming() { let Ok(mut stream) = stream else { continue }; let _ = stream.set_read_timeout(Some(IO_TIMEOUT)); @@ -126,9 +139,7 @@ fn serve(listener: TcpListener, token: String, proxy: EventLoopProxy) { } match verb { "ATTACH" => { - // The event loop side re-attaches or focuses; see - // `Processor::handle_attach_request`. - let _ = proxy.send_event(Event::new(EventType::NebulaAttach, None)); + on_attach(); let _ = stream.write_all(b"OK\n"); }, "PING" => { @@ -138,3 +149,39 @@ fn serve(listener: TcpListener, token: String, proxy: EventLoopProxy) { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + #[test] + fn callback_server_writes_port_file_and_answers_ping_and_attach() { + let dir = std::env::temp_dir().join(format!( + "nebula-mux-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).expect("temp mux dir"); + let path = dir.join("mux.port"); + let attached = Arc::new(AtomicUsize::new(0)); + let flag = attached.clone(); + let server = MuxServer::spawn_callback_at(path.clone(), move || { + flag.fetch_add(1, Ordering::SeqCst); + }) + .expect("mux callback server"); + + assert!(path.is_file(), "port file must exist while the server lives"); + assert!(request_at(&path, "PING").is_some(), "PING must return OK"); + assert!(request_at(&path, "ATTACH").is_some(), "ATTACH must return OK"); + assert_eq!(attached.load(Ordering::SeqCst), 1); + + drop(server); + assert!(!path.exists(), "Drop must remove the port file"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/nebula_app/src/runtime_api.rs b/nebula_app/src/runtime_api.rs index e075fc1..5f6a864 100644 --- a/nebula_app/src/runtime_api.rs +++ b/nebula_app/src/runtime_api.rs @@ -294,6 +294,44 @@ impl RuntimeDispatch { } } +/// Non-winit event loops (GPUI) receive attach / control without an +/// `EventLoopProxy`. ATTACH is fire-and-forget; control still waits on +/// [`RuntimeDispatch::respond`]. +#[derive(Clone)] +pub enum RuntimeCallback { + Attach, + Control(Arc), +} + +#[derive(Clone)] +enum EventSink { + Winit(EventLoopProxy), + Callback(Arc), +} + +impl EventSink { + fn emit_attach(&self) { + match self { + Self::Winit(proxy) => { + let _ = proxy.send_event(Event::new(EventType::NebulaAttach, None)); + }, + Self::Callback(callback) => callback(RuntimeCallback::Attach), + } + } + + fn emit_control(&self, dispatch: Arc) -> bool { + match self { + Self::Winit(proxy) => { + proxy.send_event(Event::new(EventType::RuntimeControl(dispatch), None)).is_ok() + }, + Self::Callback(callback) => { + callback(RuntimeCallback::Control(dispatch)); + true + }, + } + } +} + #[derive(Clone, Default)] pub struct RuntimeHub { inner: Arc>, @@ -646,6 +684,20 @@ pub struct RuntimeServer { impl RuntimeServer { pub fn spawn(proxy: EventLoopProxy, hub: RuntimeHub) -> Option { + Self::spawn_with_sink(EventSink::Winit(proxy), hub) + } + + /// Same discovery file and ATTACH/PING/JSON protocol as [`Self::spawn`], + /// but attach/control are delivered through a callback (GPUI has no winit + /// `EventLoopProxy`). + pub fn spawn_callback( + on_event: impl Fn(RuntimeCallback) + Send + Sync + 'static, + hub: RuntimeHub, + ) -> Option { + Self::spawn_with_sink(EventSink::Callback(Arc::new(on_event)), hub) + } + + fn spawn_with_sink(sink: EventSink, hub: RuntimeHub) -> Option { if read_endpoint().and_then(|endpoint| legacy_request_to("PING", &endpoint)).is_some() { info!("Runtime API server already running; this instance stays client-only"); return None; @@ -663,7 +715,7 @@ impl RuntimeServer { let server_token = endpoint.token.clone(); let spawned = std::thread::Builder::new() .name("nebula-runtime-api".into()) - .spawn(move || serve(listener, server_token, proxy, hub)) + .spawn(move || serve(listener, server_token, sink, hub)) .is_ok(); spawned.then(|| Self { endpoint, port_file: path }) } @@ -678,7 +730,7 @@ impl Drop for RuntimeServer { } } -fn serve(listener: TcpListener, token: String, proxy: EventLoopProxy, hub: RuntimeHub) { +fn serve(listener: TcpListener, token: String, sink: EventSink, hub: RuntimeHub) { let active = Arc::new(AtomicUsize::new(0)); for stream in listener.incoming() { let Ok(stream) = stream else { continue }; @@ -688,11 +740,11 @@ fn serve(listener: TcpListener, token: String, proxy: EventLoopProxy, hub } let active = active.clone(); let token = token.clone(); - let proxy = proxy.clone(); + let sink = sink.clone(); let hub = hub.clone(); let _ = std::thread::Builder::new().name("nebula-runtime-client".into()).spawn(move || { let _guard = ActiveClient(active); - if let Err(error) = handle_connection(stream, &token, &proxy, &hub) { + if let Err(error) = handle_connection(stream, &token, &sink, &hub) { log::debug!("Runtime API client disconnected: {error}"); } }); @@ -710,7 +762,7 @@ impl Drop for ActiveClient { fn handle_connection( mut stream: TcpStream, token: &str, - proxy: &EventLoopProxy, + sink: &EventSink, hub: &RuntimeHub, ) -> Result<(), IoError> { stream.set_read_timeout(Some(IO_TIMEOUT))?; @@ -730,7 +782,7 @@ fn handle_connection( } if !line.trim_start().starts_with('{') { - return handle_legacy(&mut stream, &line, token, proxy); + return handle_legacy(&mut stream, &line, token, sink); } let raw: Value = match serde_json::from_str(&line) { @@ -782,7 +834,7 @@ fn handle_connection( }, "events.subscribe" => subscribe_connection(&mut stream, request, hub), "pane.wait" => wait_connection(&mut stream, request, hub), - _ => dispatch_connection(&mut stream, request, proxy), + _ => dispatch_connection(&mut stream, request, sink), } } @@ -790,7 +842,7 @@ fn handle_legacy( stream: &mut TcpStream, line: &str, token: &str, - proxy: &EventLoopProxy, + sink: &EventSink, ) -> Result<(), IoError> { let mut parts = line.split_whitespace(); let verb = parts.next().unwrap_or(""); @@ -799,7 +851,7 @@ fn handle_legacy( } match verb { "ATTACH" => { - let _ = proxy.send_event(Event::new(EventType::NebulaAttach, None)); + sink.emit_attach(); stream.write_all(b"OK\n") }, "PING" => stream.write_all(b"OK\n"), @@ -958,14 +1010,14 @@ fn wait_connection( fn dispatch_connection( stream: &mut TcpStream, request: ApiRequest, - proxy: &EventLoopProxy, + sink: &EventSink, ) -> Result<(), IoError> { let command = match RuntimeCommand::from_request(&request) { Ok(command) => command, Err(error) => return write_response(stream, &ApiResponse::failure(request.id, error)), }; let (dispatch, receiver) = RuntimeDispatch::new(command); - if proxy.send_event(Event::new(EventType::RuntimeControl(dispatch), None)).is_err() { + if !sink.emit_control(dispatch) { return write_response( stream, &ApiResponse::failure( diff --git a/nebula_app/src/ssh_session.rs b/nebula_app/src/ssh_session.rs index 2d0bbbd..43940c5 100644 --- a/nebula_app/src/ssh_session.rs +++ b/nebula_app/src/ssh_session.rs @@ -857,7 +857,9 @@ async fn run_test(request: SshTestRequest) -> SshTestResult { } }) .await - .map_err(|err| -> SessionError { format!("SSH 地址解析任务失败: {err}").into() })??; + .map_err(|err| -> SessionError { + format!("SSH 地址解析任务失败: {err}").into() + })??; // SSH 编辑器不再维护重复的每主机代理;测试与真连都只读取网络页 // 的当前配置。`ProxyJump` 属于 OpenSSH 目标解析,仍按其原语义保留。 let proxy = resolve_network_proxy(&global, &resolved) @@ -923,6 +925,50 @@ const NETWORK_TEST_PORT: u16 = 80; trait NetworkTestStream: AsyncRead + AsyncWrite + Unpin + Send {} impl NetworkTestStream for T {} +/// 一次出网测试的完成数据。旧 winit 壳经事件投递;GPUI 设置页走 oneshot, +/// 握手与 HTTP 探测仍是 [`proxy_test_once`] 这一条路径。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProxyTestResult { + pub request_id: u64, + pub ok: bool, + pub message: String, + pub elapsed_ms: u64, +} + +fn proxy_test_timeout_message() -> String { + format!("网络测试超时({} 秒无响应)", TEST_TIMEOUT.as_secs()) +} + +fn proxy_test_pair( + outcome: Result, tokio::time::error::Elapsed>, +) -> (bool, String) { + match outcome { + Ok(Ok(route)) => (true, route), + Ok(Err(err)) => (false, err.to_string()), + Err(_) => (false, proxy_test_timeout_message()), + } +} + +async fn run_proxy_test(request_id: u64) -> ProxyTestResult { + let started = std::time::Instant::now(); + let (ok, message) = + proxy_test_pair(tokio::time::timeout(TEST_TIMEOUT, proxy_test_once()).await); + ProxyTestResult { request_id, ok, message, elapsed_ms: started.elapsed().as_millis() as u64 } +} + +/// 启动出网测试并交给调用方异步等待。GPUI 没有 winit `EventLoopProxy`, +/// 所以通过这个 receiver 把结果回写到设置页 Entity;握手逻辑仍在 +/// [`proxy_test_once`]。 +pub fn start_proxy_test( + request_id: u64, +) -> io::Result> { + let (sender, receiver) = tokio::sync::oneshot::channel(); + runtime()?.spawn(async move { + let _ = sender.send(run_proxy_test(request_id).await); + }); + Ok(receiver) +} + /// 使用与 SSH 新连接相同的全局配置解析和代理握手建立字节流,再请求一个 /// 真实 HTTP 页面。只探测代理端口并不能证明代理有出网能力,所以这里必须 /// 收到目标站点的 HTTP 状态行才算成功。 @@ -932,19 +978,13 @@ pub fn spawn_proxy_test( window_id: winit::window::WindowId, ) -> io::Result<()> { runtime()?.spawn(async move { - let started = std::time::Instant::now(); - let outcome = tokio::time::timeout(TEST_TIMEOUT, proxy_test_once()).await; - let (ok, message) = match outcome { - Ok(Ok(route)) => (true, route), - Ok(Err(err)) => (false, err.to_string()), - Err(_) => (false, format!("网络测试超时({} 秒无响应)", TEST_TIMEOUT.as_secs())), - }; + let result = run_proxy_test(request_id).await; let _ = proxy.send_event(crate::event::Event::new( crate::event::EventType::ProxyTestDone { - request_id, - ok, - message, - elapsed_ms: started.elapsed().as_millis() as u64, + request_id: result.request_id, + ok: result.ok, + message: result.message, + elapsed_ms: result.elapsed_ms, }, window_id, )); @@ -1028,13 +1068,9 @@ async fn proxy_test_stream( }) .await .map_err(|err| format!("解析跳板任务失败: {err}"))??; - let jump = authenticated_session_at::( - &jump_destination, - &jump_profile, - None, - 1, - ) - .await?; + let jump = + authenticated_session_at::(&jump_destination, &jump_profile, None, 1) + .await?; let channel = { let session = jump.lock().await; session @@ -1455,6 +1491,12 @@ mod tests { assert_eq!(destination.identity_files.len(), 1); } + #[test] + fn proxy_test_timeout_copy_matches_shared_budget() { + assert_eq!(super::proxy_test_timeout_message(), "网络测试超时(12 秒无响应)"); + assert_eq!(super::TEST_TIMEOUT.as_secs(), 12); + } + #[test] fn ssh_runtime_uses_global_network_proxy_and_keeps_openssh_proxy_jump() { let global = SshProxyConfig { diff --git a/nebula_app/src/ssh_sftp.rs b/nebula_app/src/ssh_sftp.rs index e4302e6..c5c58c8 100644 --- a/nebula_app/src/ssh_sftp.rs +++ b/nebula_app/src/ssh_sftp.rs @@ -540,8 +540,8 @@ async fn upload_local_paths( Ok(()) } -/// 剪贴板截图 → 远端 `/tmp` 的一次性 PNG 上传(SSH pane 的图片粘贴, -/// Netcatty 同款交互)。成功后经 runtime Prompt 通道把远端路径作为输入敲进 +/// 剪贴板截图 → 远端 `/tmp` 的一次性 PNG 上传(SSH pane 的图片粘贴)。 +/// 成功后经 runtime Prompt 通道把远端路径作为输入敲进 /// 目标 pane——上传在 SSH runtime 上异步进行,UI 线程零阻塞;失败只留日志, /// 绝不把错误文本粘进终端。 pub fn upload_clipboard_image( diff --git a/nebula_app/src/tray.rs b/nebula_app/src/tray.rs index 08e6bd8..e9f8cd3 100644 --- a/nebula_app/src/tray.rs +++ b/nebula_app/src/tray.rs @@ -1,590 +1,612 @@ -//! 系统托盘常驻图标 + agent attention 状态(future_planning `T1-3`)。 -//! -//! 终端最常见的姿态是「压在别的窗口底下跑着 agent」:任务栏闪烁会被忽略、 -//! toast 会过期,而托盘图标一直在。任一 agent 停下来等输入时图标翻转成 -//! attention 态(右下角橙点),右键菜单列出所有 agent pane 与状态,点击 -//! 直达来源 pane——复用 toast 点击同一条 [`EventType::FocusWindow`] 路径, -//! 因此聚焦语义(还原最小化、选中 tab、聚焦 pane)与通知横幅完全一致。 -//! -//! # 线程模型 -//! -//! `Shell_NotifyIconW` 的回调消息需要一个窗口收;不能挂在 winit 的窗口上 -//! (fork 治理红线:不往窗口层塞策略)。托盘因此拥有自己的线程:一扇永不 -//! 显示的 Win32 窗口 + 独立消息泵。app 侧只做两件事——把 agent 清单写进 -//! [`STATE`]、`PostMessageW` 摇醒托盘线程;托盘线程反向只通过 -//! `EventLoopProxy` 发用户事件。两个方向都不共享锁跨越阻塞调用。 -//! -//! 状态推送挂在既有的 1 Hz chrome 时钟(与会话自动保存同一节拍):托盘是 -//! 环境信息面,秒级延迟无感,换来的是零新增定时器与天然去抖。 - -#![cfg_attr(not(windows), allow(dead_code))] - -use winit::window::WindowId; - -/// 托盘菜单里的一行:一个正在跑 AI CLI 的 pane。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TrayAgent { - /// 宿主窗口(点击后 `FocusWindow` 的路由目标)。 - pub window: WindowId, - pub pane: u64, - /// 菜单文案:「claude · nebula」(程序名 + cwd 尾段)。 - pub label: String, - /// CLI 停在半路等用户(权限确认/提问),比「回合完成」更强。 - pub needs_attention: bool, -} - -#[cfg(windows)] -pub use win::{init, set_enabled, shutdown, update}; - -#[cfg(not(windows))] -pub fn init(_proxy: winit::event_loop::EventLoopProxy) {} -#[cfg(not(windows))] -pub fn set_enabled(_enabled: bool) {} -#[cfg(not(windows))] -pub fn update(_agents: Vec) {} -#[cfg(not(windows))] -pub fn shutdown() {} - -#[cfg(windows)] -mod win { - use std::cell::RefCell; - use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering}; - use std::sync::{Mutex, OnceLock}; - - use winit::event_loop::EventLoopProxy; - - use super::TrayAgent; - use crate::event::{Event, EventType}; - - /// 与 notify.rs 的 toast 图标同一份官方 logo,托盘不会出现第二个品牌形态。 - const LOGO_PNG: &[u8] = include_bytes!("../../extra/logo/nebula.png"); - - /// 托盘回调(lParam 携带鼠标消息;未 SETVERSION 的经典 v0 语义, - /// Win7 至今行为一致,不依赖 v4 的坐标打包)。 - const WM_APP_TRAY: u32 = windows_sys::Win32::UI::WindowsAndMessaging::WM_APP + 1; - /// STATE.agents 变了:重读并 NIM_MODIFY 图标/气泡提示。 - const WM_APP_REFRESH: u32 = windows_sys::Win32::UI::WindowsAndMessaging::WM_APP + 2; - /// STATE.enabled 变了:NIM_ADD / NIM_DELETE。 - const WM_APP_SET: u32 = windows_sys::Win32::UI::WindowsAndMessaging::WM_APP + 3; - /// 进程收尾:删图标 + 退出消息泵(不删的话图标要等用户悬停才消失)。 - const WM_APP_SHUTDOWN: u32 = windows_sys::Win32::UI::WindowsAndMessaging::WM_APP + 4; - - /// 菜单命令 id 的起点;`MENU_AGENT_BASE + i` = 聚焦第 i 个 agent。 - const MENU_AGENT_BASE: usize = 1000; - /// 「显示 Nebula」——没有 agent 时托盘仍要能把窗口捞回来。 - const MENU_SHOW: usize = 1; - - struct Shared { - agents: Vec, - enabled: bool, - } - - static PROXY: OnceLock> = OnceLock::new(); - static STATE: Mutex = Mutex::new(Shared { agents: Vec::new(), enabled: false }); - /// 托盘窗口句柄(isize 形态跨线程存取;0 = 线程未起或窗口未建)。 - static HWND: AtomicIsize = AtomicIsize::new(0); - static THREAD_STARTED: AtomicBool = AtomicBool::new(false); - - fn state() -> std::sync::MutexGuard<'static, Shared> { - // 中毒只表示某线程带锁 panic;清单本身是普通数据,继续用。 - STATE.lock().unwrap_or_else(|e| e.into_inner()) - } - - /// 装好事件代理。真正的线程按需(首次启用)才起。 - pub fn init(proxy: EventLoopProxy) { - let _ = PROXY.set(proxy); - } - - /// 开/关托盘图标(设置·高级)。开 = 保证线程在跑并 NIM_ADD; - /// 关 = 只删图标,线程留着(重开是高频往复,不值得反复建窗)。 - pub fn set_enabled(enabled: bool) { - state().enabled = enabled; - if enabled { - ensure_thread(); - } - post(WM_APP_SET); - } - - /// 发布最新 agent 清单。内容没变就不打扰托盘线程(1 Hz 调用方无需去抖)。 - pub fn update(agents: Vec) { - { - let mut shared = state(); - if !shared.enabled || shared.agents == agents { - return; - } - shared.agents = agents; - } - post(WM_APP_REFRESH); - } - - /// 进程退出前删掉图标。best-effort:托盘线程没起过就什么都不用做。 - pub fn shutdown() { - post(WM_APP_SHUTDOWN); - } - - fn post(message: u32) { - let hwnd = HWND.load(Ordering::Acquire); - if hwnd != 0 { - // SAFETY: hwnd 由托盘线程发布且只在 WM_APP_SHUTDOWN 后失效; - // PostMessageW 对已销毁窗口安全失败。 - unsafe { - windows_sys::Win32::UI::WindowsAndMessaging::PostMessageW( - hwnd as _, - message, - 0, - 0, - ); - } - } - } - - fn ensure_thread() { - if THREAD_STARTED.swap(true, Ordering::SeqCst) { - return; - } - if let Err(err) = - std::thread::Builder::new().name("nebula-tray".into()).spawn(tray_thread) - { - THREAD_STARTED.store(false, Ordering::SeqCst); - log::warn!("tray: failed to spawn tray thread: {err}"); - } - } - - // ─── 托盘线程 ──────────────────────────────────────────────────────── - - /// 托盘线程本地资源:两个状态图标 + 图标是否已挂在通知区。 - struct Icons { - normal: isize, - attention: isize, - added: bool, - } - - thread_local! { - static ICONS: RefCell> = const { RefCell::new(None) }; - } - - fn tray_thread() { - use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW; - use windows_sys::Win32::UI::WindowsAndMessaging::{ - CW_USEDEFAULT, CreateWindowExW, DispatchMessageW, GetMessageW, MSG, RegisterClassW, - TranslateMessage, WNDCLASSW, WS_OVERLAPPED, - }; - - let class_name = wide("NebulaTrayWindow"); - // SAFETY: 类名/实例句柄在调用期间有效;重复注册返回 0 也无妨—— - // 单进程只会走到这里一次(THREAD_STARTED 闸)。 - unsafe { - let hinstance = GetModuleHandleW(std::ptr::null()); - let wc = WNDCLASSW { - style: 0, - lpfnWndProc: Some(tray_wnd_proc), - cbClsExtra: 0, - cbWndExtra: 0, - hInstance: hinstance, - hIcon: std::ptr::null_mut(), - hCursor: std::ptr::null_mut(), - hbrBackground: std::ptr::null_mut(), - lpszMenuName: std::ptr::null(), - lpszClassName: class_name.as_ptr(), - }; - RegisterClassW(&wc); - // 隐藏顶层窗口(不是 message-only:通知区回调对 HWND_MESSAGE - // 窗口在部分 Windows 版本上不投递)。永不 ShowWindow。 - let hwnd = CreateWindowExW( - 0, - class_name.as_ptr(), - wide("Nebula Tray").as_ptr(), - WS_OVERLAPPED, - CW_USEDEFAULT, - CW_USEDEFAULT, - 0, - 0, - std::ptr::null_mut(), - std::ptr::null_mut(), - hinstance, - std::ptr::null(), - ); - if hwnd.is_null() { - log::warn!("tray: CreateWindowExW failed; tray icon disabled"); - return; - } - HWND.store(hwnd as isize, Ordering::Release); - - let icons = build_icons(); - ICONS.with(|slot| *slot.borrow_mut() = Some(Icons { - normal: icons.0 as isize, - attention: icons.1 as isize, - added: false, - })); - // init 与首次 set_enabled 之间可能已经积累了状态:起跑先对齐。 - apply_enabled(hwnd); - - let mut msg: MSG = std::mem::zeroed(); - while GetMessageW(&mut msg, std::ptr::null_mut(), 0, 0) > 0 { - TranslateMessage(&msg); - DispatchMessageW(&msg); - } - } - } - - unsafe extern "system" fn tray_wnd_proc( - hwnd: windows_sys::Win32::Foundation::HWND, - msg: u32, - wparam: usize, - lparam: isize, - ) -> isize { - use windows_sys::Win32::UI::WindowsAndMessaging::{ - DefWindowProcW, PostQuitMessage, WM_LBUTTONUP, WM_RBUTTONUP, - }; - match msg { - WM_APP_TRAY => { - match lparam as u32 { - // 左键 = 最短路径:直达最需要人的 pane。 - WM_LBUTTONUP => focus_best(), - WM_RBUTTONUP => show_menu(hwnd), - _ => {}, - } - 0 - }, - WM_APP_REFRESH => { - refresh_icon(hwnd); - 0 - }, - WM_APP_SET => { - apply_enabled(hwnd); - 0 - }, - WM_APP_SHUTDOWN => { - remove_icon(hwnd); - // SAFETY: 本线程的消息泵,退出后线程结束。 - unsafe { PostQuitMessage(0) }; - 0 - }, - // SAFETY: 转发给默认过程,参数原样透传。 - _ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }, - } - } - - /// 左键/菜单兜底的聚焦策略:先挑等输入的,再挑任意 agent,最后裸窗口。 - fn focus_best() { - let target = { - let shared = state(); - shared - .agents - .iter() - .find(|agent| agent.needs_attention) - .or_else(|| shared.agents.first()) - .cloned() - }; - send_focus(target); - } - - fn send_focus(agent: Option) { - let Some(proxy) = PROXY.get() else { return }; - let event = match agent { - Some(agent) => { - Event::new(EventType::FocusWindow { pane: Some(agent.pane) }, agent.window) - }, - // 没有 agent:把第一扇窗捞到前台(processor 对 None 窗口 id 的 - // FocusWindow 就是这个语义)。 - None => Event::new(EventType::FocusWindow { pane: None }, None), - }; - let _ = proxy.send_event(event); - } - - fn show_menu(hwnd: windows_sys::Win32::Foundation::HWND) { - use windows_sys::Win32::Foundation::POINT; - use windows_sys::Win32::UI::WindowsAndMessaging::{ - AppendMenuW, CreatePopupMenu, DestroyMenu, GetCursorPos, MF_GRAYED, MF_SEPARATOR, - MF_STRING, SetForegroundWindow, TPM_NONOTIFY, TPM_RETURNCMD, TPM_RIGHTBUTTON, - TrackPopupMenu, WM_NULL, - }; - - // 菜单期间用快照:TrackPopupMenu 是模态泵,STATE 可能被 1 Hz 更新, - // 命令 id 必须映射回打开菜单那一刻的清单。 - let agents = state().agents.clone(); - - // SAFETY: 菜单句柄本函数创建/销毁;文案缓冲活过 AppendMenuW(菜单 - // 复制内容)。SetForegroundWindow + WM_NULL 是 TrackPopupMenu 在 - // 托盘场景的官方仪式(否则菜单点外部不收合)。 - unsafe { - let menu = CreatePopupMenu(); - if menu.is_null() { - return; - } - if agents.is_empty() { - let text = wide("没有正在运行的 agent"); - AppendMenuW(menu, MF_STRING | MF_GRAYED, 0, text.as_ptr()); - } - for (index, agent) in agents.iter().enumerate() { - // 状态点进文案:菜单不画自定义图形,实心/空心圈已经把 - // 「等你」和「在跑」分开。 - let mark = if agent.needs_attention { "● " } else { "○ " }; - let suffix = if agent.needs_attention { " — 等待输入" } else { "" }; - let text = wide(&format!("{mark}{}{suffix}", agent.label)); - AppendMenuW(menu, MF_STRING, MENU_AGENT_BASE + index, text.as_ptr()); - } - AppendMenuW(menu, MF_SEPARATOR, 0, std::ptr::null()); - let show = wide("显示 Nebula"); - AppendMenuW(menu, MF_STRING, MENU_SHOW, show.as_ptr()); - - let mut point = POINT { x: 0, y: 0 }; - GetCursorPos(&mut point); - SetForegroundWindow(hwnd); - let cmd = TrackPopupMenu( - menu, - TPM_RIGHTBUTTON | TPM_RETURNCMD | TPM_NONOTIFY, - point.x, - point.y, - 0, - hwnd, - std::ptr::null(), - ) as usize; - windows_sys::Win32::UI::WindowsAndMessaging::PostMessageW(hwnd, WM_NULL, 0, 0); - DestroyMenu(menu); - - if cmd == MENU_SHOW { - send_focus(None); - } else if cmd >= MENU_AGENT_BASE { - send_focus(agents.get(cmd - MENU_AGENT_BASE).cloned()); - } - } - } - - // ─── 通知区图标 ────────────────────────────────────────────────────── - - fn notify_data( - hwnd: windows_sys::Win32::Foundation::HWND, - ) -> windows_sys::Win32::UI::Shell::NOTIFYICONDATAW { - // SAFETY: NOTIFYICONDATAW 是纯 POD,零值是合法起点。 - let mut data: windows_sys::Win32::UI::Shell::NOTIFYICONDATAW = - unsafe { std::mem::zeroed() }; - data.cbSize = std::mem::size_of::() as u32; - data.hWnd = hwnd; - data.uID = 1; - data - } - - /// 依据 STATE 计算图标形态并 NIM_MODIFY;未挂图标时什么都不做。 - fn refresh_icon(hwnd: windows_sys::Win32::Foundation::HWND) { - use windows_sys::Win32::UI::Shell::{NIF_ICON, NIF_TIP, NIM_MODIFY, Shell_NotifyIconW}; - - let (agent_count, attention_count) = { - let shared = state(); - let attention = - shared.agents.iter().filter(|agent| agent.needs_attention).count(); - (shared.agents.len(), attention) - }; - ICONS.with(|slot| { - let slot = slot.borrow(); - let Some(icons) = slot.as_ref().filter(|icons| icons.added) else { return }; - let mut data = notify_data(hwnd); - data.uFlags = NIF_ICON | NIF_TIP; - data.hIcon = - (if attention_count > 0 { icons.attention } else { icons.normal }) as _; - let tip = if agent_count == 0 { - "Nebula".to_owned() - } else if attention_count > 0 { - format!("Nebula — {attention_count} 个 agent 等待输入") - } else { - format!("Nebula — {agent_count} 个 agent 运行中") - }; - copy_tip(&mut data.szTip, &tip); - // SAFETY: data 完整初始化且 hwnd 属于本线程。 - unsafe { Shell_NotifyIconW(NIM_MODIFY, &data) }; - }); - } - - fn apply_enabled(hwnd: windows_sys::Win32::Foundation::HWND) { - use windows_sys::Win32::UI::Shell::{ - NIF_ICON, NIF_MESSAGE, NIF_TIP, NIM_ADD, Shell_NotifyIconW, - }; - - let enabled = state().enabled; - ICONS.with(|slot| { - let mut slot = slot.borrow_mut(); - let Some(icons) = slot.as_mut() else { return }; - if enabled && !icons.added { - let mut data = notify_data(hwnd); - data.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP; - data.uCallbackMessage = WM_APP_TRAY; - data.hIcon = icons.normal as _; - copy_tip(&mut data.szTip, "Nebula"); - // SAFETY: 同上;失败(如 explorer 未起)留 added=false, - // 下一次 WM_APP_SET 重试。 - if unsafe { Shell_NotifyIconW(NIM_ADD, &data) } != 0 { - icons.added = true; - } - } else if !enabled && icons.added { - let data = notify_data(hwnd); - // SAFETY: 删除只需要 hWnd/uID。 - unsafe { - windows_sys::Win32::UI::Shell::Shell_NotifyIconW( - windows_sys::Win32::UI::Shell::NIM_DELETE, - &data, - ) - }; - icons.added = false; - } - }); - if enabled { - refresh_icon(hwnd); - } - } - - fn remove_icon(hwnd: windows_sys::Win32::Foundation::HWND) { - ICONS.with(|slot| { - let mut slot = slot.borrow_mut(); - if let Some(icons) = slot.as_mut().filter(|icons| icons.added) { - let data = notify_data(hwnd); - // SAFETY: 同 apply_enabled 的删除分支。 - unsafe { - windows_sys::Win32::UI::Shell::Shell_NotifyIconW( - windows_sys::Win32::UI::Shell::NIM_DELETE, - &data, - ) - }; - icons.added = false; - } - }); - } - - // ─── 图标像素 ──────────────────────────────────────────────────────── - - /// 从内嵌 logo 造两个 HICON:常态 + attention(右下角橙点)。 - /// 失败退化为空句柄——Shell_NotifyIconW 会用系统默认图标,托盘仍可用。 - fn build_icons() -> ( - windows_sys::Win32::UI::WindowsAndMessaging::HICON, - windows_sys::Win32::UI::WindowsAndMessaging::HICON, - ) { - use windows_sys::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_CXSMICON}; - - // SAFETY: GetSystemMetrics 无前置条件。 - let size = unsafe { GetSystemMetrics(SM_CXSMICON) }.clamp(16, 64) as u32; - let Ok(decoded) = image::load_from_memory(LOGO_PNG) else { - log::warn!("tray: embedded logo failed to decode"); - return (std::ptr::null_mut(), std::ptr::null_mut()); - }; - let base = image::imageops::resize( - &decoded.to_rgba8(), - size, - size, - image::imageops::FilterType::Lanczos3, - ); - let normal = create_icon(size, base.as_raw()); - - let mut attention_pixels = base.clone(); - draw_attention_dot(&mut attention_pixels, size); - let attention = create_icon(size, attention_pixels.as_raw()); - (normal, attention) - } - - /// 右下角实心橙点(约 5/8 直径比)。挑警示橙而不是主题 accent:托盘 - /// 图标不知道主题,且橙点在亮暗任务栏上对比都够。 - fn draw_attention_dot(pixels: &mut image::RgbaImage, size: u32) { - let radius = (size as f32) * 0.30; - let center = (size as f32) - radius - 0.5; - for y in 0..size { - for x in 0..size { - let dx = x as f32 - center; - let dy = y as f32 - center; - let distance = (dx * dx + dy * dy).sqrt(); - // 1px 羽化边,免得小尺寸下是一颗锯齿方块。 - let coverage = (radius - distance + 0.5).clamp(0.0, 1.0); - if coverage > 0.0 { - let pixel = pixels.get_pixel_mut(x, y); - let blend = |src: u8, dst: u8| -> u8 { - (src as f32 * coverage + dst as f32 * (1.0 - coverage)) as u8 - }; - *pixel = image::Rgba([ - blend(255, pixel[0]), - blend(140, pixel[1]), - blend(0, pixel[2]), - blend(255, pixel[3]), - ]); - } - } - } - } - - /// RGBA(直 alpha)→ HICON:32bpp 预乘 BGRA DIB + 单色掩码。 - fn create_icon( - size: u32, - rgba: &[u8], - ) -> windows_sys::Win32::UI::WindowsAndMessaging::HICON { - use windows_sys::Win32::Graphics::Gdi::{ - BI_RGB, BITMAPINFO, BITMAPINFOHEADER, CreateBitmap, CreateDIBSection, DIB_RGB_COLORS, - DeleteObject, - }; - use windows_sys::Win32::UI::WindowsAndMessaging::{CreateIconIndirect, ICONINFO}; - - // SAFETY: header 描述的缓冲由 CreateDIBSection 分配并在拷贝期间有效; - // 两个位图在 CreateIconIndirect 后立即释放(图标持有自己的副本)。 - unsafe { - let mut header: BITMAPINFOHEADER = std::mem::zeroed(); - header.biSize = std::mem::size_of::() as u32; - header.biWidth = size as i32; - // 负高 = 自顶向下,行序与解码像素一致,免得手工翻转。 - header.biHeight = -(size as i32); - header.biPlanes = 1; - header.biBitCount = 32; - header.biCompression = BI_RGB; - let info = BITMAPINFO { bmiHeader: header, bmiColors: std::mem::zeroed() }; - - let mut bits: *mut core::ffi::c_void = std::ptr::null_mut(); - let color = CreateDIBSection( - std::ptr::null_mut(), - &info, - DIB_RGB_COLORS, - &mut bits, - std::ptr::null_mut(), - 0, - ); - if color.is_null() || bits.is_null() { - return std::ptr::null_mut(); - } - let out = std::slice::from_raw_parts_mut(bits.cast::(), rgba.len()); - for (dst, src) in out.chunks_exact_mut(4).zip(rgba.chunks_exact(4)) { - let alpha = src[3] as u32; - // 预乘:DWM 按 premultiplied 合成托盘图标,直 alpha 会在 - // 深色任务栏上泛白边。 - dst[0] = (src[2] as u32 * alpha / 255) as u8; - dst[1] = (src[1] as u32 * alpha / 255) as u8; - dst[2] = (src[0] as u32 * alpha / 255) as u8; - dst[3] = src[3]; - } - - let mask = CreateBitmap(size as i32, size as i32, 1, 1, std::ptr::null()); - let icon_info = ICONINFO { - fIcon: 1, - xHotspot: 0, - yHotspot: 0, - hbmMask: mask, - hbmColor: color, - }; - let icon = CreateIconIndirect(&icon_info); - DeleteObject(color); - DeleteObject(mask); - icon - } - } - - // ─── 小工具 ───────────────────────────────────────────────────────── - - fn wide(text: &str) -> Vec { - text.encode_utf16().chain(Some(0)).collect() - } - - /// UTF-16 截断拷贝进 szTip(128 wchar,含 NUL)。 - fn copy_tip(tip: &mut [u16; 128], text: &str) { - let mut cursor = 0; - for unit in text.encode_utf16() { - if cursor >= tip.len() - 1 { - break; - } - tip[cursor] = unit; - cursor += 1; - } - tip[cursor] = 0; - } -} +//! 系统托盘常驻图标 + agent attention 状态(future_planning `T1-3`)。 +//! +//! 终端最常见的姿态是「压在别的窗口底下跑着 agent」:任务栏闪烁会被忽略、 +//! toast 会过期,而托盘图标一直在。任一 agent 停下来等输入时图标翻转成 +//! attention 态(右下角橙点),右键菜单列出所有 agent pane 与状态,点击 +//! 直达来源 pane——复用 toast 点击同一条 [`EventType::FocusWindow`] 路径, +//! 因此聚焦语义(还原最小化、选中 tab、聚焦 pane)与通知横幅完全一致。 +//! +//! # 线程模型 +//! +//! `Shell_NotifyIconW` 的回调消息需要一个窗口收;不能挂在 winit 的窗口上 +//! (fork 治理红线:不往窗口层塞策略)。托盘因此拥有自己的线程:一扇永不 +//! 显示的 Win32 窗口 + 独立消息泵。app 侧只做两件事——把 agent 清单写进 +//! [`STATE`]、`PostMessageW` 摇醒托盘线程;托盘线程反向只通过 +//! `EventLoopProxy` 发用户事件。两个方向都不共享锁跨越阻塞调用。 +//! +//! 状态推送挂在既有的 1 Hz chrome 时钟(与会话自动保存同一节拍):托盘是 +//! 环境信息面,秒级延迟无感,换来的是零新增定时器与天然去抖。 + +#![cfg_attr(not(windows), allow(dead_code))] + +use winit::window::WindowId; + +/// GPUI 托盘动作:聚焦某个 pane,或真正退出驻留进程。 +/// 旧壳 `tray::init(EventLoopProxy)` 路径不使用此枚举,菜单也没有「退出」。 +#[derive(Debug, Clone, Copy)] +pub enum GpuiTrayCommand { + Focus(Option), + Quit, +} + +/// 托盘菜单里的一行:一个正在跑 AI CLI 的 pane。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrayAgent { + /// 宿主窗口(点击后 `FocusWindow` 的路由目标)。 + pub window: WindowId, + pub pane: u64, + /// 菜单文案:「claude · nebula」(程序名 + cwd 尾段)。 + pub label: String, + /// CLI 停在半路等用户(权限确认/提问),比「回合完成」更强。 + pub needs_attention: bool, +} + +#[cfg(all(windows, feature = "gpui-shell"))] +pub use win::init_gpui; +#[cfg(windows)] +pub use win::{init, set_enabled, shutdown, update}; + +#[cfg(not(windows))] +pub fn init(_proxy: winit::event_loop::EventLoopProxy) {} +#[cfg(not(windows))] +pub fn init_gpui(_on_command: impl Fn(GpuiTrayCommand) + Send + Sync + 'static) {} +#[cfg(not(windows))] +pub fn set_enabled(_enabled: bool) {} +#[cfg(not(windows))] +pub fn update(_agents: Vec) {} +#[cfg(not(windows))] +pub fn shutdown() {} + +#[cfg(windows)] +mod win { + use std::cell::RefCell; + use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering}; + use std::sync::{Mutex, OnceLock}; + + use winit::event_loop::EventLoopProxy; + + use super::TrayAgent; + use crate::event::{Event, EventType}; + + /// 与 notify.rs 的 toast 图标同一份官方 logo,托盘不会出现第二个品牌形态。 + const LOGO_PNG: &[u8] = include_bytes!("../../extra/logo/nebula.png"); + + /// 托盘回调(lParam 携带鼠标消息;未 SETVERSION 的经典 v0 语义, + /// Win7 至今行为一致,不依赖 v4 的坐标打包)。 + const WM_APP_TRAY: u32 = windows_sys::Win32::UI::WindowsAndMessaging::WM_APP + 1; + /// STATE.agents 变了:重读并 NIM_MODIFY 图标/气泡提示。 + const WM_APP_REFRESH: u32 = windows_sys::Win32::UI::WindowsAndMessaging::WM_APP + 2; + /// STATE.enabled 变了:NIM_ADD / NIM_DELETE。 + const WM_APP_SET: u32 = windows_sys::Win32::UI::WindowsAndMessaging::WM_APP + 3; + /// 进程收尾:删图标 + 退出消息泵(不删的话图标要等用户悬停才消失)。 + const WM_APP_SHUTDOWN: u32 = windows_sys::Win32::UI::WindowsAndMessaging::WM_APP + 4; + + /// 菜单命令 id 的起点;`MENU_AGENT_BASE + i` = 聚焦第 i 个 agent。 + const MENU_AGENT_BASE: usize = 1000; + /// 「显示 Nebula」——没有 agent 时托盘仍要能把窗口捞回来。 + const MENU_SHOW: usize = 1; + /// GPUI 驻留:hide 之后可能只剩托盘,需要一条真退出。旧壳没有此项。 + const MENU_QUIT: usize = 2; + + struct Shared { + agents: Vec, + enabled: bool, + } + + static PROXY: OnceLock> = OnceLock::new(); + /// GPUI 主窗没有 winit `EventLoopProxy`:点击托盘时走命令回调。 + static GPUI_COMMAND: OnceLock> = OnceLock::new(); + static STATE: Mutex = Mutex::new(Shared { agents: Vec::new(), enabled: false }); + /// 托盘窗口句柄(isize 形态跨线程存取;0 = 线程未起或窗口未建)。 + static HWND: AtomicIsize = AtomicIsize::new(0); + static THREAD_STARTED: AtomicBool = AtomicBool::new(false); + + fn state() -> std::sync::MutexGuard<'static, Shared> { + // 中毒只表示某线程带锁 panic;清单本身是普通数据,继续用。 + STATE.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// 装好事件代理。真正的线程按需(首次启用)才起。 + pub fn init(proxy: EventLoopProxy) { + let _ = PROXY.set(proxy); + } + + /// GPUI 主窗:点击托盘时发 [`super::GpuiTrayCommand`]。 + #[cfg_attr(not(feature = "gpui-shell"), allow(dead_code))] + pub fn init_gpui(on_command: impl Fn(super::GpuiTrayCommand) + Send + Sync + 'static) { + let _ = GPUI_COMMAND.set(Box::new(on_command)); + } + + /// 开/关托盘图标(设置·高级)。开 = 保证线程在跑并 NIM_ADD; + /// 关 = 只删图标,线程留着(重开是高频往复,不值得反复建窗)。 + pub fn set_enabled(enabled: bool) { + state().enabled = enabled; + if enabled { + ensure_thread(); + } + post(WM_APP_SET); + } + + /// 发布最新 agent 清单。内容没变就不打扰托盘线程(1 Hz 调用方无需去抖)。 + pub fn update(agents: Vec) { + { + let mut shared = state(); + if !shared.enabled || shared.agents == agents { + return; + } + shared.agents = agents; + } + post(WM_APP_REFRESH); + } + + /// 进程退出前删掉图标。best-effort:托盘线程没起过就什么都不用做。 + pub fn shutdown() { + post(WM_APP_SHUTDOWN); + } + + fn post(message: u32) { + let hwnd = HWND.load(Ordering::Acquire); + if hwnd != 0 { + // SAFETY: hwnd 由托盘线程发布且只在 WM_APP_SHUTDOWN 后失效; + // PostMessageW 对已销毁窗口安全失败。 + unsafe { + windows_sys::Win32::UI::WindowsAndMessaging::PostMessageW(hwnd as _, message, 0, 0); + } + } + } + + fn ensure_thread() { + if THREAD_STARTED.swap(true, Ordering::SeqCst) { + return; + } + if let Err(err) = std::thread::Builder::new().name("nebula-tray".into()).spawn(tray_thread) + { + THREAD_STARTED.store(false, Ordering::SeqCst); + log::warn!("tray: failed to spawn tray thread: {err}"); + } + } + + // ─── 托盘线程 ──────────────────────────────────────────────────────── + + /// 托盘线程本地资源:两个状态图标 + 图标是否已挂在通知区。 + struct Icons { + normal: isize, + attention: isize, + added: bool, + } + + thread_local! { + static ICONS: RefCell> = const { RefCell::new(None) }; + } + + fn tray_thread() { + use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + CW_USEDEFAULT, CreateWindowExW, DispatchMessageW, GetMessageW, MSG, RegisterClassW, + TranslateMessage, WNDCLASSW, WS_OVERLAPPED, + }; + + let class_name = wide("NebulaTrayWindow"); + // SAFETY: 类名/实例句柄在调用期间有效;重复注册返回 0 也无妨—— + // 单进程只会走到这里一次(THREAD_STARTED 闸)。 + unsafe { + let hinstance = GetModuleHandleW(std::ptr::null()); + let wc = WNDCLASSW { + style: 0, + lpfnWndProc: Some(tray_wnd_proc), + cbClsExtra: 0, + cbWndExtra: 0, + hInstance: hinstance, + hIcon: std::ptr::null_mut(), + hCursor: std::ptr::null_mut(), + hbrBackground: std::ptr::null_mut(), + lpszMenuName: std::ptr::null(), + lpszClassName: class_name.as_ptr(), + }; + RegisterClassW(&wc); + // 隐藏顶层窗口(不是 message-only:通知区回调对 HWND_MESSAGE + // 窗口在部分 Windows 版本上不投递)。永不 ShowWindow。 + let hwnd = CreateWindowExW( + 0, + class_name.as_ptr(), + wide("Nebula Tray").as_ptr(), + WS_OVERLAPPED, + CW_USEDEFAULT, + CW_USEDEFAULT, + 0, + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + hinstance, + std::ptr::null(), + ); + if hwnd.is_null() { + log::warn!("tray: CreateWindowExW failed; tray icon disabled"); + return; + } + HWND.store(hwnd as isize, Ordering::Release); + + let icons = build_icons(); + ICONS.with(|slot| { + *slot.borrow_mut() = Some(Icons { + normal: icons.0 as isize, + attention: icons.1 as isize, + added: false, + }) + }); + // init 与首次 set_enabled 之间可能已经积累了状态:起跑先对齐。 + apply_enabled(hwnd); + + let mut msg: MSG = std::mem::zeroed(); + while GetMessageW(&mut msg, std::ptr::null_mut(), 0, 0) > 0 { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } + } + + unsafe extern "system" fn tray_wnd_proc( + hwnd: windows_sys::Win32::Foundation::HWND, + msg: u32, + wparam: usize, + lparam: isize, + ) -> isize { + use windows_sys::Win32::UI::WindowsAndMessaging::{ + DefWindowProcW, PostQuitMessage, WM_LBUTTONUP, WM_RBUTTONUP, + }; + match msg { + WM_APP_TRAY => { + match lparam as u32 { + // 左键 = 最短路径:直达最需要人的 pane。 + WM_LBUTTONUP => focus_best(), + WM_RBUTTONUP => show_menu(hwnd), + _ => {}, + } + 0 + }, + WM_APP_REFRESH => { + refresh_icon(hwnd); + 0 + }, + WM_APP_SET => { + apply_enabled(hwnd); + 0 + }, + WM_APP_SHUTDOWN => { + remove_icon(hwnd); + // SAFETY: 本线程的消息泵,退出后线程结束。 + unsafe { PostQuitMessage(0) }; + 0 + }, + // SAFETY: 转发给默认过程,参数原样透传。 + _ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }, + } + } + + /// 左键/菜单兜底的聚焦策略:先挑等输入的,再挑任意 agent,最后裸窗口。 + fn focus_best() { + let target = { + let shared = state(); + shared + .agents + .iter() + .find(|agent| agent.needs_attention) + .or_else(|| shared.agents.first()) + .cloned() + }; + send_focus(target); + } + + fn send_focus(agent: Option) { + if let Some(command) = GPUI_COMMAND.get() { + command(super::GpuiTrayCommand::Focus(agent.as_ref().map(|agent| agent.pane))); + return; + } + let Some(proxy) = PROXY.get() else { return }; + let event = match agent { + Some(agent) => { + Event::new(EventType::FocusWindow { pane: Some(agent.pane) }, agent.window) + }, + // 没有 agent:把第一扇窗捞到前台(processor 对 None 窗口 id 的 + // FocusWindow 就是这个语义)。 + None => Event::new(EventType::FocusWindow { pane: None }, None), + }; + let _ = proxy.send_event(event); + } + + fn show_menu(hwnd: windows_sys::Win32::Foundation::HWND) { + use windows_sys::Win32::Foundation::POINT; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + AppendMenuW, CreatePopupMenu, DestroyMenu, GetCursorPos, MF_GRAYED, MF_SEPARATOR, + MF_STRING, SetForegroundWindow, TPM_NONOTIFY, TPM_RETURNCMD, TPM_RIGHTBUTTON, + TrackPopupMenu, WM_NULL, + }; + + // 菜单期间用快照:TrackPopupMenu 是模态泵,STATE 可能被 1 Hz 更新, + // 命令 id 必须映射回打开菜单那一刻的清单。 + let agents = state().agents.clone(); + + // SAFETY: 菜单句柄本函数创建/销毁;文案缓冲活过 AppendMenuW(菜单 + // 复制内容)。SetForegroundWindow + WM_NULL 是 TrackPopupMenu 在 + // 托盘场景的官方仪式(否则菜单点外部不收合)。 + unsafe { + let menu = CreatePopupMenu(); + if menu.is_null() { + return; + } + if agents.is_empty() { + let text = wide("没有正在运行的 agent"); + AppendMenuW(menu, MF_STRING | MF_GRAYED, 0, text.as_ptr()); + } + for (index, agent) in agents.iter().enumerate() { + // 状态点进文案:菜单不画自定义图形,实心/空心圈已经把 + // 「等你」和「在跑」分开。 + let mark = if agent.needs_attention { "● " } else { "○ " }; + let suffix = if agent.needs_attention { " — 等待输入" } else { "" }; + let text = wide(&format!("{mark}{}{suffix}", agent.label)); + AppendMenuW(menu, MF_STRING, MENU_AGENT_BASE + index, text.as_ptr()); + } + AppendMenuW(menu, MF_SEPARATOR, 0, std::ptr::null()); + let show = wide("显示 Nebula"); + AppendMenuW(menu, MF_STRING, MENU_SHOW, show.as_ptr()); + // 旧壳没有托盘「退出」:真退出是 window+detached 都空。GPUI hide + // 之后可能只剩托盘,所以只在 GPUI 回调路径上加这一项。 + if GPUI_COMMAND.get().is_some() { + let quit = wide("退出 Nebula"); + AppendMenuW(menu, MF_STRING, MENU_QUIT, quit.as_ptr()); + } + + let mut point = POINT { x: 0, y: 0 }; + GetCursorPos(&mut point); + SetForegroundWindow(hwnd); + let cmd = TrackPopupMenu( + menu, + TPM_RIGHTBUTTON | TPM_RETURNCMD | TPM_NONOTIFY, + point.x, + point.y, + 0, + hwnd, + std::ptr::null(), + ) as usize; + windows_sys::Win32::UI::WindowsAndMessaging::PostMessageW(hwnd, WM_NULL, 0, 0); + DestroyMenu(menu); + + if cmd == MENU_SHOW { + send_focus(None); + } else if cmd == MENU_QUIT { + if let Some(command) = GPUI_COMMAND.get() { + command(super::GpuiTrayCommand::Quit); + } + } else if cmd >= MENU_AGENT_BASE { + send_focus(agents.get(cmd - MENU_AGENT_BASE).cloned()); + } + } + } + + // ─── 通知区图标 ────────────────────────────────────────────────────── + + fn notify_data( + hwnd: windows_sys::Win32::Foundation::HWND, + ) -> windows_sys::Win32::UI::Shell::NOTIFYICONDATAW { + // SAFETY: NOTIFYICONDATAW 是纯 POD,零值是合法起点。 + let mut data: windows_sys::Win32::UI::Shell::NOTIFYICONDATAW = + unsafe { std::mem::zeroed() }; + data.cbSize = std::mem::size_of::() as u32; + data.hWnd = hwnd; + data.uID = 1; + data + } + + /// 依据 STATE 计算图标形态并 NIM_MODIFY;未挂图标时什么都不做。 + fn refresh_icon(hwnd: windows_sys::Win32::Foundation::HWND) { + use windows_sys::Win32::UI::Shell::{NIF_ICON, NIF_TIP, NIM_MODIFY, Shell_NotifyIconW}; + + let (agent_count, attention_count) = { + let shared = state(); + let attention = shared.agents.iter().filter(|agent| agent.needs_attention).count(); + (shared.agents.len(), attention) + }; + ICONS.with(|slot| { + let slot = slot.borrow(); + let Some(icons) = slot.as_ref().filter(|icons| icons.added) else { return }; + let mut data = notify_data(hwnd); + data.uFlags = NIF_ICON | NIF_TIP; + data.hIcon = (if attention_count > 0 { icons.attention } else { icons.normal }) as _; + let tip = if agent_count == 0 { + "Nebula".to_owned() + } else if attention_count > 0 { + format!("Nebula — {attention_count} 个 agent 等待输入") + } else { + format!("Nebula — {agent_count} 个 agent 运行中") + }; + copy_tip(&mut data.szTip, &tip); + // SAFETY: data 完整初始化且 hwnd 属于本线程。 + unsafe { Shell_NotifyIconW(NIM_MODIFY, &data) }; + }); + } + + fn apply_enabled(hwnd: windows_sys::Win32::Foundation::HWND) { + use windows_sys::Win32::UI::Shell::{ + NIF_ICON, NIF_MESSAGE, NIF_TIP, NIM_ADD, Shell_NotifyIconW, + }; + + let enabled = state().enabled; + ICONS.with(|slot| { + let mut slot = slot.borrow_mut(); + let Some(icons) = slot.as_mut() else { return }; + if enabled && !icons.added { + let mut data = notify_data(hwnd); + data.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP; + data.uCallbackMessage = WM_APP_TRAY; + data.hIcon = icons.normal as _; + copy_tip(&mut data.szTip, "Nebula"); + // SAFETY: 同上;失败(如 explorer 未起)留 added=false, + // 下一次 WM_APP_SET 重试。 + if unsafe { Shell_NotifyIconW(NIM_ADD, &data) } != 0 { + icons.added = true; + } + } else if !enabled && icons.added { + let data = notify_data(hwnd); + // SAFETY: 删除只需要 hWnd/uID。 + unsafe { + windows_sys::Win32::UI::Shell::Shell_NotifyIconW( + windows_sys::Win32::UI::Shell::NIM_DELETE, + &data, + ) + }; + icons.added = false; + } + }); + if enabled { + refresh_icon(hwnd); + } + } + + fn remove_icon(hwnd: windows_sys::Win32::Foundation::HWND) { + ICONS.with(|slot| { + let mut slot = slot.borrow_mut(); + if let Some(icons) = slot.as_mut().filter(|icons| icons.added) { + let data = notify_data(hwnd); + // SAFETY: 同 apply_enabled 的删除分支。 + unsafe { + windows_sys::Win32::UI::Shell::Shell_NotifyIconW( + windows_sys::Win32::UI::Shell::NIM_DELETE, + &data, + ) + }; + icons.added = false; + } + }); + } + + // ─── 图标像素 ──────────────────────────────────────────────────────── + + /// 从内嵌 logo 造两个 HICON:常态 + attention(右下角橙点)。 + /// 失败退化为空句柄——Shell_NotifyIconW 会用系统默认图标,托盘仍可用。 + fn build_icons() -> ( + windows_sys::Win32::UI::WindowsAndMessaging::HICON, + windows_sys::Win32::UI::WindowsAndMessaging::HICON, + ) { + use windows_sys::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_CXSMICON}; + + // SAFETY: GetSystemMetrics 无前置条件。 + let size = unsafe { GetSystemMetrics(SM_CXSMICON) }.clamp(16, 64) as u32; + let Ok(decoded) = image::load_from_memory(LOGO_PNG) else { + log::warn!("tray: embedded logo failed to decode"); + return (std::ptr::null_mut(), std::ptr::null_mut()); + }; + let base = image::imageops::resize( + &decoded.to_rgba8(), + size, + size, + image::imageops::FilterType::Lanczos3, + ); + let normal = create_icon(size, base.as_raw()); + + let mut attention_pixels = base.clone(); + draw_attention_dot(&mut attention_pixels, size); + let attention = create_icon(size, attention_pixels.as_raw()); + (normal, attention) + } + + /// 右下角实心橙点(约 5/8 直径比)。挑警示橙而不是主题 accent:托盘 + /// 图标不知道主题,且橙点在亮暗任务栏上对比都够。 + fn draw_attention_dot(pixels: &mut image::RgbaImage, size: u32) { + let radius = (size as f32) * 0.30; + let center = (size as f32) - radius - 0.5; + for y in 0..size { + for x in 0..size { + let dx = x as f32 - center; + let dy = y as f32 - center; + let distance = (dx * dx + dy * dy).sqrt(); + // 1px 羽化边,免得小尺寸下是一颗锯齿方块。 + let coverage = (radius - distance + 0.5).clamp(0.0, 1.0); + if coverage > 0.0 { + let pixel = pixels.get_pixel_mut(x, y); + let blend = |src: u8, dst: u8| -> u8 { + (src as f32 * coverage + dst as f32 * (1.0 - coverage)) as u8 + }; + *pixel = image::Rgba([ + blend(255, pixel[0]), + blend(140, pixel[1]), + blend(0, pixel[2]), + blend(255, pixel[3]), + ]); + } + } + } + } + + /// RGBA(直 alpha)→ HICON:32bpp 预乘 BGRA DIB + 单色掩码。 + fn create_icon(size: u32, rgba: &[u8]) -> windows_sys::Win32::UI::WindowsAndMessaging::HICON { + use windows_sys::Win32::Graphics::Gdi::{ + BI_RGB, BITMAPINFO, BITMAPINFOHEADER, CreateBitmap, CreateDIBSection, DIB_RGB_COLORS, + DeleteObject, + }; + use windows_sys::Win32::UI::WindowsAndMessaging::{CreateIconIndirect, ICONINFO}; + + // SAFETY: header 描述的缓冲由 CreateDIBSection 分配并在拷贝期间有效; + // 两个位图在 CreateIconIndirect 后立即释放(图标持有自己的副本)。 + unsafe { + let mut header: BITMAPINFOHEADER = std::mem::zeroed(); + header.biSize = std::mem::size_of::() as u32; + header.biWidth = size as i32; + // 负高 = 自顶向下,行序与解码像素一致,免得手工翻转。 + header.biHeight = -(size as i32); + header.biPlanes = 1; + header.biBitCount = 32; + header.biCompression = BI_RGB; + let info = BITMAPINFO { bmiHeader: header, bmiColors: std::mem::zeroed() }; + + let mut bits: *mut core::ffi::c_void = std::ptr::null_mut(); + let color = CreateDIBSection( + std::ptr::null_mut(), + &info, + DIB_RGB_COLORS, + &mut bits, + std::ptr::null_mut(), + 0, + ); + if color.is_null() || bits.is_null() { + return std::ptr::null_mut(); + } + let out = std::slice::from_raw_parts_mut(bits.cast::(), rgba.len()); + for (dst, src) in out.chunks_exact_mut(4).zip(rgba.chunks_exact(4)) { + let alpha = src[3] as u32; + // 预乘:DWM 按 premultiplied 合成托盘图标,直 alpha 会在 + // 深色任务栏上泛白边。 + dst[0] = (src[2] as u32 * alpha / 255) as u8; + dst[1] = (src[1] as u32 * alpha / 255) as u8; + dst[2] = (src[0] as u32 * alpha / 255) as u8; + dst[3] = src[3]; + } + + let mask = CreateBitmap(size as i32, size as i32, 1, 1, std::ptr::null()); + let icon_info = + ICONINFO { fIcon: 1, xHotspot: 0, yHotspot: 0, hbmMask: mask, hbmColor: color }; + let icon = CreateIconIndirect(&icon_info); + DeleteObject(color); + DeleteObject(mask); + icon + } + } + + // ─── 小工具 ───────────────────────────────────────────────────────── + + fn wide(text: &str) -> Vec { + text.encode_utf16().chain(Some(0)).collect() + } + + /// UTF-16 截断拷贝进 szTip(128 wchar,含 NUL)。 + fn copy_tip(tip: &mut [u16; 128], text: &str) { + let mut cursor = 0; + for unit in text.encode_utf16() { + if cursor >= tip.len() - 1 { + break; + } + tip[cursor] = unit; + cursor += 1; + } + tip[cursor] = 0; + } +} diff --git a/nebula_app/tests/file_line_budget.rs b/nebula_app/tests/file_line_budget.rs index 5d223eb..a18fae9 100644 --- a/nebula_app/tests/file_line_budget.rs +++ b/nebula_app/tests/file_line_budget.rs @@ -19,8 +19,8 @@ const GRANDFATHERED: &[(&str, usize)] = &[ // 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), + // 2026-08-17 下调(3600 → 3550):网络区拆到 network_settings.rs。 + ("nebula_app/src/gpui_shell/settings_pane.rs", 3550), ("nebula_app/src/display/command_palette.rs", 3980), ("nebula_app/src/window_context.rs", 3800), ("nebula_app/src/event.rs", 3780), diff --git a/nebula_config/Cargo.toml b/nebula_config/Cargo.toml index f90084e..dc6b949 100644 --- a/nebula_config/Cargo.toml +++ b/nebula_config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula_config" -version = "1.0.0" +version = "1.1.0" license = "GPL-3.0-or-later" description = "Nebula configuration abstractions" homepage = "https://github.com/Kuddev/nebula" @@ -14,5 +14,5 @@ serde = "1.0.163" toml.workspace = true [dev-dependencies] -nebula_config_derive = { version = "1.0.0", path = "../nebula_config_derive" } +nebula_config_derive = { version = "1.1.0", path = "../nebula_config_derive" } serde = { version = "1.0.163", features = ["derive"] } diff --git a/nebula_config_derive/Cargo.toml b/nebula_config_derive/Cargo.toml index 4a53765..315e03e 100644 --- a/nebula_config_derive/Cargo.toml +++ b/nebula_config_derive/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula_config_derive" -version = "1.0.0" +version = "1.1.0" license = "GPL-3.0-or-later" description = "Failure resistant deserialization derive" homepage = "https://github.com/Kuddev/nebula" @@ -18,7 +18,7 @@ syn = { version = "2.0.16", features = ["derive", "parsing", "proc-macro", "prin [dev-dependencies.nebula_config] path = "../nebula_config" -version = "1.0.0" +version = "1.1.0" [dev-dependencies] log = "0.4.11" diff --git a/nebula_gpui/ARCHITECTURE.md b/nebula_gpui/ARCHITECTURE.md index 1dbc979..1c21c7a 100644 --- a/nebula_gpui/ARCHITECTURE.md +++ b/nebula_gpui/ARCHITECTURE.md @@ -76,7 +76,7 @@ gpui-component = "=0.5.1" gpui-component-assets = "=0.5.1" ``` -Do not use wildcard versions, Git `main`, or the tty7 forks. GPUI and +Do not use wildcard versions, Git `main`, or third-party GPUI forks. GPUI and gpui-component must resolve to one GPUI version; verify each dependency change with: diff --git a/nebula_settings/Cargo.toml b/nebula_settings/Cargo.toml index 505cf8b..428f482 100644 --- a/nebula_settings/Cargo.toml +++ b/nebula_settings/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula-settings" -version = "1.0.0" +version = "1.1.0" license = "GPL-3.0-or-later" description = "Shared reader for Nebula runtime user settings (nebula_settings.txt) and theme terminal-color tables" publish = false diff --git a/nebula_settings/src/lib.rs b/nebula_settings/src/lib.rs index 527ac28..46d1fb4 100644 --- a/nebula_settings/src/lib.rs +++ b/nebula_settings/src/lib.rs @@ -579,6 +579,46 @@ impl VcsDisplayName { } } +/// 终端 BEL(`^G`)的通知方式:关 / 闪烁 / 声音 / 两者。 +/// 缺省 `Both`:旧壳给 AI CLI 回合结束的可听提示默认是开的。 +#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] +pub enum BellModeName { + None, + Visual, + Audible, + #[default] + Both, +} + +impl BellModeName { + pub fn from_settings(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "none" | "off" => Some(Self::None), + "visual" => Some(Self::Visual), + "audible" => Some(Self::Audible), + "both" => Some(Self::Both), + _ => None, + } + } + + pub fn settings_value(self) -> &'static str { + match self { + Self::None => "none", + Self::Visual => "visual", + Self::Audible => "audible", + Self::Both => "both", + } + } + + pub fn visual(self) -> bool { + matches!(self, Self::Visual | Self::Both) + } + + pub fn audible(self) -> bool { + matches!(self, Self::Audible | Self::Both) + } +} + #[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] pub enum CellWidthModeName { #[default] @@ -662,6 +702,8 @@ pub struct RuntimeSettings { pub cell_width_mode: CellWidthModeName, /// 侧栏版本控制视图的数据源(auto/git/svn)。 pub vcs_display: VcsDisplayName, + /// 终端 BEL:关 / 闪烁 / 声音 / 两者(缺省两者)。 + pub bell: BellModeName, /// 新会话欢迎屏 fastfetch(默认关:启动速度优先于观感,旧壳裁定)。 pub fetch: bool, pub keep_session: bool, @@ -742,6 +784,10 @@ impl RuntimeSettings { .value("vcs_display") .and_then(VcsDisplayName::from_settings) .unwrap_or_default(), + bell: raw + .value("bell") + .and_then(BellModeName::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), @@ -823,6 +869,7 @@ mod tests { cursor_shape=beam\n\ cursor_blink=1\n\ copy_on_select=1\n\ + bell=audible\n\ cjk_bold_regular=1\n\ tab_reveal=instant\n\ density=compact\n\ @@ -853,6 +900,7 @@ mod tests { assert_eq!(settings.cursor_shape, Some(CursorShapeName::Beam)); assert_eq!(settings.cursor_blink, Some(true)); assert!(settings.copy_on_select); + assert_eq!(settings.bell, BellModeName::Audible); assert!(!settings.powerline); assert_eq!(settings.shell.as_deref(), Some("pwsh")); assert_eq!(settings.accept, AcceptKeyName::Tab); @@ -907,6 +955,21 @@ mod tests { assert_eq!(settings.sidebar_width, DEFAULT_SIDEBAR_WIDTH); assert_eq!(settings.ssh_proxy_mode, ProxyModeName::Off); assert_eq!(settings.quick_terminal_hotkey, DEFAULT_QUICK_TERMINAL_HOTKEY); + assert_eq!(settings.bell, BellModeName::Both); + } + + #[test] + fn bell_mode_parses_known_values_and_rejects_junk() { + assert_eq!(BellModeName::from_settings("none"), Some(BellModeName::None)); + assert_eq!(BellModeName::from_settings("off"), Some(BellModeName::None)); + assert_eq!(BellModeName::from_settings("visual"), Some(BellModeName::Visual)); + assert_eq!(BellModeName::from_settings("audible"), Some(BellModeName::Audible)); + assert_eq!(BellModeName::from_settings("both"), Some(BellModeName::Both)); + assert_eq!(BellModeName::from_settings("loud"), None); + assert_eq!( + RuntimeSettings::from_raw(&RawSettings::from_text("bell=loud\n")).bell, + BellModeName::Both + ); } #[test] diff --git a/nebula_split/Cargo.toml b/nebula_split/Cargo.toml index fc85d07..d9e3d48 100644 --- a/nebula_split/Cargo.toml +++ b/nebula_split/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula-split" -version = "1.0.0" +version = "1.1.0" license = "GPL-3.0-or-later" description = "Shared split-pane rules: layout tree, geometry, divider drag and directional focus" publish = false diff --git a/nebula_terminal/Cargo.toml b/nebula_terminal/Cargo.toml index 2873d40..f37ba94 100644 --- a/nebula_terminal/Cargo.toml +++ b/nebula_terminal/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula_terminal" -version = "1.0.0" +version = "1.1.0" license = "GPL-3.0-or-later" description = "Nebula terminal emulation core" readme = "../README.md" diff --git a/nebula_terminal/src/event_loop.rs b/nebula_terminal/src/event_loop.rs index 7b406bf..c5161bb 100644 --- a/nebula_terminal/src/event_loop.rs +++ b/nebula_terminal/src/event_loop.rs @@ -507,7 +507,8 @@ where // Drain everything already readable while the grid still // has the old geometry; otherwise old-width absolute CUP // output can be parsed into a new-width grid. This is the - // local equivalent of tty7's ordered Size echo. + // Drain readable bytes against the old grid before the + // size change lands, so CUP sequences keep their geometry. loop { match self.pty_read(&mut state, &mut buf, pipe.as_mut()) { Ok(processed) if processed >= MAX_LOCKED_READ => continue, @@ -520,7 +521,7 @@ where } } - // Match Windows Terminal's order: reflow the client model + // Match ConPTY's order: reflow the client model // first, then ask ConPTY to resize. ResizePseudoConsole is // synchronous; repaint bytes it produces are consumed by // the normal readable-event path below against this grid. diff --git a/nebula_terminal/src/grid/resize.rs b/nebula_terminal/src/grid/resize.rs index 2c13338..e3dc9e7 100644 --- a/nebula_terminal/src/grid/resize.rs +++ b/nebula_terminal/src/grid/resize.rs @@ -58,8 +58,8 @@ impl Grid { // times for a 120→40 column drag, and 80 more on the way back — and the // error compounds until the cursor sits in the middle of old output. // - // Windows Terminal avoids the problem by not keeping a separate tally at - // all: `TextBuffer::Reflow` rewrites the content cell by cell and reads + // ConPTY `TextBuffer::Reflow` avoids the problem by not keeping a + // separate tally at all: it rewrites the content cell by cell and reads // the cursor back off wherever its cell landed // (`newCursorPos = { AdjustToGlyphStart(oldCursorPos.x - oldX + newX), newY }`, // textBuffer.cpp:2879). The cursor rides the content. This does the same @@ -132,11 +132,8 @@ impl Grid { /// Remove lines from the visible area. /// - /// The behavior in Terminal.app and iTerm.app is to keep the cursor at the - /// bottom of the screen. This is achieved by pushing history "out the top" - /// of the terminal window. - /// - /// Nebula takes the same approach. + /// Keep the cursor at the bottom of the screen by pushing history + /// "out the top" of the terminal window. /// /// ConPTY anchors the shrink to the last written row rather than only the /// parser cursor: PSReadLine can leave a continuation hint below its cursor diff --git a/nebula_terminal/src/grid/tests.rs b/nebula_terminal/src/grid/tests.rs index da9b58b..69fb828 100644 --- a/nebula_terminal/src/grid/tests.rs +++ b/nebula_terminal/src/grid/tests.rs @@ -304,10 +304,9 @@ fn shrink_then_grow_restores_the_original_layout() { /// where it started too, or the next keystroke overwrites the wrong cell. /// /// The cursor sits right after the text, which is where a shell prompt leaves -/// it. Alacritty tracks the cursor through reflow by incremental bookkeeping +/// it. Column reflow tracks the cursor by incremental bookkeeping /// (`cursor_line_delta` and friends in `grow_columns`) rather than deriving it -/// from where the content landed, and its grid tests never assert on the cursor -/// at all — so this is the property most likely to be wrong. +/// from where the content landed — so this is the property most likely to be wrong. #[test] fn shrink_then_grow_restores_the_cursor() { let mut grid = Grid::::new(3, 6, 4); @@ -481,7 +480,7 @@ fn conpty_shrink_lines_keeps_the_last_written_row_visible() { assert_eq!(grid[Line(-2)][Column(0)], 1); } -/// ConPTY and Windows Terminal reflow the blank cell immediately before the +/// ConPTY reflows the blank cell immediately before the /// cursor even when it has the default attributes. Without it, a cursor that /// lands exactly on the new right edge is stored as pending-wrap on the old /// row, while conhost has already moved it to the next row; the next absolute diff --git a/nebula_terminal/src/render.rs b/nebula_terminal/src/render.rs index 9b001ff..58bde2c 100644 --- a/nebula_terminal/src/render.rs +++ b/nebula_terminal/src/render.rs @@ -1,4 +1,4 @@ -//! Renderer-agnostic terminal render contract (Ghostty-style layering). +//! Renderer-agnostic terminal render contract (model → viewport → frontend). //! //! Layering: `Term`/grid (terminal model) → this module (viewport protocol + //! plain-data snapshot) → a frontend renderer (today the GPUI element; later @@ -11,9 +11,9 @@ //! - A viewport is immutable once issued and carries a monotonically //! increasing `revision`: a stale resize must never override a newer one. //! - Pixel sizes reported to the PTY / applications are always the exact -//! `columns × cell_width` product (WezTerm rule), never leftover pixels. -//! - A pixel-size change is reported even when rows/cols are unchanged -//! (Ghostty rule): applications may care about pixel metrics. +//! `columns × cell_width` product, never leftover pixels. +//! - A pixel-size change is reported even when rows/cols are unchanged: +//! applications may care about pixel metrics. pub mod boxdraw; @@ -82,12 +82,12 @@ impl TerminalViewport { } } - /// Exact text-area width: `cols × cell_width` (WezTerm rule). + /// Exact text-area width: `cols × cell_width`. pub fn text_area_width_px(&self) -> u32 { u32::from(self.cols) * u32::from(self.cell_width_px) } - /// Exact text-area height: `rows × cell_height` (WezTerm rule). + /// Exact text-area height: `rows × cell_height`. pub fn text_area_height_px(&self) -> u32 { u32::from(self.rows) * u32::from(self.cell_height_px) } @@ -118,7 +118,7 @@ pub struct ViewportChange { /// Rows/cols changed: the grid and the PTY must be resized. pub grid_changed: bool, /// Device cell size changed: the PTY must be re-informed even when the - /// grid stayed the same (Ghostty rule). + /// grid stayed the same. pub pixel_changed: bool, } @@ -477,7 +477,7 @@ mod tests { assert!(second.viewport.revision > first.viewport.revision); // Same grid but larger glyphs (font size change on a fluke-equal - // grid): pixel change alone must still be reported (Ghostty rule). + // grid): pixel change alone must still be reported. let mut larger = metrics(); larger.scale = 3.0; let third = tracker.observe(911.0 / 9.0 * 9.0, 360.0, &larger).expect("pixel change"); diff --git a/nebula_terminal/src/term/mod.rs b/nebula_terminal/src/term/mod.rs index def265f..6d9dcb7 100644 --- a/nebula_terminal/src/term/mod.rs +++ b/nebula_terminal/src/term/mod.rs @@ -314,8 +314,8 @@ pub enum Osc52 { /// Whether the grid may re-merge soft-wrapped rows when it grows wider. /// /// Nebula's grid is the terminal-side source of truth for cursor/content -/// coordinates on every backend. ConPTY also reflows its private buffer, but -/// Windows Terminal's `TextBuffer::Reflow` shows that this is intentionally +/// coordinates on every backend. ConPTY also reflows its private buffer; +/// `TextBuffer::Reflow` shows that this is intentionally /// allowed: ConPTY asks the terminal for the current cursor position instead /// of replacing the terminal's reflow state. Disabling grow reflow for one /// ConPTY variant therefore leaves the grid permanently split after a narrow diff --git a/nebula_terminal/src/tty/windows/mod.rs b/nebula_terminal/src/tty/windows/mod.rs index 544d378..72486e8 100644 --- a/nebula_terminal/src/tty/windows/mod.rs +++ b/nebula_terminal/src/tty/windows/mod.rs @@ -1069,8 +1069,8 @@ mod test { } /// `-NoProfile` on the default-shell path silently skipped the user's - /// `$PROFILE`, so functions added there never loaded in a new tab while - /// Windows Terminal loaded them fine. The explicit-shell path had its own + /// `$PROFILE`, so functions added there never loaded in a new tab. + /// The explicit-shell path had its own /// guard already; this covers the path that actually regressed. #[test] fn default_powershell_loads_the_user_profile_and_ends_with_the_integration() {