chore: checkpoint GPUI parity work

This commit is contained in:
kuddev committed 2026-08-16 19:46:45 +08:00
1 parent 09f224aa5f
commit 4dee70beb2
9 files changed
+1286 -281

No files matched your search

Generated
+3 -3
View File
@@ -3090,7 +3090,7 @@ dependencies = [
[[package]]
name = "gpui-component"
version = "0.5.1"
source = "git+https://github.com/Kuddev/gpui-component?rev=0f0ab35233212f8f3277028995caf0c41e13ee6c#0f0ab35233212f8f3277028995caf0c41e13ee6c"
source = "git+https://github.com/Kuddev/gpui-component?rev=a59f704c497d6e6e628ec2d401466d825d4a51b2#a59f704c497d6e6e628ec2d401466d825d4a51b2"
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=0f0ab35233212f8f3277028995caf0c41e13ee6c#0f0ab35233212f8f3277028995caf0c41e13ee6c"
source = "git+https://github.com/Kuddev/gpui-component?rev=a59f704c497d6e6e628ec2d401466d825d4a51b2#a59f704c497d6e6e628ec2d401466d825d4a51b2"
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=0f0ab35233212f8f3277028995caf0c41e13ee6c#0f0ab35233212f8f3277028995caf0c41e13ee6c"
source = "git+https://github.com/Kuddev/gpui-component?rev=a59f704c497d6e6e628ec2d401466d825d4a51b2#a59f704c497d6e6e628ec2d401466d825d4a51b2"
dependencies = [
"proc-macro2",
"quote",
+7 -7
View File
@@ -65,15 +65,15 @@ winit = { path = "third_party/winit-0.30.13" }
# "=0.5.1" 精确 pin;[patch] 要求版本匹配,fork 上的版本号保持不动。
#
# 下面两个 fork 的 `nebula` 分支基于 crates.io 发布基点(读自 registry 包内
# 的 .cargo_vcs_info.json,包名/版本已在基点 rev 上核对过),当前补丁栈为空,
# 构建产物应与 crates.io 版本一致:
# 的 .cargo_vcs_info.json,包名/版本已在基点 rev 上核对过)。gpui 保持发布
# 基点;gpui-component 仅带 popover 紧凑阴影补丁:
# Kuddev/zed nebula @ 69e2130295c2649963eb639fc70b4f2ee8ea1624
# (= zed-industries/zed 发布 gpui 0.2.2 的 rev,
# crates/gpui;上游发布时 dirty=true,若首次编译出现
# 与 registry src 的差异以 diff 为准补一个对齐 commit)
# Kuddev/gpui-component nebula @ 0f0ab35233212f8f3277028995caf0c41e13ee6c
# (= longbridge/gpui-component v0.5.1,crates/ui;
# 上级目录 gpui-component-v0.5.1/ 有源码检出可对照)
# Kuddev/gpui-component nebula @ a59f704c497d6e6e628ec2d401466d825d4a51b2
# (= longbridge/gpui-component v0.5.1 + popover
# 单层紧凑阴影;不修改 gpui)
#
# 加补丁 = 提交到 fork 的 nebula 分支 → push → 更新下面的 rev(永远 pin
# exact rev、不 pin branch)。每个补丁注明原因 + upstream PR 链接,上游合并
@@ -92,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 = "0f0ab35233212f8f3277028995caf0c41e13ee6c" }
gpui-component-assets = { git = "https://github.com/Kuddev/gpui-component", rev = "0f0ab35233212f8f3277028995caf0c41e13ee6c" }
gpui-component = { git = "https://github.com/Kuddev/gpui-component", rev = "a59f704c497d6e6e628ec2d401466d825d4a51b2" }
gpui-component-assets = { git = "https://github.com/Kuddev/gpui-component", rev = "a59f704c497d6e6e628ec2d401466d825d4a51b2" }
+12 -7
View File
@@ -112,17 +112,22 @@ pub(super) fn pick_font_file(owner: &Window) -> Option<PathBuf> {
pub(super) fn pick_private_key_file(owner: &Window) -> Option<Result<PathBuf, String>> {
let path = platform::pick_file(owner, "Choose SSH private key", PRIVATE_KEY_FILTERS)?;
let contents = match std::fs::read(&path) {
Ok(contents) => contents,
Err(err) => return Some(Err(format!("无法读取私钥 {}: {err}", path.display()))),
};
Some(match classify_private_key_contents(&contents) {
PrivateKeyFileKind::PrivateKey => Ok(path),
Some(validate_private_key_path(&path))
}
/// Validate a path selected by a non-winit UI shell. The legacy picker and GPUI
/// both use this exact classifier so a `.pub` file can never silently enter a
/// profile just because the shell used a different native file-dialog API.
pub(crate) fn validate_private_key_path(path: &std::path::Path) -> Result<PathBuf, String> {
let contents = std::fs::read(path)
.map_err(|err| format!("无法读取私钥 {}: {err}", path.display()))?;
match classify_private_key_contents(&contents) {
PrivateKeyFileKind::PrivateKey => Ok(path.to_path_buf()),
PrivateKeyFileKind::PublicKey => Err("请选择私钥文件,不要选择 .pub 公钥".to_owned()),
PrivateKeyFileKind::Unsupported => {
Err("文件不是受支持的 OpenSSH、PEM 或 PPK 私钥".to_owned())
},
})
}
}
pub(super) fn pick_upload_files(owner: &Window) -> Vec<PathBuf> {
+2 -1
View File
@@ -110,7 +110,7 @@ pub use state::{
};
pub use toast::ToastKind;
mod file_dialog;
pub(crate) mod file_dialog;
pub(crate) mod keymap;
mod settings;
pub(crate) mod ssh_connect;
@@ -122,6 +122,7 @@ mod text_input;
use ssh_ui::SshDeleteUndo;
pub use ssh_ui::{
SSH_DELETE_UNDO_DURATION, SshEditorField, SshEditorHit, SshEditorRects, SshHostEditor,
auth_sections, join_destination_port, push_private_key, split_destination_port,
};
pub use ui::theme::NebulaTheme;
pub(crate) use ui::theme::write_nebula_prompt_theme;
File diff suppressed because it is too large. Load diff
@@ -9,9 +9,9 @@
//! - 等宽 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,
AnyElement, Bounds, Context, Hsla, InteractiveElement as _, StatefulInteractiveElement as _,
IntoElement, MouseButton, ParentElement as _, SharedString,
Styled as _, canvas, div, fill, hsla, point,
px, size,
};
@@ -93,11 +93,17 @@ pub(super) fn rail_canvas(
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);
// 当前节点外圈比本体再大 7px;把首尾中心一并内缩,确保
// canvas 即使启用内容蒙版也不会把两端 halo 裁成半圆。
let halo_pad = 7.0_f32;
let x0 = node_d * 0.5 + halo_pad;
let x1 = (w - node_d * 0.5 - halo_pad).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;
// x/y/cy 全部保持 canvas 局部坐标;quad 提交时才加一次窗口
// origin。此前 cy 已含 oy,quad 又加 oy,整条轨道因此被画到
// canvas 下方(截图只剩阶段文字,节点/粒子全部不可见)。
let cy = 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(
@@ -170,9 +176,13 @@ pub(super) fn rail_canvas(
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 };
// GPUI 没有旧壳 `UiQuad::glow` 的软边 primitive;缩小近似盘面,
// 避免轨道中段被一串大圆斑连成粗流。
let rad = if k == 0 { 3.4 } else { 2.6 * fade + 0.8 };
let mut g = color;
g.a = alpha * 0.22;
// GPUI 用普通 quad 模拟旧壳的软 glow,降低 alpha
// 才不会把粒子画成一串高亮斑块。
g.a = alpha * 0.14;
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);
@@ -193,9 +203,11 @@ pub(super) fn rail_canvas(
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);
// 与旧壳单层 UiQuad::glow 对齐;额外的大外圈会让
// 当前阶段节点显得像一枚持续发亮的按钮。
let mut glow = accent;
glow.a = 0.45;
quad(x - 3.0, y - 3.0, d + 6.0, d + 6.0, (d + 6.0) * 0.5, glow);
}
quad(x, y, d, d, d * 0.5, accent);
} else {
@@ -213,7 +225,9 @@ pub(super) fn rail_canvas(
}
},
)
.h(px(10.0))
// 节点本体 10px;最外层节点 halo 向两侧各溢出 7px。给足 24px,
// 否则 GPUI canvas 会把刚恢复的光晕上下裁平。
.h(px(24.0))
.w_full()
.into_any_element()
}
@@ -246,7 +260,16 @@ pub(super) fn overlay(
let panel = theme.popover;
let card_bg = theme.group_box;
let hairline = theme.border;
let accent = theme.link;
let dark = theme.is_dark();
let elevation = gpui::hsla(
0.0,
0.0,
0.0,
if dark { 56.0 / 255.0 } else { 26.0 / 255.0 },
);
// 遮罩沿用终端 pane 的真实底色;直接铺 popover 会把空网格区域
// 换成另一层面板色,尤其在浅色主题下会显得整块发白。
let backdrop = crate::gpui_shell::theme::card_content_bg(cx);
// ── 身份行:机架图标 + 两行文字 + Logs 按钮 ──
let rack_ink = ink_dim;
@@ -298,8 +321,48 @@ pub(super) fn overlay(
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 logs_open = state.is_logs_open();
let logs_hover = theme.list_hover;
let logs_chevron = Icon::new(if logs_open {
IconName::ChevronUp
} else {
IconName::ChevronDown
})
.xsmall()
.text_color(ink_dim);
let logs_button = h_flex()
.id("ssh-connect-logs")
.h(px(30.0))
.w(px(68.0))
.px(px(12.0))
.items_center()
.justify_between()
.gap(px(4.0))
.rounded(px(6.0))
.border_1()
.border_color(hairline)
.bg(card_bg)
.cursor_pointer()
.hover(move |button| button.bg(logs_hover))
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.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();
}
});
}
})
.child(
div()
.font_family(family.clone())
.text_size(px(ui_px * 0.80))
.text_color(ink_dim)
.child("Logs"),
)
.child(logs_chevron);
let identity = h_flex()
.gap(px(16.0))
.items_center()
@@ -340,20 +403,7 @@ pub(super) fn overlay(
.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();
}
});
}
}),
logs_button,
);
// ── 轨道 + 阶段标签(首尾贴齐轨道两端,中间以节点为中心)──
@@ -386,21 +436,20 @@ pub(super) fn overlay(
.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 居中槽(节点近似中心)
// 节点中心是 12px / width-12px,旧壳首尾标签从
// 节点本体边缘(中心 ±5px)起止,因此各留 7px。
0 => div().absolute().left(px(7.0)).child(text).into_any_element(),
3 => div().absolute().right(px(7.0)).child(text).into_any_element(),
// 中间节点精确位于 width/3+4px 与 2*width/3-4px。
// 140px 槽以该点为中心,标签与节点共享同一套几何。
_ => div()
.absolute()
.left(gpui::relative(if i == 1 {
0.328
} else {
0.659
}))
.left(gpui::relative(if i == 1 { 1.0 / 3.0 } else { 2.0 / 3.0 }))
.w(px(0.0))
.child(
div()
.w(px(140.0))
.ml(px(-70.0))
.ml(px(if i == 1 { -66.0 } else { -74.0 }))
.flex()
.justify_center()
.child(text),
@@ -530,7 +579,7 @@ pub(super) fn overlay(
cx.stop_propagation();
})
// 遮罩:pane 底色整块盖住(连接期间 grid 是空的,跟着重绘会闪)
.child(div().absolute().inset_0().bg(panel))
.child(div().absolute().inset_0().bg(backdrop))
.child(
div()
.absolute()
@@ -548,7 +597,16 @@ pub(super) fn overlay(
.border_1()
.border_color(hairline)
.bg(panel)
.shadow_md()
// 旧壳只使用一层黑色阴影:20px 模糊、向下 7px,
// 浅色 26/255、深色 56/255。品牌色不参与卡片投影。
.shadow(vec![
gpui::BoxShadow {
color: elevation,
offset: point(px(0.0), px(7.0)),
blur_radius: px(20.0),
spread_radius: px(0.0),
},
])
.child(identity)
.child(rail_block)
.child(status_row)
+10 -2
View File
@@ -28,6 +28,11 @@ pub use crate::display::ToastKind;
/// `DUPLICATE_COOLDOWN` 同值同义;只挡逐字相同的文本)。
const DUPLICATE_COOLDOWN: Duration = Duration::from_millis(600);
/// 组件库 Notification 默认固定 `w_112`,短短一句也会铺成近半个窗口。
/// Nebula 的 toast 按内容收缩;过长消息到此上限后自然换行。
const TOAST_MIN_WIDTH: f32 = 240.0;
const TOAST_MAX_WIDTH: f32 = 440.0;
static LAST_TOAST: Mutex<Option<(String, Instant)>> = Mutex::new(None);
fn is_duplicate(text: &str) -> bool {
@@ -42,11 +47,14 @@ fn is_duplicate(text: &str) -> bool {
}
fn note(kind: ToastKind, text: String) -> Notification {
match kind {
let note = match kind {
ToastKind::Info => Notification::info(text),
ToastKind::Success => Notification::success(text),
ToastKind::Warning => Notification::warning(text),
}
};
// `refine_style` 在组件自身的 `w_112` 之后应用,所以这里能可靠覆盖
// 固定宽度;auto 让短消息收紧,max_w 给长路径/错误信息提供换行约束。
note.w_auto().min_w(px(TOAST_MIN_WIDTH)).max_w(px(TOAST_MAX_WIDTH))
}
/// 窗口构造闭包里 `NebulaWorkspace::new` 早于外层 `Root::new` 返回;此时
+61 -12
View File
@@ -412,6 +412,15 @@ fn ai_hook_target_pane(
}
}
/// AI 接续是会话回放的独立闸门:关掉时 pane 的 cwd/launch/layout 仍可
/// 恢复,但绝不把 AgentSession 转成会敲进新 shell 的命令。
fn restored_agent_command(
resume_ai: bool,
agent: Option<&crate::session::AgentSession>,
) -> Option<String> {
if resume_ai { agent.and_then(|agent| agent.resume_command()) } else { None }
}
fn open_in_file_manager(path: &Path) {
#[cfg(windows)]
let _ = std::process::Command::new("explorer.exe").arg(path).spawn();
@@ -608,7 +617,10 @@ impl NebulaWorkspace {
ai_events: std::sync::mpsc::Receiver<crate::ai_hook::AiHookEvent>,
cx: &mut Context<Self>,
) -> Self {
let sidebar_width = nebula_settings::RuntimeSettings::load().sidebar_width;
// 启动相关设置只取样一次:本次开窗的恢复决策不能被恢复过程中的
// 文件变化拆成互相矛盾的 restore/resume 状态。
let runtime = nebula_settings::RuntimeSettings::load();
let sidebar_width = runtime.sidebar_width;
let initial_grid = Self::size_window_to_default_grid(window, cx, sidebar_width);
let this = cx.entity().downgrade();
let appearance_sub = window.observe_window_appearance(move |_, cx| {
@@ -689,10 +701,11 @@ impl NebulaWorkspace {
notice,
);
}
// 会话恢复(共享 v4 schema + 崩溃断路器):恢复不出任何 tab 才落
// 到出厂的单终端。1 Hz 自动保存随后启动,首笔成功写盘自然把
// `boot_attempts` 清零(快照恒以 0 构造,旧壳同义)。
if !this.try_restore_session(window, cx) {
// 会话恢复(共享 v4 schema + 崩溃断路器):restore_session 关闭时
// 用短路保证不碰 session.json(不加载、隔离、标记或回放),直接落
// 到出厂的单终端。恢复开启但 resume_ai 关闭时仍回放布局/cwd,只不
// 注入 AI 接续命令。1 Hz 自动保存随后照常启动。
if !runtime.restore_session || !this.try_restore_session(runtime.resume_ai, window, cx) {
this.add_terminal(window, cx);
}
Self::start_ai_hook_pump(ai_events, cx);
@@ -1296,7 +1309,12 @@ impl NebulaWorkspace {
/// 启动恢复:断路器跳闸就隔离现场并走干净路径;恢复成功弹一条
/// 自动消失的提示(崩溃现场多一句来源说明)。返回是否恢复出了 tab。
fn try_restore_session(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
fn try_restore_session(
&mut self,
resume_ai: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
use crate::display::ToastKind;
let Some(mut session) = crate::session::load() else { return false };
@@ -1319,7 +1337,7 @@ impl NebulaWorkspace {
crate::session::mark_boot_attempt(&mut session);
let mut restored = 0usize;
for tab in &session.tabs {
if self.restore_tab(tab, window, cx) {
if self.restore_tab(tab, resume_ai, window, cx) {
restored += 1;
}
}
@@ -1344,6 +1362,7 @@ impl NebulaWorkspace {
fn restore_tab(
&mut self,
tab: &crate::session::TabSession,
resume_ai: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
@@ -1374,7 +1393,7 @@ impl NebulaWorkspace {
shell_name: None,
}
};
let command = agent.as_ref().and_then(|agent| agent.resume_command());
let command = restored_agent_command(resume_ai, agent.as_ref());
panes.push(self.new_pane(grid, launch, command, window, cx));
}
if panes.is_empty() {
@@ -3492,6 +3511,7 @@ impl NebulaWorkspace {
div()
.font_family(mono_family.clone())
.text_size(px(label_px * SIDEBAR_TAG_SCALE))
.font_weight(FontWeight::NORMAL)
.text_color(status_color)
.child(tag)
.into_any_element()
@@ -3522,6 +3542,9 @@ impl NebulaWorkspace {
// 旧壳 pill 圆角 = UI_CORNER_RADIUS_LOGICAL(8),rounded_md(6)
// 偏小一圈,选中水洗的轮廓形状会不一样。
.rounded(px(crate::display::UI_CORNER_RADIUS_LOGICAL))
// GPUI 默认文本样式可能把侧栏整行带到中等/粗体;旧壳
// tab chrome 使用终端 Regular face,所有子文本从这里继承常规字重。
.font_weight(FontWeight::NORMAL)
.cursor_pointer()
.when(active, |item| item.bg(active_bg).text_color(active_fg))
.when(!active && !dragged, |item| {
@@ -3583,6 +3606,7 @@ impl NebulaWorkspace {
.flex_shrink_0()
.font_family(mono_family.clone())
.text_size(px(label_px))
.font_weight(FontWeight::NORMAL)
.text_color(if active { active_fg } else { muted })
.child(glyph),
)
@@ -3609,6 +3633,9 @@ impl NebulaWorkspace {
.min_w_0()
.font_family(mono_family.clone())
.text_size(px(label_px))
// 标签标题本身使用 Light;活动态只换前景/背景色,
// 不再靠更粗字重强调,避免选中行看起来突然加粗。
.font_weight(FontWeight::LIGHT)
.whitespace_nowrap()
.overflow_hidden()
.child(title)
@@ -3728,14 +3755,14 @@ impl NebulaWorkspace {
.pr_1()
.child(
// 箭头与标题同一条 mono 文本 run(旧壳 codicon
// 字位 \u{eab4}/\u{eab6} + BOLD),不用细线 SVG——
// 实心字形才和旧壳一样有存在感。
// 字位 \u{eab4}/\u{eab6} + Regular),不用细线 SVG——
// 保持旧壳的常规字重。
div()
.w(px(tabs_disclosure_slot_w))
.flex_shrink_0()
.font_family(mono_family.clone())
.text_size(px(label_px * SIDEBAR_TITLE_SCALE))
.font_weight(FontWeight::BOLD)
.font_weight(FontWeight::NORMAL)
.text_color(muted)
.child(if self.tabs_section_collapsed {
"\u{eab6}"
@@ -3746,7 +3773,7 @@ impl NebulaWorkspace {
.child(
div()
.text_size(px(label_px * SIDEBAR_TITLE_SCALE))
.font_weight(FontWeight::BOLD)
.font_weight(FontWeight::NORMAL)
.text_color(muted)
.child("TABS"),
)
@@ -3764,6 +3791,7 @@ impl NebulaWorkspace {
.rounded_full()
.bg(theme.muted)
.text_size(px(label_px * SIDEBAR_TITLE_SCALE))
.font_weight(FontWeight::NORMAL)
.text_color(faint)
.child(count),
),
@@ -4294,7 +4322,13 @@ impl Render for NebulaWorkspace {
.child(
// 标题栏左上与旧壳同构:侧栏开关 + 齿轮,两枚裸图标,无应用名
// 文案。图标用静态 PanelLeft(旧壳折叠/展开共用同一枚方块标)。
//
// 组件库默认 TitleBar 只有 34px,而普通 Button 的命中块是
// 32px,上下各剩 1px,视觉上就会紧贴窗口顶边。旧壳合同是
// 8px 外边距 + 40px 标题带,总高 48px;显式覆写后按钮垂直
// 居中,上下各留 8px,且右侧窗口控制仍共享同一标题带。
TitleBar::new()
.h(px(48.0))
.child(
h_flex()
// 旧壳两枚 32px 命中块之间固定留 8px;默认 Button
@@ -4502,6 +4536,21 @@ mod tests {
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};
+70 -37
View File
@@ -830,51 +830,84 @@ pub struct SshTestRequest {
const TEST_TIMEOUT: Duration = Duration::from_secs(12);
/// 解析 + 连接 + 无人值守认证一轮,结果连同耗时经事件送回窗口线程。
/// 绝不弹 AskPass:交互式方法在测试中一律跳过(见 [`test_authenticate`])。
/// 一次草稿测试的完成数据。旧 winit 壳与 GPUI 设置页共用它:业务层只负责
/// 解析、代理和认证,具体 UI 决定怎样把结果投递回自己的事件循环。
#[derive(Debug, Clone)]
pub struct SshTestResult {
pub request_id: u64,
pub destination: String,
pub ok: bool,
pub message: String,
pub elapsed_ms: u64,
}
/// 执行一次无人值守的草稿测试。绝不弹 AskPass:交互式方法在测试中一律跳过
/// (见 [`test_authenticate`])。
async fn run_test(request: SshTestRequest) -> SshTestResult {
let started = std::time::Instant::now();
let request_id = request.request_id;
let raw = request.destination.clone();
let outcome = tokio::time::timeout(TEST_TIMEOUT, async {
let (resolved, global) = tokio::task::spawn_blocking({
let raw = raw.clone();
move || {
let resolved = SshDestination::resolve(&raw)?;
let global = crate::ssh_proxy::SshProxyConfig::load_global();
Ok::<_, io::Error>((resolved, global))
}
})
.await
.map_err(|err| -> SessionError { format!("SSH 地址解析任务失败: {err}").into() })??;
// SSH 编辑器不再维护重复的每主机代理;测试与真连都只读取网络页
// 的当前配置。`ProxyJump` 属于 OpenSSH 目标解析,仍按其原语义保留。
let proxy = resolve_network_proxy(&global, &resolved)
.map_err(|err| -> SessionError { format!("SSH 代理配置无效: {err}").into() })?;
test_connect(&resolved, &request, proxy.as_ref()).await
})
.await;
let (ok, message) = match outcome {
Ok(Ok(())) => (true, String::new()),
Ok(Err(err)) => (false, err.to_string()),
Err(_) => (false, format!("连接超时({} 秒无响应)", TEST_TIMEOUT.as_secs())),
};
SshTestResult {
request_id,
destination: raw,
ok,
message,
elapsed_ms: started.elapsed().as_millis() as u64,
}
}
/// 启动测试并交给调用方异步等待结果。GPUI 没有旧壳的 winit `EventLoopProxy`,
/// 所以它通过这个 receiver 把结果安全地回写到自己的 Entity;网络与认证仍然
/// 完全复用同一条 Tokio SSH runtime。
pub fn start_test(
request: SshTestRequest,
) -> io::Result<tokio::sync::oneshot::Receiver<SshTestResult>> {
let (sender, receiver) = tokio::sync::oneshot::channel();
runtime()?.spawn(async move {
let _ = sender.send(run_test(request).await);
});
Ok(receiver)
}
/// 旧 winit 壳的事件投递适配器。测试实现本身在 [`run_test`],避免两个壳
/// 各自维护解析、代理和认证的近似副本。
pub fn spawn_test(
request: SshTestRequest,
proxy: winit::event_loop::EventLoopProxy<crate::event::Event>,
window_id: winit::window::WindowId,
) -> io::Result<()> {
runtime()?.spawn(async move {
let started = std::time::Instant::now();
let request_id = request.request_id;
let raw = request.destination.clone();
let outcome = tokio::time::timeout(TEST_TIMEOUT, async {
let (resolved, global) = tokio::task::spawn_blocking({
let raw = raw.clone();
move || {
let resolved = SshDestination::resolve(&raw)?;
let global = crate::ssh_proxy::SshProxyConfig::load_global();
Ok::<_, io::Error>((resolved, global))
}
})
.await
.map_err(|err| -> SessionError {
format!("SSH 地址解析任务失败: {err}").into()
})??;
// SSH 编辑器不再维护重复的每主机代理;测试与真连都只读取网络页
// 的当前配置。`ProxyJump` 属于 OpenSSH 目标解析,仍按其原语义保留。
let proxy =
resolve_network_proxy(&global, &resolved).map_err(|err| -> SessionError {
format!("SSH 代理配置无效: {err}").into()
})?;
test_connect(&resolved, &request, proxy.as_ref()).await
})
.await;
let (ok, message) = match outcome {
Ok(Ok(())) => (true, String::new()),
Ok(Err(err)) => (false, err.to_string()),
Err(_) => (false, format!("连接超时({} 秒无响应)", TEST_TIMEOUT.as_secs())),
};
let result = run_test(request).await;
let _ = proxy.send_event(crate::event::Event::new(
crate::event::EventType::SshTestDone {
request_id,
destination: raw,
ok,
message,
elapsed_ms: started.elapsed().as_millis() as u64,
request_id: result.request_id,
destination: result.destination,
ok: result.ok,
message: result.message,
elapsed_ms: result.elapsed_ms,
},
window_id,
));