diff --git a/Cargo.lock b/Cargo.lock index 509edb5..2371b8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3090,7 +3090,7 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.1" -source = "git+https://github.com/Kuddev/gpui-component?rev=1b737db8f172d4d4fe1113fbde4d8bb09a4c6afe#1b737db8f172d4d4fe1113fbde4d8bb09a4c6afe" +source = "git+https://github.com/Kuddev/gpui-component?rev=0f0ab35233212f8f3277028995caf0c41e13ee6c#0f0ab35233212f8f3277028995caf0c41e13ee6c" dependencies = [ "aho-corasick", "anyhow", @@ -3129,7 +3129,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/Kuddev/gpui-component?rev=1b737db8f172d4d4fe1113fbde4d8bb09a4c6afe#1b737db8f172d4d4fe1113fbde4d8bb09a4c6afe" +source = "git+https://github.com/Kuddev/gpui-component?rev=0f0ab35233212f8f3277028995caf0c41e13ee6c#0f0ab35233212f8f3277028995caf0c41e13ee6c" dependencies = [ "anyhow", "gpui", @@ -3139,7 +3139,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/Kuddev/gpui-component?rev=1b737db8f172d4d4fe1113fbde4d8bb09a4c6afe#1b737db8f172d4d4fe1113fbde4d8bb09a4c6afe" +source = "git+https://github.com/Kuddev/gpui-component?rev=0f0ab35233212f8f3277028995caf0c41e13ee6c#0f0ab35233212f8f3277028995caf0c41e13ee6c" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 61df55e..09103de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,17 +71,9 @@ winit = { path = "third_party/winit-0.30.13" } # (= zed-industries/zed 发布 gpui 0.2.2 的 rev, # crates/gpui;上游发布时 dirty=true,若首次编译出现 # 与 registry src 的差异以 diff 为准补一个对齐 commit) -# Kuddev/gpui-component nebula @ 1b737db8f172d4d4fe1113fbde4d8bb09a4c6afe -# (基点 0f0ab352 = longbridge/gpui-component v0.5.1, -# crates/ui;上级目录 gpui-component-v0.5.1/ 有源码 -# 检出可对照) -# 补丁栈: -# 1b737db8 popover 阴影:Tailwind shadow-lg(0 10 15 -# -3 + 0 4 6 -4,两层 10% 黑)在半透明/亚克力窗口下 -# 没有不透明底稀释,读起来是"菜单下垫了一块黑板"。 -# 换成单层短偏移 + 负扩散,并尊重 Theme::shadow -# (button/input/slider 本来就这么门控)。无 upstream -# PR:数值是对着半透明宿主窗口调的。 +# Kuddev/gpui-component nebula @ 0f0ab35233212f8f3277028995caf0c41e13ee6c +# (= longbridge/gpui-component v0.5.1,crates/ui; +# 上级目录 gpui-component-v0.5.1/ 有源码检出可对照) # # 加补丁 = 提交到 fork 的 nebula 分支 → push → 更新下面的 rev(永远 pin # exact rev、不 pin branch)。每个补丁注明原因 + upstream PR 链接,上游合并 @@ -100,5 +92,5 @@ winit = { path = "third_party/winit-0.30.13" } # shaper 遇换行 panic;替换保 byte 长度,run 偏移不失效) # 本地 fork 工作拷贝在 ../gpui-component-fork(已挂 tty7 remote 可 diff)。 gpui = { git = "https://github.com/Kuddev/zed", rev = "69e2130295c2649963eb639fc70b4f2ee8ea1624" } -gpui-component = { git = "https://github.com/Kuddev/gpui-component", rev = "1b737db8f172d4d4fe1113fbde4d8bb09a4c6afe" } -gpui-component-assets = { git = "https://github.com/Kuddev/gpui-component", rev = "1b737db8f172d4d4fe1113fbde4d8bb09a4c6afe" } +gpui-component = { git = "https://github.com/Kuddev/gpui-component", rev = "0f0ab35233212f8f3277028995caf0c41e13ee6c" } +gpui-component-assets = { git = "https://github.com/Kuddev/gpui-component", rev = "0f0ab35233212f8f3277028995caf0c41e13ee6c" } diff --git a/nebula_app/src/display/chrome.rs b/nebula_app/src/display/chrome.rs index e7f29de..926e346 100644 --- a/nebula_app/src/display/chrome.rs +++ b/nebula_app/src/display/chrome.rs @@ -165,7 +165,7 @@ pub(super) fn contains_rect((rx, ry, rw, rh): (f32, f32, f32, f32), x: f32, y: f /// columns, appending an ellipsis when clipped. CJK glyphs count as two columns, /// matching how `draw_chrome_text` lays them out — callers already compute /// `max_cols` as the available pixel span divided by `cell_w`, i.e. columns. -pub(super) fn truncate_tab_label(label: &str, max_cols: usize) -> String { +pub(crate) fn truncate_tab_label(label: &str, max_cols: usize) -> String { let total: usize = label.chars().map(|c| c.width().unwrap_or(0)).sum(); if total <= max_cols { return label.to_owned(); diff --git a/nebula_app/src/display/keymap.rs b/nebula_app/src/display/keymap.rs index 417cc20..1dda9e4 100644 --- a/nebula_app/src/display/keymap.rs +++ b/nebula_app/src/display/keymap.rs @@ -20,20 +20,20 @@ use crate::config::{Action, BindingKey, BindingMode, KeyBinding, KeyLocation}; /// `QUICK_TERMINAL_ROW` 是设置页专用的全局快捷键行,不属于终端 /// `config::Action`;它放在动作行之前,保证用户在按键页打开时第一眼就能 /// 找到快速终端入口,同时仍复用同一套捕获/键帽交互。 -pub(super) const QUICK_TERMINAL_ROW: usize = 0; -pub(super) const DEFAULT_QUICK_TERMINAL_HOTKEY: &str = "ctrl+`"; +pub(crate) const QUICK_TERMINAL_ROW: usize = 0; +pub(crate) const DEFAULT_QUICK_TERMINAL_HOTKEY: &str = "ctrl+`"; -pub(super) fn editable_row_count() -> usize { +pub(crate) fn editable_row_count() -> usize { EDITABLE_ACTIONS.len() + 1 } -pub(super) fn display_stored_combo(combo: &str) -> String { +pub(crate) fn display_stored_combo(combo: &str) -> String { parse_combo(combo) .and_then(|(mods, key)| display_combo(mods, &key)) .unwrap_or_else(|| combo.to_owned()) } -pub(super) const EDITABLE_ACTIONS: &[(Action, &str, &str)] = &[ +pub(crate) const EDITABLE_ACTIONS: &[(Action, &str, &str)] = &[ // 顺序 = 设置页展示顺序:按 GROUPS 的连续区间分组(2026-08-09 学原型 // 分组裁定)。持久化按 Action 名存储,重排只影响展示,不动用户数据。 // -- 全局(快速终端行占本组第 0 行,见 QUICK_TERMINAL_ROW)-- @@ -72,7 +72,7 @@ pub(super) const EDITABLE_ACTIONS: &[(Action, &str, &str)] = &[ /// 设置页分组(无框裁定 2026-08-09:只用段标题+间距分组,不画容器)。 /// `usize` 是本组行数;区间连续覆盖全部可编辑行(含第 0 行快速终端)。 -pub(super) const GROUPS: &[(&str, &str, usize)] = &[ +pub(crate) const GROUPS: &[(&str, &str, usize)] = &[ ("全局", "Global", 6), ("标签页", "Tabs", 4), ("窗格", "Panes", 7), @@ -81,7 +81,7 @@ pub(super) const GROUPS: &[(&str, &str, usize)] = &[ ]; /// 只读展示行(无法在图形页编辑,TOML/settings 行仍可覆盖其中的表驱动键)。 -pub(super) const READONLY_ROWS: &[(&str, &str, &str)] = &[ +pub(crate) const READONLY_ROWS: &[(&str, &str, &str)] = &[ ("切换到第 N 个标签页", "Select tab N", "Alt+1..9 / Ctrl+1..9"), ("启动 Profile N", "Launch Profile N", "Ctrl+Shift+1..9"), ("贴入 AI 修复建议", "Paste AI fix suggestion", "Ctrl+."), @@ -165,7 +165,7 @@ const DIGIT_CODES: [(&str, KeyCode); 10] = [ ]; /// `ctrl+shift+t` → (mods, key)。`plus` 是 `+` 键的别名(`+` 是分隔符)。 -pub(super) fn parse_combo(combo: &str) -> Option<(ModifiersState, BindingKey)> { +pub(crate) fn parse_combo(combo: &str) -> Option<(ModifiersState, BindingKey)> { let mut mods = ModifiersState::empty(); let mut key: Option = None; for part in combo.split('+') { @@ -207,7 +207,7 @@ fn parse_key_name(lower: &str) -> Option { } /// (mods, key) → 存储格式 `ctrl+shift+t`。捕获结果与 parse 往返一致。 -pub(super) fn canonical_combo(mods: ModifiersState, key: &BindingKey) -> Option { +pub(crate) fn canonical_combo(mods: ModifiersState, key: &BindingKey) -> Option { let mut out = String::new(); if mods.control_key() { out.push_str("ctrl+"); @@ -268,7 +268,7 @@ pub(crate) fn mods_prefix(mods: ModifiersState) -> String { out } -pub(super) fn display_combo(mods: ModifiersState, key: &BindingKey) -> Option { +pub(crate) fn display_combo(mods: ModifiersState, key: &BindingKey) -> Option { let mut out = mods_prefix(mods); let name = match key { BindingKey::Scancode(physical) => DIGIT_CODES @@ -305,7 +305,7 @@ pub(super) fn display_combo(mods: ModifiersState, key: &BindingKey) -> Option Option { +pub(crate) fn parse_action(name: &str) -> Option { use serde::Deserialize as _; let name = name.trim(); if name.eq_ignore_ascii_case("unbound") { @@ -316,13 +316,13 @@ pub(super) fn parse_action(name: &str) -> Option { /// Action → 存储名(Debug 名即 variant 名;仅 unit variants 会出现在 /// 自定义表里,Debug 输出必然干净)。 -pub(super) fn action_storage_name(action: &Action) -> String { +pub(crate) fn action_storage_name(action: &Action) -> String { format!("{action:?}") } /// raw `keybind=` 行 → 自定义绑定表。非法行静默跳过(与该文件其余键的 /// 容错一致);行序即优先级(后写的行先匹配)。 -pub(super) fn build_bindings(raw: &[(String, String)]) -> Vec { +pub(crate) fn build_bindings(raw: &[(String, String)]) -> Vec { let mut bindings = Vec::new(); for (combo, action) in raw { let Some((mods, trigger)) = parse_combo(combo) else { continue }; @@ -342,7 +342,7 @@ pub(super) fn build_bindings(raw: &[(String, String)]) -> Vec { /// 一个动作当前的有效键(设置页展示用)。 /// 返回 (展示文本, 是否来自用户自定义);None = 未绑定。 -pub(super) fn effective_combo(action: &Action, custom: &[KeyBinding]) -> Option<(String, bool)> { +pub(crate) fn effective_combo(action: &Action, custom: &[KeyBinding]) -> Option<(String, bool)> { // custom 已是倒序(最新在前)。该动作的候选行必须没被更新的同键行 // 遮蔽(手编文件可能给同一个键堆多行,运行时只有最前面那条生效—— // 展示必须与匹配层口径一致,否则显示的键按下去没反应)。 @@ -447,6 +447,75 @@ pub(crate) fn capture_combo(key: &KeyEvent, mods: ModifiersState) -> CaptureOutc } } +/// GPUI 壳的捕获桥:`gpui::Keystroke` → 同一套 [`CaptureOutcome`] 语义 +/// (Esc 取消、裸 Backspace 恢复默认、纯修饰键等待、可存储组合落盘)。 +/// +/// 键名对齐:gpui 在 Windows 把命名键报成小写名("escape"/"up"/"f5"…), +/// 与 `NAMED_ALIASES` 的存储别名一一对应;单字符键即 `Key::Character`。 +/// 两处已知偏差,均不伤可编辑动作(默认表里没有带数字/Shift+符号的): +/// - 数字键报成 "1".."0",这里折回 digitN scancode,保证与旧壳存储同构; +/// - Shift+符号(gpui 直接给出变换后的字符并清掉 shift 位)只能按字符 +/// 存储,旧壳 winit 层不会命中这类行——与旧壳编辑器「无法表示 → 等待」 +/// 相比是多存了一条死绑定,可接受。 +pub(crate) fn capture_gpui(keystroke: &::gpui::Keystroke) -> CaptureOutcome { + let mods = gpui_modifiers(&keystroke.modifiers); + let key = keystroke.key.trim().to_lowercase(); + match key.as_str() { + "escape" if mods.is_empty() => return CaptureOutcome::Cancel, + "backspace" if mods.is_empty() => return CaptureOutcome::ClearCustom, + // 裸修饰键按下在 Windows 走 ModifiersChanged 而非 KeyDown,这里 + // 兜住其它平台的命名差异("control"/"super"/"meta"…)。 + "control" | "ctrl" | "shift" | "alt" | "alternate" | "super" | "meta" + | "command" | "win" | "function" | "capslock" | "numlock" | "scrolllock" => { + return CaptureOutcome::Pending; + }, + _ => {}, + } + let storage_name = match key.as_str() { + "0" => "digit0".to_owned(), + "1" => "digit1".to_owned(), + "2" => "digit2".to_owned(), + "3" => "digit3".to_owned(), + "4" => "digit4".to_owned(), + "5" => "digit5".to_owned(), + "6" => "digit6".to_owned(), + "7" => "digit7".to_owned(), + "8" => "digit8".to_owned(), + "9" => "digit9".to_owned(), + other => other.to_owned(), + }; + let Some(binding_key) = parse_key_name(&storage_name) else { + return CaptureOutcome::Pending; + }; + match canonical_combo(mods, &binding_key) { + Some(combo) => CaptureOutcome::Bind(combo), + None => CaptureOutcome::Pending, + } +} + +/// gpui `Modifiers` → winit `ModifiersState`(位语义一致,直接搬运)。 +pub(crate) fn gpui_modifiers(modifiers: &::gpui::Modifiers) -> ModifiersState { + let mut mods = ModifiersState::empty(); + if modifiers.control { + mods |= ModifiersState::CONTROL; + } + if modifiers.shift { + mods |= ModifiersState::SHIFT; + } + if modifiers.alt { + mods |= ModifiersState::ALT; + } + if modifiers.platform { + mods |= ModifiersState::SUPER; + } + mods +} + +/// gpui `Modifiers` 的修饰键前缀回显(捕获态实时显示 "Ctrl+…")。 +pub(crate) fn gpui_mods_prefix(modifiers: &::gpui::Modifiers) -> String { + mods_prefix(gpui_modifiers(modifiers)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/nebula_app/src/display/side_panel.rs b/nebula_app/src/display/side_panel.rs index ce8bc3e..b9b2074 100644 --- a/nebula_app/src/display/side_panel.rs +++ b/nebula_app/src/display/side_panel.rs @@ -435,6 +435,13 @@ impl SidePanel { self.custom_root.is_some() } + /// 目录:Git/SVN 状态所属的那一个。始终是终端当前目录,与目录树的浏览 + /// 位置(`custom_root`)无关——`refresh` 就是按这个路径抓 git 的,路径摘要 + /// 必须显示同一个值,否则用户会看到「树在 A、状态是 B」的错位。 + pub fn vcs_root(&self) -> Option<&Path> { + self.followed_cwd.as_deref().or(self.root.as_deref()) + } + pub fn root_notice(&self) -> Option<&str> { self.root_notice.as_deref() } @@ -485,11 +492,17 @@ impl SidePanel { return; } let expanded = self.expanded.clone(); + // VCS 状态始终读**终端当前目录**,不跟着目录树的浏览位置走。在树里点 + // `..` 往上翻是纯浏览动作(`custom_root`,窗口内覆盖),把仓库状态一起 + // 带走的后果是:翻到仓库外面之后 Git 视图只剩「当前目录不在 Git/SVN + // 仓库中」,而回头的入口偏偏只画在 Files 视图里。行与状态在这里解耦: + // 行看 `root`,git 看 `followed_cwd`。 + let git_root = self.followed_cwd.clone().unwrap_or_else(|| root.clone()); let running = std::sync::Arc::clone(&self.snapshot_running); let slot = std::sync::Arc::clone(&self.snapshot_slot); std::thread::spawn(move || { let rows = SidePanel::tree_rows(&root, &expanded); - let git = read_git(&root).or_else(|| read_svn(&root)); + let git = read_git(&git_root).or_else(|| read_svn(&git_root)); if let Ok(mut slot) = slot.lock() { *slot = Some(PanelSnapshot { root, rows, git }); } diff --git a/nebula_app/src/display/ssh_connect.rs b/nebula_app/src/display/ssh_connect.rs index 8795487..75a3f17 100644 --- a/nebula_app/src/display/ssh_connect.rs +++ b/nebula_app/src/display/ssh_connect.rs @@ -155,13 +155,38 @@ impl SshConnectState { self.failure.is_some() } + /// 当前阶段(GPUI 覆盖层文案用)。 + pub(crate) fn stage(&self) -> SshStage { + self.stage.clone() + } + + /// 失败原因(GPUI 覆盖层详情区用)。 + pub(crate) fn failure(&self) -> Option<&str> { + self.failure.as_deref() + } + + /// Logs 是否展开(GPUI 覆盖层用)。 + pub(crate) fn is_logs_open(&self) -> bool { + self.logs_open + } + + /// 连接日志(GPUI 覆盖层 Logs 区用)。 + pub(crate) fn logs(&self) -> &[String] { + &self.log + } + + /// 计时文本(GPUI 覆盖层状态行用)。 + pub(crate) fn elapsed_text(&self) -> String { + format!("{:.1}s", self.started.elapsed().as_secs_f32()) + } + /// 卡片是否该出现。失败态无视门槛立刻显示——用户已经在等结果了。 pub(crate) fn visible(&self) -> bool { self.failed() || self.started.elapsed() >= REVEAL_DELAY } /// 当前阶段在四格轨道上的下标。 - fn stage_index(&self) -> usize { + pub(crate) fn stage_index(&self) -> usize { match self.stage { SshStage::Resolve => 0, SshStage::Connect => 1, @@ -185,38 +210,44 @@ impl SshConnectState { self.fill += (target - self.fill) * k; } - fn elapsed_text(&self) -> String { - format!("{:.1}s", self.started.elapsed().as_secs_f32()) + /// 当前填充进度(0..3)的只读视图(GPUI 覆盖层绘制用)。 + pub(crate) fn fill_now(&self) -> f32 { + self.fill + } + + /// 粒子相位(0..1)的只读视图(GPUI 覆盖层绘制用)。 + pub(crate) fn phase_now(&self) -> f32 { + self.phase } } /// 卡片的像素布局。所有矩形都在窗口像素坐标系里,与文字共用同一套坐标 —— /// resize HUD 那次事故(框按窗口居中、文字按终端网格居中)的教训。 -struct Layout { - card: (f32, f32, f32, f32), +pub(crate) struct Layout { + pub(crate) card: (f32, f32, f32, f32), /// 主机图标方块。 - icon: (f32, f32, f32, f32), + pub(crate) icon: (f32, f32, f32, f32), /// 身份文字的左边缘与两行基线。 - name_x: f32, - name_y: f32, - meta_y: f32, + pub(crate) name_x: f32, + pub(crate) name_y: f32, + pub(crate) meta_y: f32, /// 右上角 Logs 折叠开关。 - logs_btn: (f32, f32, f32, f32), + pub(crate) logs_btn: (f32, f32, f32, f32), /// 轨道:左端节点中心、右端节点中心、纵向中心。 - rail_x0: f32, - rail_x1: f32, - rail_cy: f32, + pub(crate) rail_x0: f32, + pub(crate) rail_x1: f32, + pub(crate) rail_cy: f32, /// 阶段标签基线。 - labels_y: f32, + pub(crate) labels_y: f32, /// 状态行基线。 - status_y: f32, + pub(crate) status_y: f32, /// 失败详情基线(失败态才用)。 - detail_y: f32, + pub(crate) detail_y: f32, /// 日志区外框(展开时才有)。 - logs_area: Option<(f32, f32, f32, f32)>, + pub(crate) logs_area: Option<(f32, f32, f32, f32)>, /// 底部按钮:矩形、语义、是否主按钮。绘制与命中同源。 - buttons: Vec<((f32, f32, f32, f32), SshConnectHit, bool)>, - pad: f32, + pub(crate) buttons: Vec<((f32, f32, f32, f32), SshConnectHit, bool)>, + pub(crate) pad: f32, } /// 底部按钮的文案与语义,随状态切换:连接中只能放弃,失败后只剩收尾。 @@ -224,7 +255,7 @@ struct Layout { /// 这里**没有**「重试」——它要在同一个 tab 位上原子地重连,而 `Close` + /// `NewSsh` 两个事件按序执行会先把最后一个 tab 连窗口一起关掉。宁可少一个 /// 按钮,也不放一个按下去会出事的。 -fn button_specs( +pub(crate) fn button_specs( state: &SshConnectState, language: UiLanguage, ) -> Vec<(String, SshConnectHit, bool)> { @@ -236,7 +267,7 @@ fn button_specs( } #[allow(clippy::too_many_arguments)] -fn layout( +pub(crate) fn layout( state: &SshConnectState, size: &SizeInfo, view: (f32, f32, f32, f32), @@ -377,7 +408,7 @@ pub(super) fn covers(view: (f32, f32, f32, f32), x: f32, y: f32) -> bool { } /// 节点中心的 x 坐标。 -fn node_x(l: &Layout, index: usize) -> f32 { +pub(crate) fn node_x(l: &Layout, index: usize) -> f32 { l.rail_x0 + (l.rail_x1 - l.rail_x0) * index as f32 / 3.0 } @@ -782,13 +813,13 @@ fn text_w(text: &str, cell_w: f32, scale: f32) -> f32 { const TEXT_SAFETY: f32 = 0.94; /// 一块宽度能放下多少列。 -pub(super) fn cols_that_fit(width: f32, cell_w: f32, scale: f32) -> usize { +pub(crate) fn cols_that_fit(width: f32, cell_w: f32, scale: f32) -> usize { ((width * TEXT_SAFETY / (cell_w * scale)).floor() as usize).max(4) } /// 按列宽截断,超出部分收成省略号。日志行里那条中文系统错误消息不截断就 /// 会一路画到窗口外面去。 -pub(super) fn truncate_cols(text: &str, max_cols: usize) -> String { +pub(crate) fn truncate_cols(text: &str, max_cols: usize) -> String { let total: usize = text.chars().map(|c| c.width().unwrap_or(0)).sum(); if total <= max_cols { return text.to_owned(); @@ -1003,12 +1034,12 @@ fn s_half(scale: f32) -> f32 { } /// 列表和卡片共用的短名:有别名就用别名,否则去掉 user@ 只留主机。 -fn short_name(destination: &str) -> String { +pub(crate) fn short_name(destination: &str) -> String { let host = destination.rsplit('@').next().unwrap_or(destination); host.to_owned() } -fn stage_labels(language: UiLanguage) -> [String; 4] { +pub(crate) fn stage_labels(language: UiLanguage) -> [String; 4] { [ language.pick("解析", "Resolve").to_owned(), language.pick("连接", "Connect").to_owned(), @@ -1017,7 +1048,7 @@ fn stage_labels(language: UiLanguage) -> [String; 4] { ] } -fn stage_message(stage: &SshStage, language: UiLanguage) -> String { +pub(crate) fn stage_message(stage: &SshStage, language: UiLanguage) -> String { match stage { SshStage::Resolve => language.pick("正在解析主机…", "Resolving host…"), SshStage::Connect => language.pick("正在建立连接…", "Connecting…"), @@ -1029,12 +1060,12 @@ fn stage_message(stage: &SshStage, language: UiLanguage) -> String { .to_owned() } -fn failure_headline(language: UiLanguage) -> String { +pub(crate) fn failure_headline(language: UiLanguage) -> String { language.pick("连接失败", "Connection failed").to_owned() } /// 按列宽换行。中日韩宽字符按 2 列计,所以不能按 char 数切。 -fn wrap(text: &str, per_line: usize) -> Vec { +pub(crate) fn wrap(text: &str, per_line: usize) -> Vec { let mut lines = Vec::new(); let mut line = String::new(); let mut cols = 0usize; diff --git a/nebula_app/src/display/state.rs b/nebula_app/src/display/state.rs index 5806872..92cc2b3 100644 --- a/nebula_app/src/display/state.rs +++ b/nebula_app/src/display/state.rs @@ -211,6 +211,9 @@ pub struct NebulaPaneState { pub completion_items: Vec, /// Index of the highlighted popup row. pub completion_selected: usize, + /// 用户已接受或主动关闭弹窗的整行。只要屏幕行未变化,即使命令目录的 + /// 异步代次更新也不重新弹出;下一次真实输入会自然让行值失配并清除此项。 + pub(crate) completion_suppressed_line: Option, pub line_buf: String, pub(crate) screen_line: String, pub touched: bool, diff --git a/nebula_app/src/display/suggest_engine.rs b/nebula_app/src/display/suggest_engine.rs index c39d8bc..03b56a0 100644 --- a/nebula_app/src/display/suggest_engine.rs +++ b/nebula_app/src/display/suggest_engine.rs @@ -41,7 +41,18 @@ pub(crate) fn suggest_update( return; } - let key = format!("{}\u{0}{line}", state.cwd); + // 命令目录由后台 PowerShell 探针填充;目录从 0 变为完整集合时,即使 + // 用户没有继续输入,也必须让上一帧的“无候选”缓存失效。 + let command_generation = sources.commands.lock().map(|commands| commands.len()).unwrap_or(0); + let key = format!("{}\u{0}{line}\u{0}{command_generation}", state.cwd); + if state.completion_suppressed_line.as_deref() == Some(line.as_str()) { + state.suggestion_key = key; + state.suggestion.clear(); + state.completion_items.clear(); + state.completion_selected = 0; + return; + } + state.completion_suppressed_line = None; if key == state.suggestion_key { // Cache hit also protects an Esc-dismissed popup: dismissal clears // the items but keeps the key, so nothing reopens until the line @@ -182,7 +193,9 @@ pub(crate) fn elide_left(text: &str, max_chars: usize) -> String { /// the ghost hint (history → directory history → PATH commands → file /// system), but keeping several candidates each instead of the first hit. fn suggest_collect(sources: &SuggestSources<'_>, state: &mut NebulaPaneState, line: &str) { - const POPUP_MAX: usize = 8; + // 8 是视口行数,不是数据上限。旧实现把两者混成一个常量,候选在收集 + // 阶段就被截断,因而既画不出滚动条,Tab 也永远走不到第九项以后。 + const POPUP_LIMIT: usize = 256; const LABEL_MAX: usize = 44; let mut items: Vec = Vec::new(); @@ -193,7 +206,7 @@ fn suggest_collect(sources: &SuggestSources<'_>, state: &mut NebulaPaneState, li if items.iter().any(|seen| seen.insert == item.insert && seen.kind == item.kind) { return; } - if items.len() < POPUP_MAX { + if items.len() < POPUP_LIMIT { items.push(item); } }; @@ -221,10 +234,17 @@ fn suggest_collect(sources: &SuggestSources<'_>, state: &mut NebulaPaneState, li // PATH executables while the first token is being typed. if nebula_is_command_position(line) { if let Ok(commands) = sources.commands.lock() { - for command in nebula_command_hints(commands.as_slice(), line, POPUP_MAX) { + for command in nebula_command_hints(commands.as_slice(), line, POPUP_LIMIT) { + // 精确命令也必须成为可接受项;补一个空格既给用户明确反馈, + // 又让 Enter 只完成选择而不立刻执行命令。 + let insert = if command.len() == line.len() { + " ".to_owned() + } else { + command[line.len()..].to_owned() + }; push(&mut items, NebulaCompletionItem { label: elide_left(command, LABEL_MAX), - insert: command[line.len()..].to_owned(), + insert, kind: NebulaCompletionKind::Command, }); } @@ -250,7 +270,7 @@ fn suggest_collect(sources: &SuggestSources<'_>, state: &mut NebulaPaneState, li } else { matches }; - for candidate in matches.iter().take(POPUP_MAX) { + for candidate in matches.iter().take(POPUP_LIMIT) { let path = candidate.display_override.as_deref().unwrap_or(&candidate.path); if path.len() <= token.len() || !path.is_char_boundary(token.len()) { continue; diff --git a/nebula_app/src/gpui_shell/terminal/element.rs b/nebula_app/src/gpui_shell/terminal/element.rs index c4e5b3e..4e444b7 100644 --- a/nebula_app/src/gpui_shell/terminal/element.rs +++ b/nebula_app/src/gpui_shell/terminal/element.rs @@ -547,9 +547,90 @@ fn grid_text( used } -/// 弹窗补全列表:布局与配色对齐旧壳 `draw_completion_popup`——下方优先、 -/// 放不下上移、宽度不足先左滑再截 label;` label tag ` 两端各 1 格、中缝 -/// 2 格,选中行落在主题 accent 上。 +/// 绘制与命中测试共用的弹窗几何。旧壳的候选列表是在 cell 网格中绘制的, +/// GPUI 也必须用同一套偏移,否则鼠标看到的行和实际接受的行会错位。 +#[derive(Clone, Copy)] +pub(super) struct CompletionPopupLayout { + pub start_line: usize, + pub start_col: usize, + pub offset: usize, + pub rows: usize, + pub width: usize, + pub label_width: usize, + pub tag_width: usize, + pub selected: usize, +} + +pub(super) fn completion_popup_layout( + items: &[crate::display::NebulaCompletionItem], + selected: usize, + cursor_row: usize, + cursor_col: usize, + screen_lines: usize, + columns: usize, +) -> Option { + use unicode_width::UnicodeWidthChar as _; + + if columns < 12 || screen_lines < 2 || items.is_empty() { + return None; + } + + // 行位:优先光标下方,不够放则上方;最多显示八项。 + let below = screen_lines.saturating_sub(cursor_row + 1); + let above = cursor_row; + let want = items.len().min(8); + let (rows, start_line) = if below >= want || below >= above { + (want.min(below), cursor_row + 1) + } else { + (want.min(above), cursor_row - want.min(above)) + }; + if rows == 0 { + return None; + } + + let selected = selected.min(items.len() - 1); + let offset = if selected >= rows { selected + 1 - rows } else { 0 }; + let visible = &items[offset..(offset + rows).min(items.len())]; + let tag = |kind: crate::display::NebulaCompletionKind| -> &'static str { + match kind { + crate::display::NebulaCompletionKind::History => "历史", + crate::display::NebulaCompletionKind::Command => "命令", + crate::display::NebulaCompletionKind::Dir => "目录", + crate::display::NebulaCompletionKind::File => "文件", + } + }; + let cells_of = |text: &str| -> usize { text.chars().map(|c| c.width().unwrap_or(0)).sum() }; + let tag_width = visible.iter().map(|item| cells_of(tag(item.kind))).max().unwrap_or(0); + let label_width_max = visible.iter().map(|item| cells_of(&item.label)).max().unwrap_or(0); + + let mut start_col = cursor_col.min(columns - 1); + let mut avail = columns - start_col; + let full_width = label_width_max + tag_width + 4; + if full_width > avail { + let slide = (full_width - avail).min(start_col); + start_col -= slide; + avail += slide; + } + let width = full_width.min(avail); + let label_width = width.saturating_sub(tag_width + 4); + // 这里的 4 是窄窗口裁切后“至少还能读”的标签宽度,不是候选本身的 + // 最小长度。`cat` 这类没有更长邻居的短命令,其自然宽度只有 3;旧判 + // 定会让引擎已生成的精确候选在布局阶段直接消失。只拒绝完全没有标签 + // 空间的布局,短标签照常按自然宽度显示。 + (label_width > 0).then_some(CompletionPopupLayout { + start_line, + start_col, + offset, + rows, + width, + label_width, + tag_width, + selected, + }) +} + +/// 弹窗补全列表:下方优先、放不下上移、宽度不足先左滑再截 label; +/// 面板底和行内容都对齐主题色,选中态使用柔和 accent 水洗。 #[allow(clippy::too_many_arguments)] fn paint_completion_popup( window: &mut Window, @@ -564,27 +645,18 @@ fn paint_completion_popup( font: &gpui::Font, font_size: Pixels, ) { - use unicode_width::UnicodeWidthChar as _; let columns = layout.cols; let screen_lines = layout.rows; - if columns < 12 || screen_lines < 2 { + let Some(popup) = completion_popup_layout( + items, + selected, + cursor_row, + cursor_col, + screen_lines, + columns, + ) else { return; - } - - // 行位:优先光标下方,不够放则上方;截短时保证选中行可见。 - let below = screen_lines.saturating_sub(cursor_row + 1); - let above = cursor_row; - let want = items.len().min(8); - let (rows, start_line) = if below >= want || below >= above { - (want.min(below), cursor_row + 1) - } else { - (want.min(above), cursor_row - want.min(above)) }; - if rows == 0 { - return; - } - let offset = if selected >= rows { selected + 1 - rows } else { 0 }; - let tag = |kind: crate::display::NebulaCompletionKind| -> &'static str { match kind { crate::display::NebulaCompletionKind::History => "历史", @@ -593,44 +665,43 @@ fn paint_completion_popup( crate::display::NebulaCompletionKind::File => "文件", } }; - let cells_of = |text: &str| -> usize { text.chars().map(|c| c.width().unwrap_or(0)).sum() }; + let visible = &items[popup.offset..(popup.offset + popup.rows).min(items.len())]; - let visible = &items[offset..(offset + rows).min(items.len())]; - let tag_w = visible.iter().map(|item| cells_of(tag(item.kind))).max().unwrap_or(0); - let label_w_max = visible.iter().map(|item| cells_of(&item.label)).max().unwrap_or(0); - - let mut start_col = cursor_col.min(columns.saturating_sub(1)); - let mut avail = columns - start_col; - let full_w = label_w_max + tag_w + 4; - if full_w > avail { - // 先整体左滑保住宽度,窄 pane 再钳制。 - let slide = (full_w - avail).min(start_col); - start_col -= slide; - avail += slide; - } - let width = full_w.min(avail); - let label_w = width.saturating_sub(tag_w + 4); - if label_w < 4 { - return; - } + // 四周留出少量呼吸边,圆角和边框不会压到候选文字。 + let panel_pad = px(4.0); + let panel_origin = point( + bounds.origin.x + layout.cell_width * popup.start_col as f32 - panel_pad, + bounds.origin.y + layout.line_height * popup.start_line as f32 - panel_pad, + ); + let panel_bounds = Bounds::new( + panel_origin, + size( + layout.cell_width * popup.width as f32 + panel_pad * 2.0, + layout.line_height * popup.rows as f32 + panel_pad * 2.0, + ), + ); + window.paint_quad(fill(panel_bounds, colors.panel_bg).corner_radii(px(8.0))); + window.paint_quad( + outline(panel_bounds, colors.panel_border, gpui::BorderStyle::Solid).corner_radii(px(8.0)), + ); for (row, item) in visible.iter().enumerate() { - let line = start_line + row; + let line = popup.start_line + row; if line >= screen_lines { break; } - let is_selected = offset + row == selected; + let is_selected = popup.offset + row == popup.selected; let (bg, label_fg, tag_fg) = if is_selected { (colors.selected_bg, colors.selected_fg, colors.selected_fg) } else { (colors.row_bg, colors.row_fg, colors.tag_fg) }; let origin = point( - bounds.origin.x + layout.cell_width * start_col as f32, + bounds.origin.x + layout.cell_width * popup.start_col as f32, bounds.origin.y + layout.line_height * line as f32, ); window.paint_quad(fill( - Bounds::new(origin, size(layout.cell_width * width as f32, layout.line_height)), + Bounds::new(origin, size(layout.cell_width * popup.width as f32, layout.line_height)), bg, )); grid_text( @@ -643,9 +714,9 @@ fn paint_completion_popup( point(origin.x + layout.cell_width, origin.y), layout.cell_width, layout.line_height, - label_w, + popup.label_width, ); - let tag_start = width.saturating_sub(1 + tag_w); + let tag_start = popup.width.saturating_sub(1 + popup.tag_width); grid_text( window, cx, @@ -656,7 +727,56 @@ fn paint_completion_popup( point(origin.x + layout.cell_width * tag_start as f32, origin.y), layout.cell_width, layout.line_height, - tag_w, + popup.tag_width, + ); + } + + if items.len() > popup.rows { + // 滚动条覆在面板右侧 4px 呼吸边内,不占终端字符列,也不遮住 tag。 + // 拇指跟随可见窗口的 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 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), + ); + 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; + let thumb_h = (track_h * visible_ratio).max(px(14.0)).min(track_h); + let max_offset = items.len() - popup.rows; + let progress = popup.offset.min(max_offset) as f32 / max_offset as f32; + let thumb_top = track_top + (track_h - thumb_h) * progress; + window.paint_quad( + fill( + Bounds::new(point(content_right + px(1.0), thumb_top), size(px(2.0), thumb_h)), + colors.scroll_thumb, + ) + .corner_radii(px(1.0)), ); } } + +#[cfg(test)] +mod tests { + use super::completion_popup_layout; + use crate::display::{NebulaCompletionItem, NebulaCompletionKind}; + + #[test] + fn popup_keeps_a_single_short_exact_command_visible() { + let items = [NebulaCompletionItem { + label: "cat".to_owned(), + insert: " ".to_owned(), + kind: NebulaCompletionKind::Command, + }]; + + let popup = completion_popup_layout(&items, 0, 2, 7, 24, 80) + .expect("短命令的精确候选也必须形成可见面板"); + assert_eq!(popup.rows, 1); + assert_eq!(popup.label_width, 3); + } +} diff --git a/nebula_app/src/gpui_shell/terminal/mod.rs b/nebula_app/src/gpui_shell/terminal/mod.rs index bc1d67e..9b4bd06 100644 --- a/nebula_app/src/gpui_shell/terminal/mod.rs +++ b/nebula_app/src/gpui_shell/terminal/mod.rs @@ -10,6 +10,7 @@ pub mod mouse_protocol; pub mod session; pub mod suggest; pub mod view; +mod ssh_connect_overlay; use gpui::{App, KeyBinding}; diff --git a/nebula_app/src/gpui_shell/terminal/session.rs b/nebula_app/src/gpui_shell/terminal/session.rs index 38a7c6e..92cdb5e 100644 --- a/nebula_app/src/gpui_shell/terminal/session.rs +++ b/nebula_app/src/gpui_shell/terminal/session.rs @@ -15,6 +15,7 @@ use nebula_terminal::grid::Dimensions; use nebula_terminal::sync::FairMutex; use nebula_terminal::term::Config; use nebula_terminal::tty; +use nebula_terminal::tty::EventedPty as _; /// 把 PTY/SSH 线程发出的终端事件转投到 GPUI 前台的异步通道。本地与 SSH /// 会话共用同一形状:`stages` 只有 SSH 业务层会写(连接阶段横幅的数据 @@ -62,6 +63,9 @@ impl Dimensions for GridSize { pub struct TerminalSession { pub term: Arc>>, pub notifier: Notifier, + /// PTY 直系 shell PID;关闭确认沿用旧壳 `busy_child(shell_pid)` 判据。 + /// SSH 没有本地 shell 进程,固定为 0。 + pub shell_pid: u32, } /// 一次会话 spawn 的完整出口:会话句柄 + 终端事件流 + SSH 阶段流 @@ -98,6 +102,7 @@ pub fn spawn( options.env.insert(crate::ai_hook::PANE_ENV.to_owned(), pane_id.to_string()); tty::setup_env(); let pty = tty::new(&options, window_size, 0)?; + let shell_pid = pty.child_pid().unwrap_or(0); // NEBULA_PTY_RECORD=1 复用 ref_test 的 conout 录制(./nebula.recording), // 用于 resize 锚定问题的字节级取证。 @@ -106,7 +111,7 @@ pub fn spawn( let notifier = Notifier(event_loop.channel()); let _io_thread = event_loop.spawn(); - Ok((TerminalSession { term, notifier }, rx, stage_rx)) + Ok((TerminalSession { term, notifier, shell_pid }, rx, stage_rx)) } /// 启动一个 SSH 直连会话(russh,与旧壳 `create_ssh_pane` 同一业务层): @@ -129,5 +134,5 @@ pub fn spawn_ssh( let sender = crate::ssh_session::spawn_session(destination, window_size, Arc::clone(&term), proxy)?; - Ok((TerminalSession { term, notifier: Notifier(sender) }, rx, stage_rx)) + Ok((TerminalSession { term, notifier: Notifier(sender), shell_pid: 0 }, rx, stage_rx)) } diff --git a/nebula_app/src/gpui_shell/terminal/ssh_connect_overlay.rs b/nebula_app/src/gpui_shell/terminal/ssh_connect_overlay.rs new file mode 100644 index 0000000..6b7d6eb --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/ssh_connect_overlay.rs @@ -0,0 +1,561 @@ +//! GPUI 形态的 SSH 连接卡片:状态机、阶段文案、几何常量全部直接复用 +//! `display::ssh_connect`(两壳同一份语义),只有绘制层换成 GPUI 的 +//! canvas/div。视觉合同(卡片结构、轨道三态节点、粒子流、Logs 折叠、 +//! 失败即展开日志)逐条对齐旧壳 push_quads/draw_text。 +//! +//! 与旧壳的已知等价替换: +//! - `UiQuad::gradient`(进度条渐变)→ 24 段实心(2px 高的轨道上不可辨) +//! - `UiQuad::glow`(粒子/节点外溢)→ 同心低 alpha 圆 +//! - 等宽 cell 度量(`SizeInfo`)→ UI 基准字号(同一设置源) + +use gpui::{ + AnyElement, Bounds, Context, Hsla, InteractiveElement as _, + IntoElement, MouseButton, ParentElement as _, Pixels, SharedString, + StatefulInteractiveElement as _, Styled as _, canvas, div, fill, hsla, point, + px, size, +}; + +use crate::display::ssh_connect::{self, SshConnectState, stage_labels, stage_message}; +use crate::gpui_shell::prelude::*; + +/// UiLanguage 由设置折算(两壳同源)。 +pub(super) fn language() -> crate::display::UiLanguage { + let runtime = nebula_settings::RuntimeSettings::load(); + crate::display::LanguagePreference::parse(runtime.language.settings_value()) + .unwrap_or_default() + .resolved() +} + +fn hsla_from_rgba(r: u8, g: u8, b: u8) -> Hsla { + let (rf, gf, bf) = (f32::from(r) / 255.0, f32::from(g) / 255.0, f32::from(b) / 255.0); + let max = rf.max(gf).max(bf); + let min = rf.min(gf).min(bf); + let l = (max + min) / 2.0; + if (max - min).abs() < f32::EPSILON { + return hsla(0.0, 0.0, l, 1.0); + } + let d = max - min; + let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) }; + let h = if (max - rf).abs() < f32::EPSILON { + (gf - bf) / d + (if gf < bf { 6.0 } else { 0.0 }) + } else if (max - gf).abs() < f32::EPSILON { + (bf - rf) / d + 2.0 + } else { + (rf - gf) / d + 4.0 + }; + hsla(h / 6.0, s, l, 1.0) +} + +fn lerp_hsla(a: Hsla, b: Hsla, k: f32) -> Hsla { + let k = k.clamp(0.0, 1.0); + hsla( + a.h + (b.h - a.h) * k, + a.s + (b.s - a.s) * k, + a.l + (b.l - a.l) * k, + 1.0, + ) +} + +/// 品牌紫→青(旧壳 palette.edge_l/edge_r;浅色主题压暗 0.28)。 +fn brand_pair(cx: &gpui::App) -> (Hsla, Hsla) { + let chrome = crate::gpui_shell::theme::chrome_theme_resolved(cx); + let sk = chrome.skin(); + let palette = chrome.palette(); + let darken = |v: crate::renderer::ui::Rgba| -> Hsla { + let mut h = hsla_from_rgba(v.r, v.g, v.b); + if sk.is_light { + h.l *= 0.72; + h.s *= 0.9; + } + h + }; + (darken(palette.edge_l), darken(palette.edge_r)) +} + +/// 轨道(底轨 + 渐变填充 + 粒子 + 节点)的 canvas 绘制。坐标全部是 +/// canvas 局部逻辑 px:rail 两端各让半个节点(旧 `rail_x0/x1` 相对卡片 +/// 内容缘的同一条公式,pad 已由外层卡片 padding 吃掉)。 +pub(super) fn rail_canvas( + state: &SshConnectState, + cx: &gpui::App, + theme: &gpui_component::Theme, +) -> AnyElement { + let (brand_l, brand_r) = brand_pair(cx); + let light = !theme.is_dark(); + let danger = theme.danger; + let accent = theme.link; + let hairline = theme.border; + let panel = theme.popover; + let state = state.clone(); + + canvas( + move |_, _, _| {}, + move |bounds, _, window, _| { + let w = f32::from(bounds.size.width); + let node_d = 10.0_f32; + let x0 = node_d * 0.5; + let x1 = (w - node_d * 0.5).max(x0 + 1.0); + let ox = f32::from(bounds.origin.x); + let oy = f32::from(bounds.origin.y); + let cy = oy + f32::from(bounds.size.height) * 0.5; + + 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))), + ); + }; + + // 底轨 + let rail_h = 2.0; + quad(x0, cy - rail_h * 0.5, x1 - x0, rail_h, rail_h * 0.5, hairline); + + let node_x = |i: usize| x0 + (x1 - x0) * i as f32 / 3.0; + + // 已完成段:品牌渐变拆 24 段实心。 + let fill_to = node_x(0) + (x1 - x0) * (state.fill_now() / 3.0); + let fill_w = (fill_to - x0).max(0.0); + if fill_w > 0.5 { + if state.failed() { + quad(x0, cy - rail_h * 0.5, fill_w, rail_h, rail_h * 0.5, danger); + } else { + let steps = 24; + let seg_w = fill_w / steps as f32; + for s in 0..steps { + let t1 = (s + 1) as f32 / steps as f32; + let color = lerp_hsla(brand_l, brand_r, t1); + quad( + x0 + fill_w * (s as f32 / steps as f32), + cy - rail_h * 0.5, + seg_w + 0.5, + rail_h, + rail_h * 0.5, + color, + ); + } + } + } + + // 粒子(旧 PARTICLE_* 常量与 ease 曲线照搬) + if !state.failed() { + let seg = state.stage_index().min(2); + let from = node_x(seg); + let to = node_x(seg + 1); + let span = (x1 - x0).max(1.0); + let ease = |s: f32| 0.35 * s + 0.65 * (s * s * (3.0 - 2.0 * s)); + for i in 0..5usize { + let head = + (state.phase_now() + [0.0f32, 0.17, 0.31, 0.52, 0.63][i]).fract(); + for k in (0..=5usize).rev() { + let t = head - k as f32 * 0.032; + if !(0.0..=1.0).contains(&t) { + continue; + } + let x = from + (to - from) * ease(t); + let fade = 1.0 - k as f32 / (5.0 + 1.2); + let edge = (t / 0.14).min((1.0 - t) / 0.14).min(1.0); + let alpha = fade * fade * edge * if light { 0.55 } else { 0.9 }; + if alpha < 0.012 { + continue; + } + let color = + lerp_hsla(brand_l, brand_r, ((x - x0) / span).clamp(0.0, 1.0)); + let cr = if k == 0 { 1.55 } else { 1.3 * fade + 0.2 }; + let mut col = color; + col.a = alpha * if k == 0 { 1.0 } else { 0.78 }; + if !light { + let rad = if k == 0 { 5.4 } else { 4.4 * fade + 1.2 }; + let mut g = color; + g.a = alpha * 0.22; + quad(x - rad, cy - rad, rad * 2.0, rad * 2.0, rad, g); + } + quad(x - cr, cy - cr, cr * 2.0, cr * 2.0, cr, col); + } + } + } + + // 节点三态 + let done_upto = state.fill_now().floor() as usize; + let active = state.stage_index(); + for i in 0..4usize { + let d = node_d; + let x = node_x(i) - d * 0.5; + let y = cy - d * 0.5; + if state.failed() && i == active { + quad(x, y, d, d, d * 0.5, danger); + } else if i <= done_upto && i < active { + quad(x, y, d, d, d * 0.5, brand_r); + } else if i == active { + if !light { + let mut g = accent; + g.a = 0.45; + quad(x - 3.0, y - 3.0, d + 6.0, d + 6.0, (d + 6.0) * 0.5, g); + } + quad(x, y, d, d, d * 0.5, accent); + } else { + quad(x, y, d, d, d * 0.5, hairline); + let inset = 1.5; + quad( + x + inset, + y + inset, + d - inset * 2.0, + d - inset * 2.0, + (d - inset * 2.0) * 0.5, + panel, + ); + } + } + }, + ) + .h(px(10.0)) + .w_full() + .into_any_element() +} + +/// 整张卡片的 GPUI 组装。文字与按钮是真元素(命中/hover 由 GPUI 负责), +/// 轨道与粒子走 [`rail_canvas`]。垂直堆叠逐项对应旧 `layout()` 的内容 +/// 度量(icon 40 → rail → caption → status → detail → logs → buttons)。 +pub(super) fn overlay( + state: &SshConnectState, + cx: &Context, +) -> AnyElement { + use gpui::prelude::FluentBuilder as _; + + let theme = cx.theme(); + let lang = language(); + let ui_px = cx + .try_global::() + .map(|settings| settings.font_size_px) + .unwrap_or(15.0); + let family: SharedString = cx + .try_global::() + .map(|settings| settings.font_family.clone()) + .unwrap_or_else(|| "Cascadia Mono".to_owned()) + .into(); + let ink_strong = theme.sidebar_accent_foreground; + let ink = theme.foreground; + let ink_dim = theme.muted_foreground; + let ink_faint = crate::gpui_shell::theme::faint_ink(cx); + let danger = theme.danger; + let panel = theme.popover; + let card_bg = theme.group_box; + let hairline = theme.border; + let accent = theme.link; + + // ── 身份行:机架图标 + 两行文字 + Logs 按钮 ── + let rack_ink = ink_dim; + let rack_panel = panel; + let rack = canvas( + move |_, _, _| {}, + move |bounds, _, window, _| { + // 服务器机架:两层机箱 + 指示灯(旧 push_quads 的矢量复刻)。 + let cxp = f32::from(bounds.origin.x) + f32::from(bounds.size.width) * 0.5; + let cyp = f32::from(bounds.origin.y) + f32::from(bounds.size.height) * 0.5; + let mut quad = |x: f32, y: f32, w: f32, h: f32, r: f32, bg: Hsla| { + window.paint_quad( + fill( + Bounds::new(point(px(x), px(y)), size(px(w.max(0.0)), px(h.max(0.0)))), + bg, + ) + .corner_radii(px(r.max(0.0))), + ); + }; + let (bw, bh, gap) = (18.0f32, 7.5f32, 3.0f32); + let (stroke, r) = (1.4f32, 2.0f32); + for row in [-1.0f32, 1.0] { + let by = cyp + row * (bh + gap) * 0.5 - bh * 0.5; + let bx = cxp - bw * 0.5; + quad(bx, by, bw, bh, r, rack_ink); + quad( + bx + stroke, + by + stroke, + bw - stroke * 2.0, + bh - stroke * 2.0, + (r - stroke).max(0.0), + rack_panel, + ); + let d = 2.4; + quad( + bx + stroke + 2.2, + by + bh * 0.5 - d * 0.5, + d, + d, + d * 0.5, + rack_ink, + ); + } + }, + ) + .size(px(24.0)) + .into_any_element(); + + let name: SharedString = ssh_connect::short_name(state.destination()).into(); + let meta: SharedString = format!("SSH · {}", state.destination()).into(); + let logs_target = cx.entity().downgrade(); + let logs_label: SharedString = + format!("Logs {}", if state.is_logs_open() { "⌃" } else { "⌄" }).into(); + let identity = h_flex() + .gap(px(16.0)) + .items_center() + .child( + div() + .size(px(40.0)) + .rounded(px(6.0)) + .border_1() + .border_color(hairline) + .bg(card_bg) + .flex() + .items_center() + .justify_center() + .child(rack), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap(px(2.0)) + .child( + div() + .font_family(family.clone()) + .text_size(px(ui_px)) + .text_color(ink_strong) + .truncate() + .child(name), + ) + .child( + div() + .font_family(family.clone()) + .text_size(px(ui_px * 0.80)) + .text_color(ink_dim) + .truncate() + .child(meta), + ), + ) + .child( + // 可交互按钮统一走 gpui-component:命中、Hover、键盘焦点与 + // 主题 token 都由组件库负责,不在业务卡片里维护第二套按钮。 + Button::new("ssh-connect-logs") + .label(logs_label) + .outline() + .small() + .on_click(move |_, _, cx| { + if let Some(view) = logs_target.upgrade() { + view.update(cx, |this, cx| { + if let Some(state) = &mut this.ssh_connect { + state.toggle_logs(); + cx.notify(); + } + }); + } + }), + ); + + // ── 轨道 + 阶段标签(首尾贴齐轨道两端,中间以节点为中心)── + let labels = stage_labels(lang); + let active = state.stage_index(); + let caption_h = ui_px * 0.75; + let rail_block = v_flex() + .w_full() + .gap(px(8.0)) + .mt(px(24.0)) + .child(rail_canvas(state, cx, &theme)) + .child( + div().relative().h(px(caption_h)).children( + labels + .iter() + .enumerate() + .map(|(i, label)| { + let ink_i = if state.failed() && i == active { + danger + } else if i == active { + ink + } else if i < active { + ink_dim + } else { + ink_faint + }; + let text = div() + .font_family(family.clone()) + .text_size(px(caption_h)) + .text_color(ink_i) + .child(label.clone()); + match i { + 0 => div().absolute().left(px(0.0)).child(text).into_any_element(), + 3 => div().absolute().right(px(0.0)).child(text).into_any_element(), + // 中间:零宽锚点 + 140px 居中槽(节点近似中心) + _ => div() + .absolute() + .left(gpui::relative(if i == 1 { + 0.328 + } else { + 0.659 + })) + .w(px(0.0)) + .child( + div() + .w(px(140.0)) + .ml(px(-70.0)) + .flex() + .justify_center() + .child(text), + ) + .into_any_element(), + } + }) + .collect::>(), + ), + ); + + // ── 状态行 ── + let (msg, msg_ink) = if state.failed() { + (ssh_connect::failure_headline(lang), danger) + } else { + (stage_message(&state.stage(), lang), ink) + }; + let status_row = h_flex() + .w_full() + .mt(px(24.0)) + .justify_between() + .child( + div() + .font_family(family.clone()) + .text_size(px(ui_px)) + .text_color(msg_ink) + .child(msg), + ) + .when(!state.failed(), |row| { + row.child( + div() + .font_family(family.clone()) + .text_size(px(ui_px)) + .text_color(ink_faint) + .child(state.elapsed_text()), + ) + }); + + // ── 失败详情(两行,超出收省略号)── + let per_line = 44usize; + let detail = state.failure().map(|reason| { + v_flex() + .mt(px(8.0)) + .gap(px(ui_px * 0.28)) + .children( + ssh_connect::wrap(reason, per_line) + .into_iter() + .take(2) + .map(|line| { + div() + .font_family(family.clone()) + .text_size(px(ui_px * 0.80)) + .text_color(ink_dim) + .child(ssh_connect::truncate_cols(&line, per_line)) + }) + .collect::>(), + ) + }); + + // ── Logs 区(末尾 6 行,最新在最下)── + let logs_area = state.is_logs_open().then(|| { + let line_h = ui_px * 0.80; + let logs = state.logs(); + let start = logs.len().saturating_sub(6); + v_flex() + .mt(px(24.0)) + .rounded(px(6.0)) + .border_1() + .border_color(hairline) + .bg(card_bg) + .p(px(12.0)) + .gap(px(2.0)) + .children( + logs[start..] + .iter() + .map(|line| { + let color = if line.contains("error") { danger } else { ink_dim }; + div() + .font_family(family.clone()) + .text_size(px(line_h)) + .text_color(color) + .truncate() + .child(line.clone()) + }) + .collect::>(), + ) + }); + + // ── 底部按钮(旧裁定:没有重试;连接中=取消,失败=关闭=主按钮)── + let action_label: SharedString = if state.failed() { + lang.pick("关闭", "Close").into() + } else { + lang.pick("取消", "Cancel").into() + }; + let failed = state.failed(); + let action_target = cx.entity().downgrade(); + let buttons = h_flex() + .w_full() + .mt(px(24.0)) + .justify_end() + .child( + Button::new("ssh-connect-action") + .label(action_label) + .small() + .map(|button| if failed { button.primary() } else { button.outline() }) + .on_click(move |_, _, cx| { + if let Some(view) = action_target.upgrade() { + view.update(cx, |this, cx| { + // 取消与关闭是同一个动作(旧裁定):这个 pane + // 除了这条连接没有别的内容。 + this.ssh_connect = None; + cx.emit(super::view::TerminalViewEvent::RequestClose); + cx.notify(); + }); + } + }), + ); + + // ── 组装 ── + div() + .id("ssh-connect-overlay") + .absolute() + .inset_0() + .occlude() + // 模态:卡片在场时吞掉一切点击(旧 covers 合同) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + // 遮罩:pane 底色整块盖住(连接期间 grid 是空的,跟着重绘会闪) + .child(div().absolute().inset_0().bg(panel)) + .child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .p(px(24.0)) + .child( + v_flex() + .w(px(600.0)) + .max_w(gpui::relative(1.0)) + .p(px(24.0)) + .rounded(px(8.0)) + .border_1() + .border_color(hairline) + .bg(panel) + .shadow_md() + .child(identity) + .child(rail_block) + .child(status_row) + .when_some(detail, |card, d| card.child(d)) + .when_some(logs_area, |card, d| card.child(d)) + .child(buttons), + ), + ) + .into_any_element() +} diff --git a/nebula_app/src/gpui_shell/terminal/suggest.rs b/nebula_app/src/gpui_shell/terminal/suggest.rs index 45edc39..672a0ff 100644 --- a/nebula_app/src/gpui_shell/terminal/suggest.rs +++ b/nebula_app/src/gpui_shell/terminal/suggest.rs @@ -67,6 +67,11 @@ pub fn commit_line(state: &mut NebulaPaneState) { if !line.is_empty() { shared().history.record(&line, &state.cwd); } + // 旧壳 `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 }; crate::display::Display::nebula_clear_line(state); } @@ -107,6 +112,10 @@ pub fn popup_dismiss(state: &mut NebulaPaneState) -> bool { if state.completion_items.is_empty() { return false; } + let line = if state.screen_line.is_empty() { &state.line_buf } else { &state.screen_line }; + if !line.is_empty() { + state.completion_suppressed_line = Some(line.clone()); + } state.completion_items.clear(); state.completion_selected = 0; true diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index 99028c9..650a735 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -1,6 +1,5 @@ //! 终端视图:持有会话、处理输入与 IME、驱动重绘。 -use gpui::prelude::FluentBuilder as _; use gpui::{ App, Bounds, ClipboardItem, Context, EventEmitter, FocusHandle, Focusable, Font, FontFeatures, FontStyle, FontWeight, Hsla, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, @@ -21,7 +20,7 @@ use nebula_terminal::vte::ansi::CursorStyle; use std::sync::Arc; use super::colors::Palette; -use super::element::TerminalElement; +use super::element::{TerminalElement, completion_popup_layout}; use super::keymap; use super::mouse_protocol; use super::session::{self, TerminalSession}; @@ -93,11 +92,22 @@ pub enum TerminalViewEvent { /// 用户在本视图内按下鼠标:分屏宿主据此更新聚焦 pane(键盘焦点已由 /// 视图自己 `window.focus` 拿走,这里只同步宿主的 pane 级焦点记账)。 FocusRequested, + /// 连接卡片的取消/关闭(旧壳 TabRequest::Close 的对应物)。 + RequestClose, } /// 会话种类:本地 shell 或 SSH 直连(russh,共享旧壳业务层)。 +/// +/// `shell` 是 Tab 创建/恢复时已经裁定好的启动命令。不能只传 cwd 再在 +/// `TerminalView::new` 里读取“此刻的默认 Shell”,否则恢复混合的 PowerShell / +/// WSL 工作区时,每个 Tab 都会被重新解释成同一个当前默认值。 pub enum TerminalLaunch { - Local { cwd: Option }, + Local { + cwd: Option, + shell: Option, + /// 只用于欢迎屏选择 PowerShell/Bash 形态,不参与 PTY 启动。 + shell_name: Option, + }, Ssh { destination: String }, } @@ -134,10 +144,21 @@ pub struct TerminalView { pub branch: String, /// 标题协议第 4 字段:远端 `nebula ssh` 上报的运行中程序,本地为空。 pub running_program: Option, + /// OSC 133;C 起、133;D 止:命令正在执行。自带 PowerShell prompt 靠 + /// `NEBULA|` 标题报 `running_program`,装了 OSC133 集成的 bash/zsh/nu 只 + /// 发这一对语义标记——两条路都要能点亮侧栏的「运行中」。 + command_running: bool, + /// OSC 9 程序通知,攒到 render(那里才有 `Window` 判聚焦)再呈现。 + pending_notify: Vec, /// SSH 直连目的地(`user@host[:port]`);本地会话为 None。 pub ssh_destination: Option, /// SSH 连接阶段(业务层上报):Ready 前画连接横幅,Failed 驻留错误。 ssh_stage: Option, + /// SSH 连接卡片状态(旧壳 `display::ssh_connect::SshConnectState` 的 + /// 直接复用:阶段日志、粒子相位、进度插值、350ms 显示门槛都在里面)。 + pub(super) ssh_connect: Option, + /// 上一次动画帧时刻(卡片 step 的 delta 来源)。 + ssh_connect_last_step: std::time::Instant, /// Hook-reported identity for exact resume/fork. Process/title inference is /// never accepted here because a wrong id would continue the wrong chat. pub ai_session: Option, @@ -395,15 +416,25 @@ 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 (ssh_destination, initial_title, spawned) = match launch { - TerminalLaunch::Local { cwd } => ( + let (ssh_destination, initial_title, intro_shell_name, spawned) = match launch { + TerminalLaunch::Local { cwd, shell: launch_shell, shell_name } => ( None, String::from("shell"), - session::spawn(initial, term_config, shell, pane_id, cwd), + shell_name, + session::spawn( + initial, + term_config, + // 显式 launch(会话恢复/创建时冻结)优先;只有旧会话没有 + // 身份时才回退当前设置。这正是共享 v4 的 Default 语义。 + launch_shell.or(shell), + pane_id, + cwd, + ), ), TerminalLaunch::Ssh { destination } => ( Some(destination.clone()), destination.clone(), + None, session::spawn_ssh(destination, initial, term_config), ), }; @@ -418,7 +449,11 @@ impl TerminalView { let runtime = nebula_settings::RuntimeSettings::load(); if runtime.fetch && !is_ssh { use nebula_terminal::event::Notify as _; - let id = runtime.shell.as_deref().unwrap_or_default().to_ascii_lowercase(); + let id = intro_shell_name + .as_deref() + .or(runtime.shell.as_deref()) + .unwrap_or_default() + .to_ascii_lowercase(); let intro_shell = if id.contains("wsl") || id.contains("bash") { crate::display::NebulaShell::Bash } else { @@ -465,7 +500,37 @@ impl TerminalView { while let Some(stage) = stage_rx.next().await { if this .update(cx, |view: &mut Self, cx| { - view.ssh_stage = Some(stage); + view.ssh_stage = Some(stage.clone()); + // 连接卡片状态机(旧壳 ssh_connect_stage + // 同款合同):Ready 移除;Resolve 开新卡 + // 片;其余阶段推进。中途断线不会让用 + // 了半天的终端凭空复活一张卡片。 + match stage { + crate::ssh_session::SshStage::Ready => { + view.ssh_connect = None; + }, + other => { + match &mut view.ssh_connect { + Some(state) => state.set_stage(other), + None => { + if matches!( + other, + crate::ssh_session::SshStage::Resolve + ) { + if let Some(dest) = + view.ssh_destination.clone() + { + view.ssh_connect = Some( + crate::display::ssh_connect::SshConnectState::new(dest), + ); + view.ssh_connect_last_step = + std::time::Instant::now(); + } + } + }, + } + }, + } cx.notify(); }) .is_err() @@ -508,8 +573,12 @@ impl TerminalView { cwd: String::new(), branch: String::new(), running_program: None, + command_running: false, + pending_notify: Vec::new(), ssh_destination, ssh_stage: None, + ssh_connect: None, + ssh_connect_last_step: std::time::Instant::now(), ai_session: None, error, exited: None, @@ -604,7 +673,73 @@ impl TerminalView { TermEvent::Exit => { self.mark_exited(String::from("会话已结束"), cx); }, - // 垂直切片不处理:铃声、cwd 上报、内联图片、OSC133、AI hook 等。 + TermEvent::CwdReport(cwd) => { + // 标准 OSC 7 / 9;9 的目录上报。只动 cwd,`NEBULA|` 标题带来的 + // branch/program 保持不变——两条通道并存(旧壳 event.rs:3142 + // 同合同)。少了这一条,只有自带 PowerShell prompt 的 tab 能被 + // 目录树/git 跟随,bash/zsh/nu 一律跟不动。 + let cwd = cwd.trim().to_owned(); + if !cwd.is_empty() && self.cwd != cwd { + self.cwd = cwd; + self.suggest.cwd = self.cwd.clone(); + super::suggest::record_directory(&self.cwd); + cx.notify(); + } + }, + TermEvent::CommandStart => { + // 程序身份(旧壳 event.rs CommandStart 分支原样):从 Enter + // 时捕获的命令行提取首 token,AgentKind 认得的归一成 slug。 + // 这是侧栏 AI 图标对「直接敲 codex/grok 而没装 hook」的 + // 会话也点亮的那条路——hook 信封只是更准的覆盖层。 + 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) + }); + if identity != self.running_program { + self.running_program = identity; + cx.emit(TerminalViewEvent::TitleChanged); + } + self.command_running = true; + cx.notify(); + }, + // 退出码先只用来结束「运行中」;失败命令的未读标记要等 + // `SidebarActivity` 扩出未读态再接,现在记下来也没有呈现位置。 + TermEvent::CommandDone { exit_code: _ } => { + // 旧壳同款收尾:CLI 退回提示符后,它不再是这个 pane 的前台 + // 事实——hook 稍后若仍在跑会重新点亮(handle_ai_hook 覆写)。 + if self.running_program.take().is_some() || self.ai_session.take().is_some() { + cx.emit(TerminalViewEvent::TitleChanged); + } + self.command_running = false; + cx.notify(); + }, + TermEvent::Notify(body) => { + // 程序自己发的 OSC 9 通知:旧壳只在窗口没聚焦时才递出去 + // (event.rs:3291),聚焦时用户本来就在看这个 pane。这里攒着, + // render 拿到 `Window` 才判聚焦。 + let body = body.trim().to_owned(); + if !body.is_empty() { + self.pending_notify.push(body); + cx.notify(); + } + }, + TermEvent::AiHookEnvelope(envelope) => { + // SSH pane 里的 agent 靠私有 OSC 把 hook 信封带回本地(本地 + // agent 走 workspace 的 ai_events 通道)。信封在 event_loop 里 + // 已核过通道令牌,这里解析出来喂进同一个应用路径。 + // + // hook 自报客户端名(claude/codex),是**程序身份的权威来源** + // ——比 OSC 133 的命令行嗅探准,后者漏掉包装启动和没有 shell + // 集成的会话。少了这一条,远端 agent 的侧栏图标和会话身份 + // (fork/恢复要用)全都缺席。 + if let Some(hook) = crate::ai_hook::parse_remote_envelope(&envelope, None) { + self.handle_ai_hook(&hook, cx); + } + }, + // 仍未接线:内联图片(要图片管线把 abs_line 锚到网格)、OSC 1337 + // UserVar(AI 查询拦截)、铃声。 _ => {}, } } @@ -625,6 +760,20 @@ impl TerminalView { } } + /// 旧壳 `WindowContext::busy_process_in` 的单 Pane 形态:进程树只负责 + /// 判断 shell 下面是否仍有子进程;展示名称优先采用终端协议识别出的 + /// 程序名,避免把 Claude Code 一律显示成承载它的 `node.exe`。 + pub fn busy_process(&self) -> Option { + let shell_pid = self.session.as_ref()?.shell_pid; + if shell_pid == 0 { + 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) + })) + } + /// Apply one lifecycle event already routed to this pane by the workspace. pub fn handle_ai_hook(&mut self, event: &crate::ai_hook::AiHookEvent, cx: &mut Context) { // SessionEnd 可能是第一次携带最终权威 id 的事件;必须先落盘再清理 @@ -666,7 +815,8 @@ impl TerminalView { ) { SidebarActivity::Failed - } else if self.running_program.is_some() + } else if self.command_running + || self.running_program.is_some() || self.ssh_stage.as_ref().is_some_and(|stage| { !matches!(stage, crate::ssh_session::SshStage::Ready) }) @@ -1209,10 +1359,10 @@ impl TerminalView { if plain { if suggest::popup_active(&self.suggest) { match ks.key.as_str() { - "down" | "up" => { + "down" | "up" | "tab" => { suggest::popup_move( &mut self.suggest, - if ks.key.as_str() == "down" { 1 } else { -1 }, + if ks.key.as_str() == "up" { -1 } else { 1 }, ); cx.notify(); cx.stop_propagation(); @@ -1225,19 +1375,18 @@ impl TerminalView { return; } }, - key @ ("tab" | "right") if suggest::accepts(self.accept, key) => { - if let Some(insert) = suggest::popup_take(&mut self.suggest) { - if !insert.is_empty() { - for c in insert.chars() { - crate::display::Display::nebula_input_char( - &mut self.suggest, - c, - ); - } - self.write_input(insert.into_bytes(), cx); - cx.stop_propagation(); - return; - } + "enter" => { + if self.accept_completion_popup(cx) { + cx.notify(); + cx.stop_propagation(); + return; + } + }, + "right" if suggest::accepts(self.accept, "right") => { + if self.accept_completion_popup(cx) { + cx.notify(); + cx.stop_propagation(); + return; } }, _ => {}, @@ -1495,6 +1644,78 @@ impl TerminalView { } } + /// 像素命中与绘制共用同一份候选几何,截短列表或上翻时也不会点错行。 + fn completion_popup_hit(&self, position: Point) -> Option { + let (cursor_row, cursor_col) = self.suggest_anchor?; + let popup = completion_popup_layout( + &self.suggest.completion_items, + self.suggest.completion_selected, + cursor_row, + cursor_col, + self.rows, + self.cols, + )?; + let left = self.origin.x + self.cell_width * popup.start_col as f32; + let top = self.origin.y + self.line_height * popup.start_line as f32; + let right = left + self.cell_width * popup.width as f32; + let bottom = top + self.line_height * popup.rows as f32; + if position.x < left || position.x >= right || position.y < top || position.y >= bottom { + return None; + } + let row = ((position.y - top).as_f32() / self.line_height.as_f32().max(1.0)) as usize; + let index = popup.offset + row; + (index < self.suggest.completion_items.len()).then_some(index) + } + + /// 右侧窄条不覆盖候选字符列;点击轨道按指针比例跳到完整候选集中的位置。 + fn completion_popup_scrollbar_target(&self, position: Point) -> Option { + let (cursor_row, cursor_col) = self.suggest_anchor?; + let popup = completion_popup_layout( + &self.suggest.completion_items, + self.suggest.completion_selected, + cursor_row, + cursor_col, + self.rows, + self.cols, + )?; + let len = self.suggest.completion_items.len(); + if len <= popup.rows { + return None; + } + 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 + || position.x >= content_right + px(5.0) + || position.y < top + || position.y >= top + height + { + return None; + } + 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) + } + + /// 接受当前候选时只写入补全余量。即使余量为空也算已处理,Enter 不能 + /// 继续透传成一次命令执行。 + fn accept_completion_popup(&mut self, cx: &mut Context) -> bool { + let Some(insert) = suggest::popup_take(&mut self.suggest) else { return false }; + let before = if self.suggest.screen_line.is_empty() { + self.suggest.line_buf.clone() + } else { + self.suggest.screen_line.clone() + }; + for c in insert.chars() { + crate::display::Display::nebula_input_char(&mut self.suggest, c); + } + self.suggest.completion_suppressed_line = Some(format!("{before}{insert}")); + if !insert.is_empty() { + self.write_input(insert.into_bytes(), cx); + } + true + } + fn on_mouse_down( &mut self, event: &MouseDownEvent, @@ -1506,6 +1727,31 @@ impl TerminalView { if self.session.is_none() { return; } + if event.button == MouseButton::Left + && let Some(index) = self.completion_popup_scrollbar_target(event.position) + { + self.suggest.completion_selected = index; + cx.notify(); + cx.stop_propagation(); + return; + } + if event.button == MouseButton::Left + && let Some(index) = self.completion_popup_hit(event.position) + { + self.suggest.completion_selected = index; + if self.accept_completion_popup(cx) { + cx.notify(); + cx.stop_propagation(); + return; + } + } + // 面板外第一次点击只负责关闭浮层,避免同一击又在终端里开选区或触发 + // TUI 鼠标协议;缓存当前行,行不变时浮层不会下一帧原地复活。 + if suggest::popup_dismiss(&mut self.suggest) { + cx.notify(); + cx.stop_propagation(); + return; + } // 滚动条是壳的控件,命中优先于选区和鼠标上报——否则在开了鼠标追踪的 // TUI 里(codex/vim)根本抓不住条。贴底时拇指为 None,正常操作零影响。 let (display_offset, history) = self.scroll_state(); @@ -1553,6 +1799,17 @@ impl TerminalView { _window: &mut Window, cx: &mut Context, ) { + // 候选行 hover 与键盘高亮共用选中索引;这样鼠标所见即 Enter 所收。 + // 移出面板时保留最后选择,继续按 Tab 不会突然跳回第一项。 + if event.pressed_button.is_none() + && let Some(index) = self.completion_popup_hit(event.position) + { + if self.suggest.completion_selected != index { + self.suggest.completion_selected = index; + cx.notify(); + } + return; + } // 拖条中:指针的整段轨迹都归滚动条,不进选区也不上报。 if let Some(grab) = self.scrollbar_drag { if event.pressed_button != Some(MouseButton::Left) { @@ -1641,6 +1898,11 @@ impl TerminalView { ) { window.focus(&self.focus_handle); cx.emit(TerminalViewEvent::FocusRequested); + if suggest::popup_dismiss(&mut self.suggest) { + cx.notify(); + cx.stop_propagation(); + return; + } if self.session.is_none() { return; } @@ -1690,6 +1952,11 @@ impl TerminalView { ) { window.focus(&self.focus_handle); cx.emit(TerminalViewEvent::FocusRequested); + if suggest::popup_dismiss(&mut self.suggest) { + cx.notify(); + cx.stop_propagation(); + return; + } if self.mouse_mode_active(&event.modifiers) { self.send_mouse_report( event.position, @@ -1720,6 +1987,20 @@ impl TerminalView { cx: &mut Context, ) { let delta_y = event.delta.pixel_delta(self.line_height).y.as_f32(); + if suggest::popup_active(&self.suggest) + && (self.completion_popup_hit(event.position).is_some() + || self.completion_popup_scrollbar_target(event.position).is_some()) + { + if delta_y != 0.0 { + // 系统会把一格滚轮编码成“滚动 N 行”(Windows 常见为 3), + // 终端回滚区需要尊重这个倍率,候选选择器则不应该:一次滚轮 + // 手势只跨一个 item,否则 8 行视口会瞬间跳过半屏。 + suggest::popup_move(&mut self.suggest, if delta_y > 0.0 { -1 } else { 1 }); + cx.notify(); + } + cx.stop_propagation(); + return; + } self.scroll_px += delta_y; let lines = (self.scroll_px / self.line_height.as_f32().max(1.0)).trunc() as i32; if lines == 0 { @@ -1865,11 +2146,34 @@ impl gpui::EntityInputHandler for TerminalView { } impl Render for TerminalView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // OSC 9 程序通知:旧壳只在窗口没聚焦时递出去,聚焦时用户本来就在看这 + // 个 pane。这里是最早能同时拿到 `Window` 和 view 可变引用的地方。用驻留 + // 式 banner 而不是 toast——人不在看的时候弹一条 5 秒自动消失的提示等于 + // 没弹。推送 defer 到本轮 effect 之后,不在 render 中途改 `Root`。 + if !self.pending_notify.is_empty() { + let notes = std::mem::take(&mut self.pending_notify); + if !window.is_window_active() { + window.defer(cx, move |window, cx| { + for body in notes { + crate::gpui_shell::toast::banner( + window, + cx, + crate::display::ToastKind::Info, + body, + ); + } + }); + } + } let mut root = div() .id("nebula-terminal") .key_context(KEY_CONTEXT) .size_full() + // SSH 连接卡片使用 absolute + inset_0;本 pane 必须成为它的 + // containing block,否则坐标会逃到工作区/窗口根,轨道和粒子 + // 就会出现在终端卡片之外甚至窗口底部。 + .relative() .overflow_hidden() // 不画自己的背景:卡容器统一负责"卡底色(带窗口透明度)→ // 壁纸"两层,这里再铺一层不透明 bg 会把它们全部盖死。 @@ -1896,34 +2200,26 @@ impl Render for TerminalView { root = root.child(div().p_4().text_color(gpui::red()).child(error.clone())); } else { root = root.child(TerminalElement::new(cx.entity())); - // SSH 连接阶段横幅(右上角,Ready 即撤):每个取值对应业务层 - // 一个真实调用点(resolve → connect → auth → open shell), - // 不是估算进度。失败原因由业务层写进 grid,横幅只留短标。 - if let Some(stage) = &self.ssh_stage { - use crate::ssh_session::SshStage; - let (label, failed): (Option, bool) = match stage { - SshStage::Resolve => (Some("解析地址…".into()), false), - SshStage::Connect => (Some("建立连接…".into()), false), - SshStage::Authenticate => (Some("认证…".into()), false), - SshStage::OpenShell => (Some("打开远端终端…".into()), false), - SshStage::Ready => (None, false), - SshStage::Failed(_) => (Some("连接失败".into()), true), - }; - if let Some(label) = label { - let host = self.ssh_destination.clone().unwrap_or_default(); - root = root.child( - div() - .absolute() - .top_2() - .right_2() - .px_2() - .py_1() - .rounded_md() - .bg(gpui::opaque_grey(0.2, 0.9)) - .text_sm() - .when(failed, |banner| banner.text_color(gpui::red())) - .child(format!("SSH {host} · {label}")), - ); + // SSH 连接卡片(旧壳 `display::ssh_connect` 的 GPUI 形态): + // 状态机/文案/常量直接复用,卡片遮罩盖住空 grid。350ms 显示 + // 门槛由 `visible()` 决定;动画帧驱动粒子与进度插值。 + if let Some(state) = &self.ssh_connect { + if state.visible() { + root = root.child(super::ssh_connect_overlay::overlay(state, cx)); + if !state.failed() { + // 每帧 step + 重绘(旧壳吃共享 motion frame,GPUI 用 + // 下一帧回调自驱):notify 触发下一次 render,render + // 再续下一帧——环由渲染侧闭合,无需递归闭包。 + cx.on_next_frame(window, |this, _, cx| { + let now = std::time::Instant::now(); + let dt = now - this.ssh_connect_last_step; + this.ssh_connect_last_step = now; + if let Some(state) = &mut this.ssh_connect { + state.step(dt); + } + cx.notify(); + }); + } } } if let Some(exited) = &self.exited { diff --git a/nebula_app/src/gpui_shell/toast.rs b/nebula_app/src/gpui_shell/toast.rs index e3e1278..be578d3 100644 --- a/nebula_app/src/gpui_shell/toast.rs +++ b/nebula_app/src/gpui_shell/toast.rs @@ -16,10 +16,12 @@ use std::sync::Mutex; use std::time::{Duration, Instant}; -use gpui::{App, Window}; +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 crate::gpui_shell::prelude::v_flex; + pub use crate::display::ToastKind; /// 同一文本短时间连发只刷新语境,不叠一摞相同卡片(旧壳 @@ -64,6 +66,32 @@ fn push(window: &mut Window, cx: &mut App, notification: Notification) { } } +/// Nebula 的通知层定位:右下角固定 20px 安全边距,最新一条在最下,旧的 +/// 向上堆叠。组件库默认 `NotificationList` 写死在右上角且拉满视口高度; +/// 这里继续复用它的 Notification 实体、生命周期、关闭和动画,只替换宿主 +/// 的排列层,不复制组件内部状态机。 +pub fn render_layer(window: &mut Window, cx: &mut App) -> Option { + let root = window.root::()??; + let list = root.read(cx).notification.clone(); + let mut items = list.read(cx).notifications(); + // 与旧壳同屏上限一致。保留队尾三条,顺序仍是旧→新,因此 v_flex + // 底部锚定后最新项自然落在最下面。 + if items.len() > 3 { + items.drain(..items.len() - 3); + } + if items.is_empty() { + return None; + } + Some( + div() + .absolute() + .right(px(20.0)) + .bottom(px(20.0)) + .child(v_flex().items_end().gap_2().children(items)) + .into_any_element(), + ) +} + /// 弹一条自动消失的提示(toast 层)。空文本与 600ms 内的重复文本静默丢弃。 pub fn toast(window: &mut Window, cx: &mut App, kind: ToastKind, text: impl Into) { let text = text.into(); diff --git a/nebula_settings/src/lib.rs b/nebula_settings/src/lib.rs index 5440ccf..474e45f 100644 --- a/nebula_settings/src/lib.rs +++ b/nebula_settings/src/lib.rs @@ -90,6 +90,66 @@ pub fn persist_keys(updates: &[(&str, String)]) -> std::io::Result<()> { std::fs::write(&path, updated) } +/// `nebula_settings.txt` 里的 `keybind=combo:Action` 行(行序即优先级, +/// 后写者胜)。两壳共读这一份;`RawSettings` 的哈希视图按 key 去重,表达 +/// 不了多行 keybind,必须走行级解析。 +pub fn keybind_pairs() -> Vec<(String, String)> { + std::fs::read_to_string(settings_path()) + .map(|text| keybind_pairs_from_text(&text)) + .unwrap_or_default() +} + +/// [`keybind_pairs`] 的纯文本解析,单测锁定。 +pub fn keybind_pairs_from_text(text: &str) -> Vec<(String, String)> { + let mut pairs = Vec::new(); + for line in text.lines() { + let Some((key, value)) = line.split_once('=') else { continue }; + if !key.trim().eq_ignore_ascii_case("keybind") { + continue; + } + let Some((combo, action)) = value.split_once(':') else { continue }; + let (combo, action) = (combo.trim(), action.trim()); + if combo.is_empty() || action.is_empty() { + continue; + } + pairs.push((combo.to_owned(), action.to_owned())); + } + pairs +} + +/// 整表替换全部 `keybind=` 行(删除旧行、按给定顺序追加到尾部),其余行 +/// 原样保留(注释、未知键、行序)。键位编辑器的提交路径——逐行增删的 +/// 语义由调用方(keymap.rs)先算好整表再落盘。 +pub fn persist_keybinds(pairs: &[(String, String)]) -> std::io::Result<()> { + let path = settings_path(); + let text = std::fs::read_to_string(&path).unwrap_or_default(); + let updated = apply_keybinds(&text, pairs); + std::fs::create_dir_all(settings_dir())?; + std::fs::write(&path, updated) +} + +/// [`persist_keybinds`] 的纯文本变换,单测锁定。 +pub fn apply_keybinds(text: &str, pairs: &[(String, String)]) -> String { + let mut out = String::with_capacity(text.len() + 64); + for line in text.lines() { + let is_keybind = line + .split_once('=') + .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("keybind")); + if !is_keybind { + out.push_str(line); + out.push('\n'); + } + } + for (combo, action) in pairs { + out.push_str("keybind="); + out.push_str(combo); + out.push(':'); + out.push_str(action); + out.push('\n'); + } + out +} + /// [`persist_keys`] 的纯文本变换,单测锁定行为。 pub fn apply_updates(text: &str, updates: &[(&str, String)]) -> String { let mut pending: Vec<(usize, &(&str, String))> = updates.iter().enumerate().collect(); @@ -869,6 +929,23 @@ mod tests { ); } + #[test] + fn keybind_pairs_parse_and_rewrite() { + let text = "theme=Nebula\nkeybind=ctrl+x:Copy\n# comment\nkeybind=alt+f1:SplitRight\n"; + assert_eq!( + keybind_pairs_from_text(text), + vec![ + ("ctrl+x".to_owned(), "Copy".to_owned()), + ("alt+f1".to_owned(), "SplitRight".to_owned()), + ] + ); + let rewritten = apply_keybinds(text, &[("ctrl+y".to_owned(), "Paste".to_owned())]); + assert_eq!( + rewritten, + "theme=Nebula\n# comment\nkeybind=ctrl+y:Paste\n" + ); + } + #[test] fn apply_updates_preserves_unknown_lines_and_matches_case_insensitively() { let text = "# comment survives\nTHEME=CoalDark\nkeybind=ctrl+x=Copy\n";