feat(gpui): load markdown network images and land 1.1.0 shell parity

Register a ureq HttpClient so document tabs can fetch http(s) images. Also lands OSC 8/URL links, configurable terminal bell, and the rest of the 1.1.0 GPUI changelog.
This commit is contained in:
kuddev committed 2026-08-17 23:24:20 +08:00
1 parent 1ea860a2f7
commit 03ea868aba
82 files changed
+9693 -5648

No files matched your search

+7
View File
@@ -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
+21 -5
View File
@@ -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-<ts>.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-<ts>.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 <id>` / `codex resume <id>`) 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 <id>` / `codex resume <id>`)敲进恢复出来的 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
Generated
+7 -7
View File
@@ -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",
+7 -8
View File
@@ -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)不是就地加滚动条,而是把
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nebula-completions"
version = "1.0.0"
version = "1.1.0"
edition.workspace = true
[features]
+5 -5
View File
@@ -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"] }
+1 -1
View File
@@ -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"]
@@ -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"]
+1 -1
View File
@@ -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"]
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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]]
+1 -1
View File
@@ -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"]
+1 -1
View File
@@ -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"]
+1 -1
View File
@@ -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"]
+1 -1
View File
@@ -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]]
+1 -1
View File
@@ -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"]
+1 -1
View File
@@ -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"]
+1 -1
View File
@@ -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"]
+1 -1
View File
@@ -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"]
+1 -1
View File
@@ -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"]
+1 -1
View File
@@ -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"]
+1 -1
View File
@@ -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]]
+1 -1
View File
@@ -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"]
+1 -1
View File
@@ -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]]
+1 -1
View File
@@ -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"]
+5 -5
View File
@@ -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")
+13 -7
View File
@@ -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<AiLogo> {
/// (一个汉字 → `%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);
+55 -31
View File
@@ -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() {
+1 -1
View File
@@ -177,7 +177,7 @@ impl SizeInfo<f32> {
/// 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;
+204 -2
View File
@@ -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<str>,
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());
+21 -5
View File
@@ -182,14 +182,30 @@ pub fn imported_font_directory() -> PathBuf {
#[cfg(windows)]
pub fn imported_font_files() -> Vec<PathBuf> {
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<PathBuf> {
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::<Vec<_>>();
files.sort();
files
.collect()
}
#[cfg(windows)]
@@ -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>) {
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<Self>,
) {
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>) {
self.set_hsv_from_rgb(rgb);
self.persist_background_rgb(rgb, window, cx);
}
fn commit_hsv(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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<Self>,
) {
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<Self>) {
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<Self>) {
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<Self>,
) {
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<Pixels>,
window: &mut Window,
cx: &mut Context<Self>,
) {
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>,
) {
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<Self>,
) {
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<Self>) {
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<Self>) -> 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<Self>) -> 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<Pixels>,
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<Pixels>, 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<Pixels>, 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(),
);
}
+7 -4
View File
@@ -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) => {
+18 -24
View File
@@ -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<PathBuf> {
}
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<PathBuf> {
fn load_merged_toml(path: &Path, notice: &mut Option<String>) -> toml::Value {
let main = read_toml(path, notice);
let imports: Vec<String> = [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<String> =
[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<String>) -> 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
+15 -13
View File
@@ -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()
}),
),
)
}
+119
View File
@@ -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<u8>)>, Vec<u8>);
fn fetch_blocking(
agent: ureq::Agent,
parts: gpui::http_client::http::request::Parts,
request_bytes: Vec<u8>,
) -> HttpResult<FetchedBody> {
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<u8>)> = 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::<Self>()
}
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<AsyncBody>,
) -> futures::future::BoxFuture<'static, HttpResult<gpui::http_client::Response<AsyncBody>>>
{
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())));
}
+31 -23
View File
@@ -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::<MathAssets>();
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(),
+112 -9
View File
@@ -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<u64>),
TrayQuit,
MuxAttach,
RuntimeControl(std::sync::Arc<crate::runtime_api::RuntimeDispatch>),
}
/// 在当前线程启动 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<crate::ai_hook::AiHookEvent>,
shell_events: std::sync::mpsc::Receiver<GpuiShellEvent>,
) {
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
}
@@ -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<ProxyTestStatus> {
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<Self>) {
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<Self>) {
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<Self>) {
if matches!(event, InputEvent::Change) {
self.commit_proxy_address(cx);
}
}
pub(super) fn section_network(&mut self, cx: &mut Context<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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() })
);
}
}
+3 -1
View File
@@ -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},
File diff suppressed because it is too large. Load diff
+92 -52
View File
@@ -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::<crate::gpui_shell::config::Settings>()
.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<String> = 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)| {
+141 -52
View File
@@ -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<String>, usize)> {
) -> Option<(RenderSnapshot, Option<String>, 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::<crate::gpui_shell::math_view::MathAssets>()
.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<Pixels> {
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<Pixels>,
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<Pixels>,
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;
+40 -12
View File
@@ -87,8 +87,26 @@ fn ctrl_char(c: char) -> Option<u8> {
/// 一旦子进程要过 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<Vec<u8>> {
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<Vec<u8>> {
// 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<String> {
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]
@@ -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<T: EventListener>(
&mut self,
term: &Term<T>,
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<Option<OverlayDrawPlan>> = 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<T: EventListener>(
term: &Term<T>,
size: &SizeInfo,
default_foreground: Rgb,
) -> Vec<RenderableCell> {
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<VoidListener> {
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<VoidListener>,
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);
}
}
+2 -1
View File
@@ -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};
@@ -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<UiConfig> {
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<T: EventListener>(
term: &Term<T>,
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<T: EventListener>(
term: &Term<T>,
config: &UiConfig,
point: Point,
mods: &gpui::Modifiers,
) -> Option<HintMatch> {
hint::highlighted_at(term, config, point, winit_mouse_mods(mods))
}
pub(super) fn hover_from_hint<T: EventListener>(
term: &Term<T>,
hint: HintMatch,
rows: usize,
cols: usize,
) -> Option<LinkHover> {
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<T: EventListener>(hint: &HintMatch, term: &Term<T>, 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 使用。
},
}
}
@@ -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 由设置折算(两壳同源)。
@@ -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);
}
+353 -138
View File
@@ -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<String>,
},
Ssh { destination: String },
Ssh {
destination: String,
},
}
pub(crate) fn last_path_component(path: &str) -> Option<String> {
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<UiConfig>,
pub(super) link_hover: Option<super::osc_links::LinkHover>,
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::<Settings>() {
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::<Settings>() {
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<Self>) {
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<u8>, cx: &mut Context<Self>) {
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<Self>) {
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>) {
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<Pixels>,
@@ -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<Self>) {
let current =
cx.try_global::<Settings>().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<Self>) {
@@ -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<Self>) {
pub fn copy_selection(&mut self, notify: bool, window: &mut Window, cx: &mut Context<Self>) {
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<Self>) {
pub fn paste(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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<Pixels>,
mods: &gpui::Modifiers,
cx: &mut Context<Self>,
) {
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<Self>) {
if self.link_hover.take().is_some() {
cx.notify();
}
}
fn try_open_hovered_link(&self, cx: &Context<Self>) {
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<Self>) {
@@ -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<Self>,
) {
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()));
+16 -32
View File
@@ -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::<crate::gpui_shell::config::Settings>()
.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<Pixels>, 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<Pixels>, 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)
)
);
}
}
+1 -1
View File
@@ -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;
+71 -51
View File
@@ -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<Option<CardWallpaper>>,
/// 铺满整窗时的窗口级合成图。绝不能每帧把原图像素丢给 GPU。
window_cache: Mutex<Option<CardWallpaper>>,
}
#[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::<VisualEffects>().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::<VisualEffects>() 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::<VisualEffects>().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::<VisualEffects>().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<RenderImage>, Arc<RgbaImage>, 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<Pixels>, wp: &Wallpaper, scale: f32) -> Option<Bounds<Pixels>> {
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<Pixels>, 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<Pixels>,
window: &Window,
wp: &Wallpaper,
) -> Option<Arc<RenderImage>> {
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)
}
+184 -110
View File
@@ -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()
File diff suppressed because it is too large. Load diff
@@ -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<PopupMenu>,
position: Point<Pixels>,
_subscription: Subscription,
}
impl NebulaWorkspace {
pub(super) fn render_file_tree(&mut self, cx: &mut Context<Self>) -> 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::<crate::gpui_shell::config::Settings>()
.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<Pixels>,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<gpui::AnyElement> {
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<Self>,
) {
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<NebulaWorkspace>,
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);
});
}
}))
}
@@ -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<Self>,
) {
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<Self>) {
let settings = cx.try_global::<crate::gpui_shell::config::Settings>();
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<Self>) {
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<Self>) {
if let Some(view) = self.tabs.get(self.active).and_then(WorkspaceTab::focused_view) {
view.update(cx, |view, cx| view.paste(window, cx));
}
}
}
@@ -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<GpuiShellEvent>,
cx: &mut Context<Self>,
) {
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<Self>) {
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<RuntimeDispatch>,
cx: &mut Context<Self>,
) {
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<Self>) {
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<u64>, cx: &mut Context<Self>) {
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<Self>) {
crate::gpui_shell::reveal_all_windows(cx);
self.window_hidden = false;
}
fn quit_from_tray(&mut self, cx: &mut Context<Self>) {
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<usize> {
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<Self>) {
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<Self>,
) -> 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<crate::tray::TrayAgent> {
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
);
}
}
@@ -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<Self>,
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<Self>,
ix: usize,
terminal: bool,
ai_fork: bool,
color: Option<Rgb>,
) -> 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<Self>,
ix: usize,
current: Option<Rgb>,
) -> 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,
})));
}
}
@@ -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<OverlayScrollbar> {
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<Pixels>) -> (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<Pixels>) -> 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<Self>,
) {
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<Item = gpui::AnyElement>,
cx: &mut Context<Self>,
) -> 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<Pixels>, cx: &mut Context<Self>) {
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<Self>,
) -> Option<gpui::AnyElement> {
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);
}
}
@@ -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::<Vec<_>>();
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::<String>, "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::<CloseCommandPalette>()),
"终端聚焦时 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::<CloseCommandPalette>());
}
#[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);
}
+46 -32
View File
@@ -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<dyn Error>> {
// 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<dyn Error>> {
.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<dyn Error>> {
// 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<dyn Error>> {
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;
+8
View File
@@ -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,代价是公式变高
+67 -20
View File
@@ -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<Event>) -> Option<Self> {
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> {
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<Self> {
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<Event>) {
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<Event>) {
}
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<Event>) {
}
}
}
#[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);
}
}
+63 -11
View File
@@ -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<RuntimeDispatch>),
}
#[derive(Clone)]
enum EventSink {
Winit(EventLoopProxy<Event>),
Callback(Arc<dyn Fn(RuntimeCallback) + Send + Sync>),
}
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<RuntimeDispatch>) -> 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<Mutex<HubState>>,
@@ -646,6 +684,20 @@ pub struct RuntimeServer {
impl RuntimeServer {
pub fn spawn(proxy: EventLoopProxy<Event>, hub: RuntimeHub) -> Option<Self> {
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> {
Self::spawn_with_sink(EventSink::Callback(Arc::new(on_event)), hub)
}
fn spawn_with_sink(sink: EventSink, hub: RuntimeHub) -> Option<Self> {
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<Event>, 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<Event>, 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<Event>,
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<Event>,
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<Event>,
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(
+61 -19
View File
@@ -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<T: AsyncRead + AsyncWrite + Unpin + Send> 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<Result<String, SessionError>, 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<tokio::sync::oneshot::Receiver<ProxyTestResult>> {
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::<EventProxy>(
&jump_destination,
&jump_profile,
None,
1,
)
.await?;
let jump =
authenticated_session_at::<EventProxy>(&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 {
+2 -2
View File
@@ -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(
+612 -590
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -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),
+2 -2
View File
@@ -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"] }
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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
+63
View File
@@ -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<Self> {
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]
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"
+3 -2
View File
@@ -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.
+4 -7
View File
@@ -58,8 +58,8 @@ impl<T: GridCell + Default + PartialEq> Grid<T> {
// 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<T: GridCell + Default + PartialEq> Grid<T> {
/// 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
+3 -4
View File
@@ -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::<Cell>::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
+8 -8
View File
@@ -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");
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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() {