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]] [[package]]
name = "gpui-component" name = "gpui-component"
version = "0.5.1" 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 = [ dependencies = [
"aho-corasick", "aho-corasick",
"anyhow", "anyhow",
@@ -3129,7 +3129,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui-component-assets" name = "gpui-component-assets"
version = "0.5.1" 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 = [ dependencies = [
"anyhow", "anyhow",
"gpui", "gpui",
@@ -3139,7 +3139,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui-component-macros" name = "gpui-component-macros"
version = "0.5.1" 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 = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
+7 -7
View File
@@ -65,15 +65,15 @@ winit = { path = "third_party/winit-0.30.13" }
# "=0.5.1" 精确 pin;[patch] 要求版本匹配,fork 上的版本号保持不动。 # "=0.5.1" 精确 pin;[patch] 要求版本匹配,fork 上的版本号保持不动。
# #
# 下面两个 fork 的 `nebula` 分支基于 crates.io 发布基点(读自 registry 包内 # 下面两个 fork 的 `nebula` 分支基于 crates.io 发布基点(读自 registry 包内
# 的 .cargo_vcs_info.json,包名/版本已在基点 rev 上核对过),当前补丁栈为空, # 的 .cargo_vcs_info.json,包名/版本已在基点 rev 上核对过)。gpui 保持发布
# 构建产物应与 crates.io 版本一致: # 基点;gpui-component 仅带 popover 紧凑阴影补丁:
# Kuddev/zed nebula @ 69e2130295c2649963eb639fc70b4f2ee8ea1624 # Kuddev/zed nebula @ 69e2130295c2649963eb639fc70b4f2ee8ea1624
# (= zed-industries/zed 发布 gpui 0.2.2 的 rev, # (= zed-industries/zed 发布 gpui 0.2.2 的 rev,
# crates/gpui;上游发布时 dirty=true,若首次编译出现 # crates/gpui;上游发布时 dirty=true,若首次编译出现
# 与 registry src 的差异以 diff 为准补一个对齐 commit) # 与 registry src 的差异以 diff 为准补一个对齐 commit)
# Kuddev/gpui-component nebula @ 0f0ab35233212f8f3277028995caf0c41e13ee6c # Kuddev/gpui-component nebula @ a59f704c497d6e6e628ec2d401466d825d4a51b2
# (= longbridge/gpui-component v0.5.1,crates/ui; # (= longbridge/gpui-component v0.5.1 + popover
# 上级目录 gpui-component-v0.5.1/ 有源码检出可对照) # 单层紧凑阴影;不修改 gpui)
# #
# 加补丁 = 提交到 fork 的 nebula 分支 → push → 更新下面的 rev(永远 pin # 加补丁 = 提交到 fork 的 nebula 分支 → push → 更新下面的 rev(永远 pin
# exact rev、不 pin branch)。每个补丁注明原因 + upstream PR 链接,上游合并 # exact rev、不 pin branch)。每个补丁注明原因 + upstream PR 链接,上游合并
@@ -92,5 +92,5 @@ winit = { path = "third_party/winit-0.30.13" }
# shaper 遇换行 panic;替换保 byte 长度,run 偏移不失效) # shaper 遇换行 panic;替换保 byte 长度,run 偏移不失效)
# 本地 fork 工作拷贝在 ../gpui-component-fork(已挂 tty7 remote 可 diff)。 # 本地 fork 工作拷贝在 ../gpui-component-fork(已挂 tty7 remote 可 diff)。
gpui = { git = "https://github.com/Kuddev/zed", rev = "69e2130295c2649963eb639fc70b4f2ee8ea1624" } gpui = { git = "https://github.com/Kuddev/zed", rev = "69e2130295c2649963eb639fc70b4f2ee8ea1624" }
gpui-component = { 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 = "0f0ab35233212f8f3277028995caf0c41e13ee6c" } 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>> { 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 path = platform::pick_file(owner, "Choose SSH private key", PRIVATE_KEY_FILTERS)?;
let contents = match std::fs::read(&path) { Some(validate_private_key_path(&path))
Ok(contents) => contents, }
Err(err) => return Some(Err(format!("无法读取私钥 {}: {err}", path.display()))),
}; /// Validate a path selected by a non-winit UI shell. The legacy picker and GPUI
Some(match classify_private_key_contents(&contents) { /// both use this exact classifier so a `.pub` file can never silently enter a
PrivateKeyFileKind::PrivateKey => Ok(path), /// 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::PublicKey => Err("请选择私钥文件,不要选择 .pub 公钥".to_owned()),
PrivateKeyFileKind::Unsupported => { PrivateKeyFileKind::Unsupported => {
Err("文件不是受支持的 OpenSSH、PEM 或 PPK 私钥".to_owned()) Err("文件不是受支持的 OpenSSH、PEM 或 PPK 私钥".to_owned())
}, },
}) }
} }
pub(super) fn pick_upload_files(owner: &Window) -> Vec<PathBuf> { 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; pub use toast::ToastKind;
mod file_dialog; pub(crate) mod file_dialog;
pub(crate) mod keymap; pub(crate) mod keymap;
mod settings; mod settings;
pub(crate) mod ssh_connect; pub(crate) mod ssh_connect;
@@ -122,6 +122,7 @@ mod text_input;
use ssh_ui::SshDeleteUndo; use ssh_ui::SshDeleteUndo;
pub use ssh_ui::{ pub use ssh_ui::{
SSH_DELETE_UNDO_DURATION, SshEditorField, SshEditorHit, SshEditorRects, SshHostEditor, 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 use ui::theme::NebulaTheme;
pub(crate) use ui::theme::write_nebula_prompt_theme; 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 基准字号(同一设置源) //! - 等宽 cell 度量(`SizeInfo`)→ UI 基准字号(同一设置源)
use gpui::{ use gpui::{
AnyElement, Bounds, Context, Hsla, InteractiveElement as _, AnyElement, Bounds, Context, Hsla, InteractiveElement as _, StatefulInteractiveElement as _,
IntoElement, MouseButton, ParentElement as _, Pixels, SharedString, IntoElement, MouseButton, ParentElement as _, SharedString,
StatefulInteractiveElement as _, Styled as _, canvas, div, fill, hsla, point, Styled as _, canvas, div, fill, hsla, point,
px, size, px, size,
}; };
@@ -93,11 +93,17 @@ pub(super) fn rail_canvas(
move |bounds, _, window, _| { move |bounds, _, window, _| {
let w = f32::from(bounds.size.width); let w = f32::from(bounds.size.width);
let node_d = 10.0_f32; let node_d = 10.0_f32;
let x0 = node_d * 0.5; // 当前节点外圈比本体再大 7px;把首尾中心一并内缩,确保
let x1 = (w - node_d * 0.5).max(x0 + 1.0); // 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 ox = f32::from(bounds.origin.x);
let oy = f32::from(bounds.origin.y); 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| { let mut quad = |x: f32, y: f32, w: f32, h: f32, r: f32, bg: Hsla| {
window.paint_quad( window.paint_quad(
@@ -170,9 +176,13 @@ pub(super) fn rail_canvas(
let mut col = color; let mut col = color;
col.a = alpha * if k == 0 { 1.0 } else { 0.78 }; col.a = alpha * if k == 0 { 1.0 } else { 0.78 };
if !light { 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; 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 - rad, cy - rad, rad * 2.0, rad * 2.0, rad, g);
} }
quad(x - cr, cy - cr, cr * 2.0, cr * 2.0, cr, col); 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); quad(x, y, d, d, d * 0.5, brand_r);
} else if i == active { } else if i == active {
if !light { if !light {
let mut g = accent; // 与旧壳单层 UiQuad::glow 对齐;额外的大外圈会让
g.a = 0.45; // 当前阶段节点显得像一枚持续发亮的按钮。
quad(x - 3.0, y - 3.0, d + 6.0, d + 6.0, (d + 6.0) * 0.5, g); 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); quad(x, y, d, d, d * 0.5, accent);
} else { } 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() .w_full()
.into_any_element() .into_any_element()
} }
@@ -246,7 +260,16 @@ pub(super) fn overlay(
let panel = theme.popover; let panel = theme.popover;
let card_bg = theme.group_box; let card_bg = theme.group_box;
let hairline = theme.border; 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 按钮 ── // ── 身份行:机架图标 + 两行文字 + Logs 按钮 ──
let rack_ink = ink_dim; let rack_ink = ink_dim;
@@ -298,8 +321,48 @@ pub(super) fn overlay(
let name: SharedString = ssh_connect::short_name(state.destination()).into(); let name: SharedString = ssh_connect::short_name(state.destination()).into();
let meta: SharedString = format!("SSH · {}", state.destination()).into(); let meta: SharedString = format!("SSH · {}", state.destination()).into();
let logs_target = cx.entity().downgrade(); let logs_target = cx.entity().downgrade();
let logs_label: SharedString = let logs_open = state.is_logs_open();
format!("Logs {}", if state.is_logs_open() { "⌃" } else { "⌄" }).into(); 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() let identity = h_flex()
.gap(px(16.0)) .gap(px(16.0))
.items_center() .items_center()
@@ -340,20 +403,7 @@ pub(super) fn overlay(
.child( .child(
// 可交互按钮统一走 gpui-component:命中、Hover、键盘焦点与 // 可交互按钮统一走 gpui-component:命中、Hover、键盘焦点与
// 主题 token 都由组件库负责,不在业务卡片里维护第二套按钮。 // 主题 token 都由组件库负责,不在业务卡片里维护第二套按钮。
Button::new("ssh-connect-logs") logs_button,
.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();
}
});
}
}),
); );
// ── 轨道 + 阶段标签(首尾贴齐轨道两端,中间以节点为中心)── // ── 轨道 + 阶段标签(首尾贴齐轨道两端,中间以节点为中心)──
@@ -386,21 +436,20 @@ pub(super) fn overlay(
.text_color(ink_i) .text_color(ink_i)
.child(label.clone()); .child(label.clone());
match i { match i {
0 => div().absolute().left(px(0.0)).child(text).into_any_element(), // 节点中心是 12px / width-12px,旧壳首尾标签从
3 => div().absolute().right(px(0.0)).child(text).into_any_element(), // 节点本体边缘(中心 ±5px)起止,因此各留 7px。
// 中间:零宽锚点 + 140px 居中槽(节点近似中心) 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() _ => div()
.absolute() .absolute()
.left(gpui::relative(if i == 1 { .left(gpui::relative(if i == 1 { 1.0 / 3.0 } else { 2.0 / 3.0 }))
0.328
} else {
0.659
}))
.w(px(0.0)) .w(px(0.0))
.child( .child(
div() div()
.w(px(140.0)) .w(px(140.0))
.ml(px(-70.0)) .ml(px(if i == 1 { -66.0 } else { -74.0 }))
.flex() .flex()
.justify_center() .justify_center()
.child(text), .child(text),
@@ -530,7 +579,7 @@ pub(super) fn overlay(
cx.stop_propagation(); cx.stop_propagation();
}) })
// 遮罩:pane 底色整块盖住(连接期间 grid 是空的,跟着重绘会闪) // 遮罩:pane 底色整块盖住(连接期间 grid 是空的,跟着重绘会闪)
.child(div().absolute().inset_0().bg(panel)) .child(div().absolute().inset_0().bg(backdrop))
.child( .child(
div() div()
.absolute() .absolute()
@@ -548,7 +597,16 @@ pub(super) fn overlay(
.border_1() .border_1()
.border_color(hairline) .border_color(hairline)
.bg(panel) .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(identity)
.child(rail_block) .child(rail_block)
.child(status_row) .child(status_row)
+10 -2
View File
@@ -28,6 +28,11 @@ pub use crate::display::ToastKind;
/// `DUPLICATE_COOLDOWN` 同值同义;只挡逐字相同的文本)。 /// `DUPLICATE_COOLDOWN` 同值同义;只挡逐字相同的文本)。
const DUPLICATE_COOLDOWN: Duration = Duration::from_millis(600); 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); static LAST_TOAST: Mutex<Option<(String, Instant)>> = Mutex::new(None);
fn is_duplicate(text: &str) -> bool { fn is_duplicate(text: &str) -> bool {
@@ -42,11 +47,14 @@ fn is_duplicate(text: &str) -> bool {
} }
fn note(kind: ToastKind, text: String) -> Notification { fn note(kind: ToastKind, text: String) -> Notification {
match kind { let note = match kind {
ToastKind::Info => Notification::info(text), ToastKind::Info => Notification::info(text),
ToastKind::Success => Notification::success(text), ToastKind::Success => Notification::success(text),
ToastKind::Warning => Notification::warning(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` 返回;此时 /// 窗口构造闭包里 `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) { fn open_in_file_manager(path: &Path) {
#[cfg(windows)] #[cfg(windows)]
let _ = std::process::Command::new("explorer.exe").arg(path).spawn(); 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>, ai_events: std::sync::mpsc::Receiver<crate::ai_hook::AiHookEvent>,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> 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 initial_grid = Self::size_window_to_default_grid(window, cx, sidebar_width);
let this = cx.entity().downgrade(); let this = cx.entity().downgrade();
let appearance_sub = window.observe_window_appearance(move |_, cx| { let appearance_sub = window.observe_window_appearance(move |_, cx| {
@@ -689,10 +701,11 @@ impl NebulaWorkspace {
notice, notice,
); );
} }
// 会话恢复(共享 v4 schema + 崩溃断路器):恢复不出任何 tab 才落 // 会话恢复(共享 v4 schema + 崩溃断路器):restore_session 关闭时
// 到出厂的单终端。1 Hz 自动保存随后启动,首笔成功写盘自然把 // 用短路保证不碰 session.json(不加载、隔离、标记或回放),直接落
// `boot_attempts` 清零(快照恒以 0 构造,旧壳同义)。 // 到出厂的单终端。恢复开启但 resume_ai 关闭时仍回放布局/cwd,只不
if !this.try_restore_session(window, cx) { // 注入 AI 接续命令。1 Hz 自动保存随后照常启动。
if !runtime.restore_session || !this.try_restore_session(runtime.resume_ai, window, cx) {
this.add_terminal(window, cx); this.add_terminal(window, cx);
} }
Self::start_ai_hook_pump(ai_events, cx); Self::start_ai_hook_pump(ai_events, cx);
@@ -1296,7 +1309,12 @@ impl NebulaWorkspace {
/// 启动恢复:断路器跳闸就隔离现场并走干净路径;恢复成功弹一条 /// 启动恢复:断路器跳闸就隔离现场并走干净路径;恢复成功弹一条
/// 自动消失的提示(崩溃现场多一句来源说明)。返回是否恢复出了 tab。 /// 自动消失的提示(崩溃现场多一句来源说明)。返回是否恢复出了 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; use crate::display::ToastKind;
let Some(mut session) = crate::session::load() else { return false }; let Some(mut session) = crate::session::load() else { return false };
@@ -1319,7 +1337,7 @@ impl NebulaWorkspace {
crate::session::mark_boot_attempt(&mut session); crate::session::mark_boot_attempt(&mut session);
let mut restored = 0usize; let mut restored = 0usize;
for tab in &session.tabs { for tab in &session.tabs {
if self.restore_tab(tab, window, cx) { if self.restore_tab(tab, resume_ai, window, cx) {
restored += 1; restored += 1;
} }
} }
@@ -1344,6 +1362,7 @@ impl NebulaWorkspace {
fn restore_tab( fn restore_tab(
&mut self, &mut self,
tab: &crate::session::TabSession, tab: &crate::session::TabSession,
resume_ai: bool,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> bool { ) -> bool {
@@ -1374,7 +1393,7 @@ impl NebulaWorkspace {
shell_name: None, 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)); panes.push(self.new_pane(grid, launch, command, window, cx));
} }
if panes.is_empty() { if panes.is_empty() {
@@ -3492,6 +3511,7 @@ impl NebulaWorkspace {
div() div()
.font_family(mono_family.clone()) .font_family(mono_family.clone())
.text_size(px(label_px * SIDEBAR_TAG_SCALE)) .text_size(px(label_px * SIDEBAR_TAG_SCALE))
.font_weight(FontWeight::NORMAL)
.text_color(status_color) .text_color(status_color)
.child(tag) .child(tag)
.into_any_element() .into_any_element()
@@ -3522,6 +3542,9 @@ impl NebulaWorkspace {
// 旧壳 pill 圆角 = UI_CORNER_RADIUS_LOGICAL(8),rounded_md(6) // 旧壳 pill 圆角 = UI_CORNER_RADIUS_LOGICAL(8),rounded_md(6)
// 偏小一圈,选中水洗的轮廓形状会不一样。 // 偏小一圈,选中水洗的轮廓形状会不一样。
.rounded(px(crate::display::UI_CORNER_RADIUS_LOGICAL)) .rounded(px(crate::display::UI_CORNER_RADIUS_LOGICAL))
// GPUI 默认文本样式可能把侧栏整行带到中等/粗体;旧壳
// tab chrome 使用终端 Regular face,所有子文本从这里继承常规字重。
.font_weight(FontWeight::NORMAL)
.cursor_pointer() .cursor_pointer()
.when(active, |item| item.bg(active_bg).text_color(active_fg)) .when(active, |item| item.bg(active_bg).text_color(active_fg))
.when(!active && !dragged, |item| { .when(!active && !dragged, |item| {
@@ -3583,6 +3606,7 @@ impl NebulaWorkspace {
.flex_shrink_0() .flex_shrink_0()
.font_family(mono_family.clone()) .font_family(mono_family.clone())
.text_size(px(label_px)) .text_size(px(label_px))
.font_weight(FontWeight::NORMAL)
.text_color(if active { active_fg } else { muted }) .text_color(if active { active_fg } else { muted })
.child(glyph), .child(glyph),
) )
@@ -3609,6 +3633,9 @@ impl NebulaWorkspace {
.min_w_0() .min_w_0()
.font_family(mono_family.clone()) .font_family(mono_family.clone())
.text_size(px(label_px)) .text_size(px(label_px))
// 标签标题本身使用 Light;活动态只换前景/背景色,
// 不再靠更粗字重强调,避免选中行看起来突然加粗。
.font_weight(FontWeight::LIGHT)
.whitespace_nowrap() .whitespace_nowrap()
.overflow_hidden() .overflow_hidden()
.child(title) .child(title)
@@ -3728,14 +3755,14 @@ impl NebulaWorkspace {
.pr_1() .pr_1()
.child( .child(
// 箭头与标题同一条 mono 文本 run(旧壳 codicon // 箭头与标题同一条 mono 文本 run(旧壳 codicon
// 字位 \u{eab4}/\u{eab6} + BOLD),不用细线 SVG—— // 字位 \u{eab4}/\u{eab6} + Regular),不用细线 SVG——
// 实心字形才和旧壳一样有存在感。 // 保持旧壳的常规字重。
div() div()
.w(px(tabs_disclosure_slot_w)) .w(px(tabs_disclosure_slot_w))
.flex_shrink_0() .flex_shrink_0()
.font_family(mono_family.clone()) .font_family(mono_family.clone())
.text_size(px(label_px * SIDEBAR_TITLE_SCALE)) .text_size(px(label_px * SIDEBAR_TITLE_SCALE))
.font_weight(FontWeight::BOLD) .font_weight(FontWeight::NORMAL)
.text_color(muted) .text_color(muted)
.child(if self.tabs_section_collapsed { .child(if self.tabs_section_collapsed {
"\u{eab6}" "\u{eab6}"
@@ -3746,7 +3773,7 @@ impl NebulaWorkspace {
.child( .child(
div() div()
.text_size(px(label_px * SIDEBAR_TITLE_SCALE)) .text_size(px(label_px * SIDEBAR_TITLE_SCALE))
.font_weight(FontWeight::BOLD) .font_weight(FontWeight::NORMAL)
.text_color(muted) .text_color(muted)
.child("TABS"), .child("TABS"),
) )
@@ -3764,6 +3791,7 @@ impl NebulaWorkspace {
.rounded_full() .rounded_full()
.bg(theme.muted) .bg(theme.muted)
.text_size(px(label_px * SIDEBAR_TITLE_SCALE)) .text_size(px(label_px * SIDEBAR_TITLE_SCALE))
.font_weight(FontWeight::NORMAL)
.text_color(faint) .text_color(faint)
.child(count), .child(count),
), ),
@@ -4294,7 +4322,13 @@ impl Render for NebulaWorkspace {
.child( .child(
// 标题栏左上与旧壳同构:侧栏开关 + 齿轮,两枚裸图标,无应用名 // 标题栏左上与旧壳同构:侧栏开关 + 齿轮,两枚裸图标,无应用名
// 文案。图标用静态 PanelLeft(旧壳折叠/展开共用同一枚方块标)。 // 文案。图标用静态 PanelLeft(旧壳折叠/展开共用同一枚方块标)。
//
// 组件库默认 TitleBar 只有 34px,而普通 Button 的命中块是
// 32px,上下各剩 1px,视觉上就会紧贴窗口顶边。旧壳合同是
// 8px 外边距 + 40px 标题带,总高 48px;显式覆写后按钮垂直
// 居中,上下各留 8px,且右侧窗口控制仍共享同一标题带。
TitleBar::new() TitleBar::new()
.h(px(48.0))
.child( .child(
h_flex() h_flex()
// 旧壳两枚 32px 命中块之间固定留 8px;默认 Button // 旧壳两枚 32px 命中块之间固定留 8px;默认 Button
@@ -4502,6 +4536,21 @@ mod tests {
assert_eq!(ai_hook_target_pane(&pane_ids, None, None), None); 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] #[test]
fn new_tab_position_uses_the_shared_runtime_semantics() { fn new_tab_position_uses_the_shared_runtime_semantics() {
use nebula_settings::NewTabPositionName::{AfterCurrent, End}; 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); const TEST_TIMEOUT: Duration = Duration::from_secs(12);
/// 解析 + 连接 + 无人值守认证一轮,结果连同耗时经事件送回窗口线程。 /// 一次草稿测试的完成数据。旧 winit 壳与 GPUI 设置页共用它:业务层只负责
/// 绝不弹 AskPass:交互式方法在测试中一律跳过(见 [`test_authenticate`])。 /// 解析、代理和认证,具体 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( pub fn spawn_test(
request: SshTestRequest, request: SshTestRequest,
proxy: winit::event_loop::EventLoopProxy<crate::event::Event>, proxy: winit::event_loop::EventLoopProxy<crate::event::Event>,
window_id: winit::window::WindowId, window_id: winit::window::WindowId,
) -> io::Result<()> { ) -> io::Result<()> {
runtime()?.spawn(async move { runtime()?.spawn(async move {
let started = std::time::Instant::now(); let result = run_test(request).await;
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 _ = proxy.send_event(crate::event::Event::new( let _ = proxy.send_event(crate::event::Event::new(
crate::event::EventType::SshTestDone { crate::event::EventType::SshTestDone {
request_id, request_id: result.request_id,
destination: raw, destination: result.destination,
ok, ok: result.ok,
message, message: result.message,
elapsed_ms: started.elapsed().as_millis() as u64, elapsed_ms: result.elapsed_ms,
}, },
window_id, window_id,
)); ));