feat: 输入框光标模型下沉到组件层,SSH 编辑器对齐原型

在此之前每个输入框只有一个"是否全选"的 bool,光标恒画在文本末尾——
模型里根本没有"光标在哪"这个概念,所以点不了、拖不了、方向键无处可去。
这类能力是输入框的地板不是天花板,因此落在 `ui/text_field.rs`:命中
换算与渲染共用同一个起点和格宽,两边各算一次必然在全角字符和居中字段
上漂移,而漂移几像素恰恰最难被报告、也最伤手感。

配色这轮追出两个根因,都不是参数写错:

- `push_stroke` 画的是**实心**圆角矩形,靠调用方随后的填充盖住中心才
  形成"环"。`panel`/`input` 不透明盖得住,而 `card`/`surface` 只有
  4~8% 不透明度盖不住——描边色渗满整块,把卡片和按钮压深一整档。实测
  Nebula 深色的分组卡片因此成了 (62,65,74),原型是 (56,61,73)。新增
  `surface::over` 把叠加色与底色预合成,`push_group` 一个 quad 画完。
- 深色浮层底改从 `shell_bg` 提亮,不再从 `term_bg`。旧注释的理由是
  "浮层要比内容层亮",方向对、起点错:Nebula 外壳比终端亮 20,从终端
  起跳的浮层落在 (29,31,40),比它实际漂浮的那张外壳还暗 5 级。

分段选中片改回两个主题共用 `panel`。前两轮按"选中片必须比轨道亮"去分
主题,那条规则本身是错的——原型深色下选中片比轨道暗 18 级(按进去),
浅色下比轨道亮(浮起来),方向相反但对比都成立。同一个语义就该同一个
token。

顺带修掉一个多实例竞态:`ai_hook` 的原子写用固定临时文件名,两个
Nebula 同时自愈 `settings.json` 时后者 rename 扑空,报出一句"系统找不到
指定的文件"——指的却是那个临时文件,读起来像 settings.json 不见了。

`display/mod.rs` 8562 → 7974 行,SSH 交互层按 Warp 的 model/view/input
三分拆出 `ssh_editor_input.rs`。

506 passed。
This commit is contained in:
kuddev committed 2026-07-31 19:21:55 +08:00
1 parent fcd0929c88
commit 2c35799643
30 files changed
+4344 -1212

No files matched your search

+9 -1
View File
@@ -1011,7 +1011,15 @@ export default function (pi: ExtensionAPI) {
/// Write via tmp + rename (MoveFileEx REPLACE_EXISTING under the hood):
/// readers never observe a torn file, a crash leaves the original intact.
fn write_atomic(path: &Path, data: &str) -> std::io::Result<()> {
let tmp = path.with_extension("nebula-tmp");
// 临时文件名带上进程号。多个 Nebula 实例各自守着同一份
// `settings.json` 自愈,共用一个固定的 tmp 名就会互相踩:A 写 tmp、
// B 覆盖同一个 tmp、A `rename` 把它搬走,B 的 `rename` 于是报
// `ERROR_FILE_NOT_FOUND(2)`——一句"系统找不到指定的文件",指的却是
// 那个临时文件,读起来像 settings.json 不见了。
//
// 内容本身是幂等的(装的是同一套 hook 条目),所以最后谁赢都行,
// 要防的只是这个假报错。
let tmp = path.with_extension(format!("nebula-tmp-{}", std::process::id()));
std::fs::write(&tmp, data)?;
std::fs::rename(&tmp, path)
}
+4 -1
View File
@@ -1695,6 +1695,9 @@ pub(super) fn draw_chrome(d: &mut Display) {
let cy = row_text_cy(hy, hh);
let name = d.nebula_ssh_hosts.get(index).map(String::as_str).unwrap_or("?");
let pinned = d.nebula_pinned_hosts.iter().any(|p| p == name);
// 起过名字就显示名字。地址仍然是这一行的身份(置顶、删除、连接
// 都按地址走),只有给人看的那串文字换掉。
let shown = d.nebula_ssh_labels.get(name).map(String::as_str).unwrap_or(name);
// Label budget from its real start to the row's right edge
// (minus the pin marker's slot when pinned), in columns. The
// leading remote icon + space cost 2 columns of the drawn
@@ -1706,7 +1709,7 @@ pub(super) fn draw_chrome(d: &mut Display) {
let row_tracking = s(0.35);
let max_cols = (((right - text_x) / (cell_w + row_tracking)).floor() as usize)
.saturating_sub(2);
let label = truncate_tab_label(name, max_cols.max(1));
let label = truncate_tab_label(shown, max_cols.max(1));
d.renderer.draw_chrome_text(
&size,
text_x,
+198 -469
View File
@@ -109,7 +109,9 @@ pub use state::{
mod file_dialog;
pub(crate) mod keymap;
mod settings;
mod ssh_editor_input;
mod ssh_editor_render;
pub(crate) mod ssh_connect;
mod ssh_ui;
mod text_input;
@@ -755,18 +757,14 @@ pub(crate) fn nebula_link_log(message: impl AsRef<str>) {
}
}
/// Directory holding Nebula's persistent state (`%APPDATA%\Nebula` or
/// `~/.config/Nebula`), created on demand. Settings live here next to the
/// history file managed by [`crate::nebula_history`] and the session
/// snapshot managed by [`crate::session`].
/// Directory holding Nebula's persistent state, created on demand. Settings
/// live here next to the history file managed by [`crate::nebula_history`] and
/// the session snapshot managed by [`crate::session`].
///
/// Per-platform locations and the reasoning behind them live in
/// [`crate::platform::dirs`] — this is a thin alias kept for its 26 call sites.
pub(crate) fn nebula_data_dir() -> PathBuf {
let base = std::env::var_os("APPDATA")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
.unwrap_or_else(std::env::temp_dir);
let dir = base.join("Nebula");
let _ = std::fs::create_dir_all(&dir);
dir
crate::platform::dirs::data_dir().to_path_buf()
}
/// Read one raw `key=value` from `nebula_settings.txt` (case-insensitive key).
@@ -1130,6 +1128,14 @@ pub struct Display {
/// Transient "cols × rows" HUD shown briefly after a window resize; it fades
/// out over ~0.9s. `None` when nothing is showing.
nebula_resize_hud: Option<ResizeHud>,
/// 每个 pane 的 SSH 连接进度。成功时立刻移除——卡片让位给真实终端,
/// 持续重绘也随之停止;失败保留,让用户读得到原因。
nebula_ssh_connect: std::collections::HashMap<u64, ssh_connect::SshConnectState>,
/// 聚焦 pane 的 id,由绘制流程每帧同步。连接卡片只画在聚焦 pane 里,
/// 而 `nebula_pane_view` 只给几何、不给身份。
nebula_focused_pane: u64,
/// Skip the first resize (window creation) so no HUD flashes at startup.
nebula_resize_hud_armed: bool,
@@ -1172,6 +1178,9 @@ pub struct Display {
pub nebula_ssh_editor_rects: Option<SshEditorRects>,
nebula_ssh_editor_open: bool,
nebula_ssh_editor_hover: SshEditorHit,
/// 正在拖选的字段。鼠标按在输入框里时置位,松开清掉——拖拽的语义是
/// "从按下的那个字符拉到现在这个字符",所以中途划出框外也要继续跟。
nebula_ssh_editor_drag: Option<SshEditorField>,
/// 「测试连接」点击时暂存的请求;input 层随后取走并交给 SSH runtime。
/// display 不持有事件代理,这一格就是点击→网络之间的交接台。
nebula_ssh_test_request: Option<crate::ssh_session::SshTestRequest>,
@@ -1279,6 +1288,9 @@ pub struct Display {
/// them here makes Delete stable instead of letting the next merge revive
/// the row immediately.
nebula_hidden_hosts: Vec<String>,
/// 地址 → 用户起的显示名,从 `ssh_profiles.json` 缓存而来。侧栏每帧都要
/// 画这些行,读文件必须发生在保存那一刻,而不是绘制路径上。
nebula_ssh_labels: std::collections::HashMap<String, String>,
/// Accordion fold state of the two sidebar sections.
nebula_tabs_section_open: bool,
nebula_hosts_section_open: bool,
@@ -1784,6 +1796,8 @@ impl Display {
cursor_hidden: Default::default(),
nebula_pane_view: None,
nebula_resize_hud: None,
nebula_ssh_connect: std::collections::HashMap::new(),
nebula_focused_pane: 0,
nebula_resize_hud_armed: false,
nebula_history: {
let history = crate::nebula_history::NebulaHistory::load();
@@ -1806,6 +1820,7 @@ impl Display {
nebula_ssh_editor_rects: None,
nebula_ssh_editor_open: false,
nebula_ssh_editor_hover: SshEditorHit::None,
nebula_ssh_editor_drag: None,
nebula_ssh_test_request: None,
nebula_ssh_test_seq: 0,
nebula_frame_images: Vec::new(),
@@ -1871,6 +1886,11 @@ impl Display {
nebula_pinned_hosts: settings_init.pinned_hosts.clone(),
nebula_saved_hosts: settings_init.saved_hosts.clone(),
nebula_hidden_hosts: settings_init.hidden_hosts.clone(),
nebula_ssh_labels: crate::ssh_profiles::SshProfiles::load(
&nebula_data_dir().join("ssh_profiles.json"),
)
.map(|profiles| profiles.labels())
.unwrap_or_default(),
nebula_tabs_section_open: true,
nebula_hosts_section_open: true,
nebula_tabs_scroll: 0,
@@ -1981,464 +2001,6 @@ impl Display {
self.nebula_hidden_hosts.len()
}
pub fn open_ssh_editor(&mut self) {
self.nebula_ssh_editor = Some(SshHostEditor {
original_destination: None,
error: None,
destination_selection: Default::default(),
password_selection: Default::default(),
destination: String::new(),
password: String::new(),
save_password: true,
show_password: false,
auth: crate::ssh_profiles::SshAuthMode::Auto,
private_keys: Vec::new(),
field: SshEditorField::Destination,
focus: crate::ux::FocusIndex::default(),
test: Default::default(),
});
self.nebula_ssh_editor_rects = None;
self.nebula_ssh_editor_open = true;
self.nebula_ssh_editor_hover = SshEditorHit::None;
// 每次打开都从零开始,避免上一次退出动画的残余进度造成闪跳。
self.nebula_ui_anims.ssh_editor = UiAnim::new(0.0);
self.pending_update.dirty = true;
}
pub fn edit_ssh_host(&mut self, index: usize) {
let Some(destination) = self.nebula_ssh_hosts.get(index).cloned() else { return };
let profile =
crate::ssh_profiles::SshProfiles::load(&nebula_data_dir().join("ssh_profiles.json"))
.unwrap_or_else(|err| {
warn!("加载 SSH Profile 失败,编辑器使用自动认证: {err}");
crate::ssh_profiles::SshProfiles::default()
})
.for_destination(&destination);
self.nebula_ssh_editor = Some(SshHostEditor {
original_destination: Some(destination.clone()),
error: None,
destination_selection: Default::default(),
password_selection: Default::default(),
destination,
// Never pull a stored secret back into a text field. Leaving this
// blank preserves the existing credential when the address stays
// unchanged; typing a new value explicitly replaces it.
password: String::new(),
save_password: true,
show_password: false,
auth: profile.auth,
private_keys: profile.private_keys,
field: SshEditorField::Destination,
focus: crate::ux::FocusIndex::default(),
test: Default::default(),
});
self.nebula_ssh_editor_rects = None;
self.nebula_ssh_editor_open = true;
self.nebula_ssh_editor_hover = SshEditorHit::None;
self.nebula_ui_anims.ssh_editor = UiAnim::new(0.0);
self.pending_update.dirty = true;
self.window.request_redraw();
}
pub fn ssh_editor_active(&self) -> bool {
self.nebula_ssh_editor_open && self.nebula_ssh_editor.is_some()
}
pub fn close_ssh_editor(&mut self) {
if self.nebula_ssh_editor.is_some() {
self.nebula_ssh_editor_open = false;
self.nebula_ssh_editor_hover = SshEditorHit::None;
self.pending_update.dirty = true;
self.window.request_redraw();
}
}
pub fn ssh_editor_hit(&self, x: f32, y: f32) -> SshEditorHit {
let Some(rects) = self.nebula_ssh_editor_rects.as_ref() else {
return SshEditorHit::None;
};
let hit = |r: (f32, f32, f32, f32)| x >= r.0 && x < r.0 + r.2 && y >= r.1 && y < r.1 + r.3;
if hit(rects.close) {
SshEditorHit::Close
} else if hit(rects.destination) {
SshEditorHit::Destination
} else if let Some((mode, _)) = rects.auth.iter().find(|(_, rect)| hit(*rect)) {
SshEditorHit::Auth(*mode)
} else if hit(rects.add_private_key) {
SshEditorHit::AddPrivateKey
} else if let Some((index, _)) = rects.private_key_rows.iter().find(|(_, rect)| hit(*rect))
{
SshEditorHit::RemovePrivateKey(*index)
} else if hit(rects.password_toggle) {
SshEditorHit::PasswordToggle
} else if hit(rects.password) {
SshEditorHit::Password
} else if hit(rects.save_checkbox) {
SshEditorHit::SaveToggleBox
} else if hit(rects.save_toggle) {
SshEditorHit::SaveToggleLabel
} else if hit(rects.test) {
SshEditorHit::Test
} else if hit(rects.test_status) {
SshEditorHit::TestStatus
} else if hit(rects.cancel) {
SshEditorHit::Cancel
} else if hit(rects.primary) {
SshEditorHit::Primary
} else {
SshEditorHit::None
}
}
pub fn set_ssh_editor_hover(&mut self, hover: SshEditorHit) {
if self.nebula_ssh_editor_hover != hover {
self.nebula_ssh_editor_hover = hover;
self.pending_update.dirty = true;
}
}
pub fn ssh_editor_insert(&mut self, text: &str) {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
editor.error = None;
match editor.field {
SshEditorField::Destination => {
editor.destination_selection.insert(&mut editor.destination, text)
},
SshEditorField::Password => {
editor.password_selection.insert(&mut editor.password, text)
},
}
editor.test = Default::default();
}
}
pub fn ssh_editor_backspace(&mut self) {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
let before = match editor.field {
SshEditorField::Destination => editor.destination.len(),
SshEditorField::Password => editor.password.len(),
};
match editor.field {
SshEditorField::Destination => {
editor.destination_selection.backspace(&mut editor.destination)
},
SshEditorField::Password => {
editor.password_selection.backspace(&mut editor.password)
},
}
let after = match editor.field {
SshEditorField::Destination => editor.destination.len(),
SshEditorField::Password => editor.password.len(),
};
if before != after {
editor.test = Default::default();
}
}
}
pub fn ssh_editor_select_all(&mut self) {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
match editor.field {
SshEditorField::Destination => {
editor.destination_selection.select(&editor.destination)
},
SshEditorField::Password => editor.password_selection.select(&editor.password),
}
}
}
/// Copying an invisible password would persist a secret in the system
/// clipboard without visible intent, so it is enabled only after Reveal.
pub fn ssh_editor_selected_text(&self) -> Option<String> {
let editor = self.nebula_ssh_editor.as_ref()?;
match editor.field {
SshEditorField::Destination => {
editor.destination_selection.selected_text(&editor.destination)
},
SshEditorField::Password if editor.show_password => {
editor.password_selection.selected_text(&editor.password)
},
SshEditorField::Password => None,
}
}
pub fn ssh_editor_next_field(&mut self, reverse: bool) {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
editor.destination_selection.clear();
editor.password_selection.clear();
let shows_password = ssh_ui::auth_sections(editor.auth).0;
// Destination, optional password, test, cancel, save.
let count = if shows_password { 5 } else { 4 };
editor.focus.advance(count, reverse);
editor.field = match (shows_password, editor.focus.current()) {
(_, 0) => SshEditorField::Destination,
(true, 1) => SshEditorField::Password,
_ => editor.field,
};
}
}
pub fn ssh_editor_activate_focus(&mut self) {
let Some(editor) = self.nebula_ssh_editor.as_ref() else { return };
let shows_password = ssh_ui::auth_sections(editor.auth).0;
match (shows_password, editor.focus.current()) {
(true, 2) | (false, 1) => self.queue_ssh_test(),
(true, 3) | (false, 2) => self.close_ssh_editor(),
(true, 4) | (false, 3) => self.save_ssh_editor(),
_ => {},
}
}
pub fn ssh_editor_toggle_save(&mut self) {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
editor.save_password = !editor.save_password;
editor.test = Default::default();
}
}
/// input 层在处理完编辑器点击后调用:取走「测试连接」暂存的请求。
pub fn take_ssh_test_request(&mut self) -> Option<crate::ssh_session::SshTestRequest> {
self.nebula_ssh_test_request.take()
}
/// Validate the current draft and stage an exact, numbered test request.
/// The input layer owns the event proxy and takes this request immediately
/// after mouse/keyboard activation.
fn queue_ssh_test(&mut self) {
let Some(editor) = self.nebula_ssh_editor.as_mut() else { return };
let destination = editor.destination.trim().to_owned();
let valid = !destination.is_empty()
&& !destination
.chars()
.any(|c| c.is_whitespace() || c.is_control() || ";&|<>\"'`".contains(c));
if !valid {
editor.error = Some(if destination.is_empty() {
"请输入 SSH 地址,例如 user@example.com".to_owned()
} else {
"地址不能包含空白、控制字符或 shell 分隔符".to_owned()
});
editor.test = Default::default();
return;
}
if matches!(editor.test, ssh_ui::SshTestState::Running { .. }) {
return;
}
let shows_password = ssh_ui::auth_sections(editor.auth).0;
editor.focus.set(if shows_password { 2 } else { 1 }, if shows_password { 5 } else { 4 });
self.nebula_ssh_test_seq = self.nebula_ssh_test_seq.wrapping_add(1).max(1);
let request_id = self.nebula_ssh_test_seq;
editor.error = None;
editor.test = ssh_ui::SshTestState::Running { request_id };
self.nebula_ssh_test_request = Some(crate::ssh_session::SshTestRequest {
request_id,
destination,
auth: editor.auth,
private_keys: editor.private_keys.clone(),
password: (!editor.password.is_empty()).then(|| editor.password.clone()),
});
}
/// 后台测试完成回报。结果只属于发起时的草稿:地址已改或状态已非
/// Running(用户改过字段被清位)时直接丢弃,不让旧结果背书新配置。
pub fn ssh_test_done(
&mut self,
request_id: u64,
destination: &str,
ok: bool,
message: &str,
elapsed_ms: u64,
) {
let Some(editor) = self.nebula_ssh_editor.as_mut() else { return };
if editor.destination.trim() != destination
|| editor.test != (ssh_ui::SshTestState::Running { request_id })
{
return;
}
editor.test = if ok {
ssh_ui::SshTestState::Ok { elapsed_ms }
} else {
ssh_ui::SshTestState::Failed { summary: message.to_owned() }
};
self.pending_update.dirty = true;
self.window.request_redraw();
}
pub fn ssh_editor_click(&mut self, x: f32, y: f32) -> bool {
match self.ssh_editor_hit(x, y) {
SshEditorHit::Destination => {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
let count = if ssh_ui::auth_sections(editor.auth).0 { 5 } else { 4 };
editor.focus.set(0, count);
editor.destination_selection.clear();
editor.password_selection.clear();
editor.field = SshEditorField::Destination;
}
},
SshEditorHit::PasswordToggle => {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
editor.show_password = !editor.show_password;
}
},
SshEditorHit::Password => {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
editor.focus.set(1, 5);
editor.destination_selection.clear();
editor.password_selection.clear();
editor.field = SshEditorField::Password;
}
},
SshEditorHit::Auth(mode) => {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
editor.auth = mode;
editor.error = None;
editor.test = Default::default();
if !ssh_ui::auth_sections(mode).0 {
editor.field = SshEditorField::Destination;
}
}
},
SshEditorHit::AddPrivateKey => {
if let Some(result) = file_dialog::pick_private_key_file(&self.window) {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
match result {
Ok(path) => {
ssh_ui::push_private_key(&mut editor.private_keys, path);
editor.error = None;
editor.test = Default::default();
},
Err(err) => editor.error = Some(err),
}
}
}
},
SshEditorHit::RemovePrivateKey(index) => {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
if index < editor.private_keys.len() {
editor.private_keys.remove(index);
editor.test = Default::default();
}
}
},
SshEditorHit::SaveToggleBox | SshEditorHit::SaveToggleLabel => {
self.ssh_editor_toggle_save();
},
SshEditorHit::Test => self.queue_ssh_test(),
SshEditorHit::TestStatus => {},
SshEditorHit::Close => {
self.close_ssh_editor();
},
SshEditorHit::Cancel => {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
let shows_password = ssh_ui::auth_sections(editor.auth).0;
editor.focus.set(
if shows_password { 3 } else { 2 },
if shows_password { 5 } else { 4 },
);
}
self.close_ssh_editor();
},
SshEditorHit::Primary => {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
let shows_password = ssh_ui::auth_sections(editor.auth).0;
editor.focus.set(
if shows_password { 4 } else { 3 },
if shows_password { 5 } else { 4 },
);
}
self.save_ssh_editor();
},
SshEditorHit::None => return false,
}
self.pending_update.dirty = true;
self.window.request_redraw();
true
}
pub fn save_ssh_editor(&mut self) {
let Some(mut editor) = self.nebula_ssh_editor.take() else { return };
let destination = editor.destination.trim().to_owned();
let valid = !destination.is_empty()
&& !destination
.chars()
.any(|c| c.is_whitespace() || c.is_control() || ";&|<>\"'`".contains(c));
if !valid {
editor.error = Some(if destination.is_empty() {
"请输入 SSH 地址,例如 user@example.com".to_owned()
} else {
"地址不能包含空白、控制字符或 shell 分隔符".to_owned()
});
editor.field = SshEditorField::Destination;
self.nebula_ssh_editor = Some(editor);
self.pending_update.dirty = true;
self.window.request_redraw();
return;
}
if let Some(original) = editor.original_destination.as_deref() {
if original != destination {
self.nebula_saved_hosts.retain(|host| host != original);
self.nebula_pinned_hosts.retain(|host| host != original);
if !self.nebula_hidden_hosts.iter().any(|host| host == original) {
self.nebula_hidden_hosts.push(original.to_owned());
}
#[cfg(windows)]
{
let _ = crate::ssh_credentials::forget_password(original);
}
}
}
// Saving/editing is an explicit request to surface this destination,
// so it also undoes a previous Delete of the same address.
self.nebula_hidden_hosts.retain(|host| host != &destination);
self.nebula_saved_hosts.retain(|host| host != &destination);
self.nebula_saved_hosts.insert(0, destination.clone());
self.nebula_saved_hosts.truncate(20);
self.nebula_ssh_hosts = merge_ssh_hosts(
&self.nebula_saved_hosts,
&self.nebula_pinned_hosts,
&self.nebula_hidden_hosts,
);
let profile_path = nebula_data_dir().join("ssh_profiles.json");
let mut profiles =
crate::ssh_profiles::SshProfiles::load(&profile_path).unwrap_or_else(|err| {
warn!("加载 SSH Profile 失败,将创建新文件: {err}");
crate::ssh_profiles::SshProfiles::default()
});
if let Some(original) = editor.original_destination.as_deref() {
if original != destination {
profiles.rename(original, &destination);
}
}
profiles.upsert(crate::ssh_profiles::SshProfileAuth {
destination: destination.clone(),
auth: editor.auth,
private_keys: editor.private_keys.clone(),
});
if let Err(err) = profiles.save(&profile_path) {
editor.error = Some(format!("保存 SSH Profile 失败: {err}"));
self.nebula_ssh_editor = Some(editor);
self.pending_update.dirty = true;
self.window.request_redraw();
return;
}
if ssh_ui::auth_sections(editor.auth).0
&& editor.save_password
&& !editor.password.is_empty()
{
#[cfg(windows)]
{
let _ = crate::ssh_credentials::store_password(
&destination,
editor.password.as_bytes(),
);
}
}
// 凭据落盘后立即清除内存中的明文,但保留其余内容完成短退出动画。
editor.password.clear();
self.persist_nebula_settings();
self.nebula_ssh_editor = Some(editor);
self.close_ssh_editor();
}
/// Ask before removing a saved destination. Config aliases use different
/// wording because Delete hides them inside Nebula and never edits
/// `~/.ssh/config` itself.
@@ -6655,6 +6217,170 @@ impl Display {
/// Overlay a transient, fading "cols × rows" HUD centered in the window,
/// shown briefly after a resize (a resize overlay HUD). Keeps requesting
/// redraws until it fades out, then clears itself.
/// 每帧同步聚焦 pane 的身份。连接卡片据此决定画在哪个 pane 里——
/// `nebula_pane_view` 只给几何,不给身份。
pub fn set_focused_pane(&mut self, pane: u64) {
self.nebula_focused_pane = pane;
}
/// 后台 SSH runtime 上报的连接阶段。
///
/// `Ready` 直接移除状态:卡片退场,持续重绘随之停止,不会留下一个连完
/// 还在后台跑粒子的 tab。
pub fn ssh_connect_stage(
&mut self,
pane: u64,
destination: String,
stage: crate::ssh_session::SshStage,
) {
if matches!(stage, crate::ssh_session::SshStage::Ready) {
self.nebula_ssh_connect.remove(&pane);
return;
}
match self.nebula_ssh_connect.entry(pane) {
std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut().set_stage(stage),
std::collections::hash_map::Entry::Vacant(entry) => {
// 只有 `Resolve` 能开一张新卡片。`Ready` 时状态已被移除,若
// 任何后续阶段都能重建,一次会话中途断线就会让连接卡片在一
// 个用了半天的终端上凭空复活。
if matches!(stage, crate::ssh_session::SshStage::Resolve) {
entry.insert(ssh_connect::SshConnectState::new(destination));
}
},
}
}
/// pane 关闭时丢弃它的连接状态。
pub fn forget_ssh_connect(&mut self, pane: u64) {
self.nebula_ssh_connect.remove(&pane);
}
/// 卡片当前占据的矩形 = 聚焦 pane 的内容区。绘制与命中共用它,两者不会
/// 漂移。
fn ssh_connect_rect(&self) -> (f32, f32, f32, f32) {
let view = self.pane_view();
(
view.padding_x(),
view.padding_y(),
view.width() - view.padding_x() - view.padding_right(),
view.height() - view.padding_y() - view.padding_bottom(),
)
}
/// 聚焦 pane 是否正被连接卡片接管(遮罩已经在画了)。
pub fn ssh_connect_active(&self) -> bool {
self.nebula_ssh_connect
.get(&self.nebula_focused_pane)
.is_some_and(|state| state.visible())
}
/// 遮罩盖住整个 pane,所以卡片在场时 pane 内的一切点击都归卡片,不能
/// 漏进终端去起拖选——侧栏拖拽残影那个 bug 的同类。
pub fn ssh_connect_covers(&self, x: f32, y: f32) -> bool {
self.ssh_connect_active() && ssh_connect::covers(self.ssh_connect_rect(), x, y)
}
pub fn ssh_connect_hit(&self, x: f32, y: f32) -> ssh_connect::SshConnectHit {
let Some(state) = self.nebula_ssh_connect.get(&self.nebula_focused_pane) else {
return ssh_connect::SshConnectHit::None;
};
if !state.visible() {
return ssh_connect::SshConnectHit::None;
}
ssh_connect::hit_test(
state,
&self.ui_size_info(),
self.ssh_connect_rect(),
self.window.scale_factor as f32,
self.nebula_language,
x,
y,
)
}
/// 更新悬停并返回是否需要重绘。
pub fn ssh_connect_set_hover(&mut self, hit: ssh_connect::SshConnectHit) -> bool {
let pane = self.nebula_focused_pane;
self.nebula_ssh_connect.get_mut(&pane).is_some_and(|state| state.set_hover(hit))
}
/// Logs 折叠是纯显示状态,就地处理;其余动作要关 pane 或重连,交给
/// `window_context`。
pub fn ssh_connect_toggle_logs(&mut self) {
let pane = self.nebula_focused_pane;
if let Some(state) = self.nebula_ssh_connect.get_mut(&pane) {
state.toggle_logs();
}
}
/// 聚焦 pane 的连接目标,供"重试"重新发起同一个连接。
pub fn ssh_connect_destination(&self) -> Option<String> {
self.nebula_ssh_connect
.get(&self.nebula_focused_pane)
.map(|state| state.destination().to_owned())
}
/// SSH 连接卡片:星云轨道 + 粒子流,画在聚焦 pane 内的浮层。
fn draw_ssh_connect(&mut self) {
let pane = self.nebula_focused_pane;
if !self.nebula_ssh_connect.contains_key(&pane) {
return;
}
let delta = self.nebula_ui_anims.frame().delta;
// 借用分离:绘制要同时摸 renderer 与 glyph_cache,先把状态摘出来。
let mut states = std::mem::take(&mut self.nebula_ssh_connect);
if let Some(state) = states.get_mut(&pane) {
state.step(delta);
if state.visible() {
let size = self.ui_size_info();
let scale = self.window.scale_factor as f32;
let view = self.pane_view();
// pane 的内容矩形:padding 编码了 pane 在窗口里的位置,
// 分屏时左右两半的 padding 是非对称的。
let rect = (
view.padding_x(),
view.padding_y(),
view.width() - view.padding_x() - view.padding_right(),
view.height() - view.padding_y() - view.padding_bottom(),
);
let mut quads = Vec::new();
// 遮罩用 pane 的真实底色,这样卡片浮在一块与终端同色的板上,
// 而不是凭空多出一层灰。
let bg = self.nebula_background.unwrap_or(self.colors[NamedColor::Background]);
let backdrop = crate::renderer::ui::Rgba::opaque(bg);
let language = self.nebula_language;
ssh_connect::push_quads(
state,
&self.nebula_theme,
&mut quads,
&size,
rect,
scale,
language,
backdrop,
);
self.renderer.draw_ui(&size, &quads);
let glyph_cache = &mut self.glyph_cache;
ssh_connect::draw_text(
state,
&self.nebula_theme,
language,
&mut self.renderer,
glyph_cache,
&size,
rect,
scale,
);
}
// 门槛期内也要保持帧循环,否则永远到不了该显示的那一帧。
// 失败态不再有动画,交给事件驱动即可。
if !state.failed() {
self.window.request_redraw();
}
}
self.nebula_ssh_connect = states;
}
fn draw_resize_hud(&mut self) {
let Some(mut hud) = self.nebula_resize_hud else { return };
hud.opacity.step(self.nebula_ui_anims.frame());
@@ -6752,6 +6478,9 @@ impl Display {
}
}
// SSH 连接卡片:在 chrome 之上,resize HUD 之下。
self.draw_ssh_connect();
// Transient resize HUD painted on top of the chrome.
self.draw_resize_hud();
context_menu::draw(self);
File diff suppressed because it is too large. Load diff
+623
View File
@@ -0,0 +1,623 @@
//! SSH 主机编辑器的**交互层**:打开/关闭、命中、光标定位与拖选、字段校验、
//! 测试连接、保存。
//!
//! # 为什么单独一个文件
//!
//! 这个域此前是 model([`ssh_ui`](super::ssh_ui))+ view
//! ([`ssh_editor_render`](super::ssh_editor_render))两分,交互逻辑却留在
//! `display::mod` 里,跟另外三百多个方法堆在一起。结果是想弄清"点一下地址框
//! 会发生什么",得在一个八千行的文件里翻。
//!
//! 分法参照 Warp:`app/src/terminal/` 下 `model/`、`view.rs`、`input.rs` 三分,
//! 每个功能域自带这三块,中枢只负责装配。这里照搬同一条线——`mod.rs` 不该
//! 知道端口框只收五位数字。
//!
//! # 与相邻两层的边界
//!
//! - **model**([`ssh_ui`](super::ssh_ui))—— 只有状态与不变量,不碰
//! [`Display`]。
//! - **view**([`ssh_editor_render`](super::ssh_editor_render))—— 只吃状态
//! 吐 quad 与文字,不改状态。
//! - **input**(本文件)—— 唯一同时持有两边的地方:把指针与按键翻译成对
//! model 的修改,并拿 view 算出的矩形做命中。
use super::*;
impl Display {
pub fn open_ssh_editor(&mut self) {
self.nebula_ssh_editor = Some(SshHostEditor {
original_destination: None,
error: None,
destination_cursor: Default::default(),
port_cursor: Default::default(),
label_cursor: Default::default(),
password_cursor: Default::default(),
destination: String::new(),
port: String::new(),
label: String::new(),
password: String::new(),
save_password: true,
show_password: false,
auth: crate::ssh_profiles::SshAuthMode::Auto,
private_keys: Vec::new(),
field: SshEditorField::Destination,
focus: crate::ux::FocusIndex::default(),
test: Default::default(),
});
self.nebula_ssh_editor_rects = None;
self.nebula_ssh_editor_open = true;
self.nebula_ssh_editor_hover = SshEditorHit::None;
// 每次打开都从零开始,避免上一次退出动画的残余进度造成闪跳。
self.nebula_ui_anims.ssh_editor = UiAnim::new(0.0);
self.pending_update.dirty = true;
}
pub fn edit_ssh_host(&mut self, index: usize) {
let Some(destination) = self.nebula_ssh_hosts.get(index).cloned() else { return };
let profile =
crate::ssh_profiles::SshProfiles::load(&nebula_data_dir().join("ssh_profiles.json"))
.unwrap_or_else(|err| {
warn!("加载 SSH Profile 失败,编辑器使用自动认证: {err}");
crate::ssh_profiles::SshProfiles::default()
})
.for_destination(&destination);
// 存盘的地址串里端口是内嵌的;编辑器分两个框显示,所以这里拆开。
let (address, port) = ssh_ui::split_destination_port(&destination);
self.nebula_ssh_editor = Some(SshHostEditor {
original_destination: Some(destination.clone()),
error: None,
destination_cursor: Default::default(),
port_cursor: Default::default(),
label_cursor: Default::default(),
password_cursor: Default::default(),
destination: address,
port,
label: profile.label.clone().unwrap_or_default(),
// Never pull a stored secret back into a text field. Leaving this
// blank preserves the existing credential when the address stays
// unchanged; typing a new value explicitly replaces it.
password: String::new(),
save_password: true,
show_password: false,
auth: profile.auth,
private_keys: profile.private_keys,
field: SshEditorField::Destination,
focus: crate::ux::FocusIndex::default(),
test: Default::default(),
});
self.nebula_ssh_editor_rects = None;
self.nebula_ssh_editor_open = true;
self.nebula_ssh_editor_hover = SshEditorHit::None;
self.nebula_ui_anims.ssh_editor = UiAnim::new(0.0);
self.pending_update.dirty = true;
self.window.request_redraw();
}
pub fn ssh_editor_active(&self) -> bool {
self.nebula_ssh_editor_open && self.nebula_ssh_editor.is_some()
}
/// 「测试连接」正在跑。转圈指示器要按显示刷新率画,8fps 下一圈只有五六
/// 帧,转起来是一顿一顿的——那种顿挫读作"卡住了",恰好和它要表达的
/// "正在工作"相反。
pub fn ssh_test_running(&self) -> bool {
self.nebula_ssh_editor
.as_ref()
.is_some_and(|editor| matches!(editor.test, ssh_ui::SshTestState::Running { .. }))
}
pub fn close_ssh_editor(&mut self) {
if self.nebula_ssh_editor.is_some() {
self.nebula_ssh_editor_open = false;
self.nebula_ssh_editor_hover = SshEditorHit::None;
self.nebula_ssh_editor_drag = None;
self.pending_update.dirty = true;
self.window.request_redraw();
}
}
pub fn ssh_editor_hit(&self, x: f32, y: f32) -> SshEditorHit {
let Some(rects) = self.nebula_ssh_editor_rects.as_ref() else {
return SshEditorHit::None;
};
let hit = |r: (f32, f32, f32, f32)| x >= r.0 && x < r.0 + r.2 && y >= r.1 && y < r.1 + r.3;
if hit(rects.close) {
SshEditorHit::Close
} else if hit(rects.destination) {
SshEditorHit::Destination
} else if hit(rects.port) {
SshEditorHit::Port
} else if hit(rects.label) {
SshEditorHit::Label
} else if let Some((mode, _)) = rects.auth.iter().find(|(_, rect)| hit(*rect)) {
SshEditorHit::Auth(*mode)
} else if hit(rects.add_private_key) {
SshEditorHit::AddPrivateKey
} else if let Some((index, _)) = rects.private_key_rows.iter().find(|(_, rect)| hit(*rect))
{
SshEditorHit::RemovePrivateKey(*index)
} else if hit(rects.password_toggle) {
SshEditorHit::PasswordToggle
} else if hit(rects.password) {
SshEditorHit::Password
} else if hit(rects.save_checkbox) {
SshEditorHit::SaveToggleBox
} else if hit(rects.save_toggle) {
SshEditorHit::SaveToggleLabel
} else if hit(rects.test) {
SshEditorHit::Test
} else if hit(rects.test_status) {
SshEditorHit::TestStatus
} else if hit(rects.cancel) {
SshEditorHit::Cancel
} else if hit(rects.primary) {
SshEditorHit::Primary
} else {
SshEditorHit::None
}
}
pub fn set_ssh_editor_hover(&mut self, hover: SshEditorHit) {
if self.nebula_ssh_editor_hover != hover {
self.nebula_ssh_editor_hover = hover;
self.pending_update.dirty = true;
}
}
/// 把键盘焦点与文本光标一起放到 Tab 顺序里第 `slot` 个位置上。点击和 Tab
/// 走同一套序号,否则"点一下地址框再按 Tab"会跳到和视觉顺序无关的地方。
///
/// `select_all` 区分两种进入方式:Tab 键进来是全选(原生行为,方便直接
/// 覆写),鼠标点进来是把光标放在点的位置——那时全选等于把用户瞄准的
/// 那个字符位置扔掉。
fn focus_ssh_editor_field(&mut self, slot: usize, select_all: bool) {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
let shows_password = ssh_ui::auth_sections(editor.auth).0;
let count = if shows_password { 7 } else { 6 };
editor.focus.set(slot.min(count - 1), count);
editor.clear_selections();
editor.field = match slot {
1 => SshEditorField::Port,
2 => SshEditorField::Label,
3 if shows_password => SshEditorField::Password,
_ => SshEditorField::Destination,
};
if select_all {
let (value, cursor) = editor.active_field();
let text = value.clone();
cursor.select_all(&text);
}
}
}
/// 鼠标按在输入框里:聚焦该字段并把光标放到点击的字符缝隙上,同时开一段
/// 拖选。`x` 是物理像素的窗口坐标。
pub fn ssh_editor_press_field(&mut self, hit: SshEditorHit, x: f32) {
let Some(rects) = self.nebula_ssh_editor_rects.as_ref() else { return };
let Some((field, rect)) = rects.field_of(hit) else { return };
let origin = rects.metrics.origin(field);
let cell_w = rects.metrics.cell_w;
let slot = match field {
SshEditorField::Destination => 0,
SshEditorField::Port => 1,
SshEditorField::Label => 2,
SshEditorField::Password => 3,
};
self.focus_ssh_editor_field(slot, false);
let Some(editor) = self.nebula_ssh_editor.as_mut() else { return };
let index = editor.index_at(field, x - origin, cell_w);
let (value, cursor) = editor.field_mut(field);
let text = value.clone();
cursor.place(&text, index);
let _ = rect;
self.nebula_ssh_editor_drag = Some(field);
}
/// 拖动中:把选区从按下的锚点拉到当前位置。返回是否真的在拖,让调用方
/// 决定要不要重绘。
pub fn ssh_editor_drag_to(&mut self, x: f32) -> bool {
let Some(field) = self.nebula_ssh_editor_drag else { return false };
let Some(rects) = self.nebula_ssh_editor_rects.as_ref() else { return false };
let origin = rects.metrics.origin(field);
let cell_w = rects.metrics.cell_w;
let Some(editor) = self.nebula_ssh_editor.as_mut() else { return false };
let index = editor.index_at(field, x - origin, cell_w);
let (value, cursor) = editor.field_mut(field);
let text = value.clone();
cursor.extend_to(&text, index);
true
}
pub fn ssh_editor_end_drag(&mut self) {
self.nebula_ssh_editor_drag = None;
}
pub fn ssh_editor_dragging(&self) -> bool {
self.nebula_ssh_editor_drag.is_some()
}
pub fn ssh_editor_insert(&mut self, text: &str) {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
editor.error = None;
// 端口框只收数字,且不超过 5 位。非法字符当场挡住,比让人填完
// 再在保存时弹一句"端口无效"要好——那时已经不知道错在哪一格。
let text = if editor.field == SshEditorField::Port {
let mut digits: String = text.chars().filter(char::is_ascii_digit).collect();
if digits.is_empty() {
return;
}
// 位数上限在插入**前**算:先插再 `truncate` 会砍掉尾部,而
// 用户刚打的那个字符正在光标处——被砍的看起来是别人的字符。
let selected = editor
.port_cursor
.range(&editor.port)
.map_or(0, |(start, end)| end - start);
let room = 5usize.saturating_sub(editor.port.chars().count() - selected);
if room == 0 {
return;
}
digits.truncate(room);
digits
} else {
text.to_owned()
};
let (value, cursor) = editor.active_field();
cursor.insert(value, &text);
editor.test = Default::default();
}
}
pub fn ssh_editor_backspace(&mut self) {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
let before = editor.active_text().len();
let (value, cursor) = editor.active_field();
cursor.backspace(value);
if before != editor.active_text().len() {
editor.test = Default::default();
}
}
}
pub fn ssh_editor_delete_forward(&mut self) {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
let before = editor.active_text().len();
let (value, cursor) = editor.active_field();
cursor.delete_forward(value);
if before != editor.active_text().len() {
editor.test = Default::default();
}
}
}
/// ←/→ 移动光标;`extend` 为真时按住 Shift 扩选。
pub fn ssh_editor_move_caret(&mut self, forward: bool, extend: bool) {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
let (value, cursor) = editor.active_field();
let text = value.clone();
cursor.step(&text, forward, extend);
}
}
/// Home / End。
pub fn ssh_editor_jump_caret(&mut self, to_end: bool, extend: bool) {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
let (value, cursor) = editor.active_field();
let text = value.clone();
cursor.jump(&text, to_end, extend);
}
}
pub fn ssh_editor_select_all(&mut self) {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
let (value, cursor) = editor.active_field();
let text = value.clone();
cursor.select_all(&text);
}
}
/// Copying an invisible password would persist a secret in the system
/// clipboard without visible intent, so it is enabled only after Reveal.
pub fn ssh_editor_selected_text(&self) -> Option<String> {
let editor = self.nebula_ssh_editor.as_ref()?;
if editor.field == SshEditorField::Password && !editor.show_password {
return None;
}
let (text, cursor) = editor.field_view(editor.field);
cursor.selected_text(text)
}
pub fn ssh_editor_next_field(&mut self, reverse: bool) {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
editor.clear_selections();
let shows_password = ssh_ui::auth_sections(editor.auth).0;
// 地址、端口、标签、[密码]、测试、取消、保存。
let count = if shows_password { 7 } else { 6 };
editor.focus.advance(count, reverse);
editor.field = match (shows_password, editor.focus.current()) {
(_, 0) => SshEditorField::Destination,
(_, 1) => SshEditorField::Port,
(_, 2) => SshEditorField::Label,
(true, 3) => SshEditorField::Password,
_ => editor.field,
};
// Tab 进一个输入框时全选:Windows 上所有输入框都这样,让"切过去
// 直接覆写"是一次按键的事。焦点落在按钮上时不选,否则会有一段和
// 当前操作无关的高亮一直亮着。
if editor.focus.current() < if shows_password { 4 } else { 3 } {
let (value, cursor) = editor.active_field();
let text = value.clone();
cursor.select_all(&text);
}
}
}
pub fn ssh_editor_activate_focus(&mut self) {
let Some(editor) = self.nebula_ssh_editor.as_ref() else { return };
let shows_password = ssh_ui::auth_sections(editor.auth).0;
match (shows_password, editor.focus.current()) {
(true, 4) | (false, 3) => self.queue_ssh_test(),
(true, 5) | (false, 4) => self.close_ssh_editor(),
(true, 6) | (false, 5) => self.save_ssh_editor(),
_ => {},
}
}
pub fn ssh_editor_toggle_save(&mut self) {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
editor.save_password = !editor.save_password;
editor.test = Default::default();
}
}
/// input 层在处理完编辑器点击后调用:取走「测试连接」暂存的请求。
pub fn take_ssh_test_request(&mut self) -> Option<crate::ssh_session::SshTestRequest> {
self.nebula_ssh_test_request.take()
}
/// Validate the current draft and stage an exact, numbered test request.
/// The input layer owns the event proxy and takes this request immediately
/// after mouse/keyboard activation.
fn queue_ssh_test(&mut self) {
let Some(editor) = self.nebula_ssh_editor.as_mut() else { return };
let destination = editor.destination.trim().to_owned();
let valid = !destination.is_empty()
&& !destination
.chars()
.any(|c| c.is_whitespace() || c.is_control() || ";&|<>\"'`".contains(c));
if !valid {
editor.error = Some(if destination.is_empty() {
"请输入 SSH 地址,例如 user@example.com".to_owned()
} else {
"地址不能包含空白、控制字符或 shell 分隔符".to_owned()
});
editor.test = Default::default();
return;
}
if matches!(editor.test, ssh_ui::SshTestState::Running { .. }) {
return;
}
let shows_password = ssh_ui::auth_sections(editor.auth).0;
editor.focus.set(if shows_password { 2 } else { 1 }, if shows_password { 5 } else { 4 });
self.nebula_ssh_test_seq = self.nebula_ssh_test_seq.wrapping_add(1).max(1);
let request_id = self.nebula_ssh_test_seq;
editor.error = None;
editor.test = ssh_ui::SshTestState::Running { request_id };
self.nebula_ssh_test_request = Some(crate::ssh_session::SshTestRequest {
request_id,
destination,
auth: editor.auth,
private_keys: editor.private_keys.clone(),
password: (!editor.password.is_empty()).then(|| editor.password.clone()),
});
}
/// 后台测试完成回报。结果只属于发起时的草稿:地址已改或状态已非
/// Running(用户改过字段被清位)时直接丢弃,不让旧结果背书新配置。
pub fn ssh_test_done(
&mut self,
request_id: u64,
destination: &str,
ok: bool,
message: &str,
elapsed_ms: u64,
) {
let Some(editor) = self.nebula_ssh_editor.as_mut() else { return };
if editor.destination.trim() != destination
|| editor.test != (ssh_ui::SshTestState::Running { request_id })
{
return;
}
editor.test = if ok {
ssh_ui::SshTestState::Ok { elapsed_ms }
} else {
ssh_ui::SshTestState::Failed { summary: message.to_owned() }
};
self.pending_update.dirty = true;
self.window.request_redraw();
}
pub fn ssh_editor_click(&mut self, x: f32, y: f32) -> bool {
match self.ssh_editor_hit(x, y) {
// 点哪个框就聚焦哪个框,并把光标放到点中的字符缝隙上,同时开一段
// 拖选。焦点序号必须和 `ssh_editor_next_field` 的顺序一致,否则点
// 完再按 Tab 会跳到毫不相干的地方。
hit @ (SshEditorHit::Destination
| SshEditorHit::Port
| SshEditorHit::Label
| SshEditorHit::Password) => self.ssh_editor_press_field(hit, x),
SshEditorHit::PasswordToggle => {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
editor.show_password = !editor.show_password;
}
},
SshEditorHit::Auth(mode) => {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
editor.auth = mode;
editor.error = None;
editor.test = Default::default();
if !ssh_ui::auth_sections(mode).0 {
editor.field = SshEditorField::Destination;
}
}
},
SshEditorHit::AddPrivateKey => {
if let Some(result) = file_dialog::pick_private_key_file(&self.window) {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
match result {
Ok(path) => {
ssh_ui::push_private_key(&mut editor.private_keys, path);
editor.error = None;
editor.test = Default::default();
},
Err(err) => editor.error = Some(err),
}
}
}
},
SshEditorHit::RemovePrivateKey(index) => {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
if index < editor.private_keys.len() {
editor.private_keys.remove(index);
editor.test = Default::default();
}
}
},
SshEditorHit::SaveToggleBox | SshEditorHit::SaveToggleLabel => {
self.ssh_editor_toggle_save();
},
SshEditorHit::Test => self.queue_ssh_test(),
SshEditorHit::TestStatus => {},
SshEditorHit::Close => {
self.close_ssh_editor();
},
SshEditorHit::Cancel => {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
let shows_password = ssh_ui::auth_sections(editor.auth).0;
editor.focus.set(
if shows_password { 3 } else { 2 },
if shows_password { 5 } else { 4 },
);
}
self.close_ssh_editor();
},
SshEditorHit::Primary => {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
let shows_password = ssh_ui::auth_sections(editor.auth).0;
editor.focus.set(
if shows_password { 4 } else { 3 },
if shows_password { 5 } else { 4 },
);
}
self.save_ssh_editor();
},
SshEditorHit::None => return false,
}
self.pending_update.dirty = true;
self.window.request_redraw();
true
}
pub fn save_ssh_editor(&mut self) {
let Some(mut editor) = self.nebula_ssh_editor.take() else { return };
// 端口先单独校验:它有自己的输入框,错误也该指回那个框。u16 的上界拦不住
// `0`——那不是一个能连的端口,却能通过 parse。
let port = editor.port.trim().to_owned();
if !port.is_empty() && !port.parse::<u16>().is_ok_and(|p| p > 0) {
editor.error = Some("端口需要是 1–65535 之间的数字".to_owned());
editor.field = SshEditorField::Port;
self.nebula_ssh_editor = Some(editor);
self.pending_update.dirty = true;
self.window.request_redraw();
return;
}
// 存盘仍然是一条地址串(凭据、profile、历史都以它为键),端口在这里拼回去。
let destination = ssh_ui::join_destination_port(&editor.destination, &port);
let valid = !destination.is_empty()
&& !destination
.chars()
.any(|c| c.is_whitespace() || c.is_control() || ";&|<>\"'`".contains(c));
if !valid {
editor.error = Some(if destination.is_empty() {
"请输入 SSH 地址,例如 user@example.com".to_owned()
} else {
"地址不能包含空白、控制字符或 shell 分隔符".to_owned()
});
editor.field = SshEditorField::Destination;
self.nebula_ssh_editor = Some(editor);
self.pending_update.dirty = true;
self.window.request_redraw();
return;
}
if let Some(original) = editor.original_destination.as_deref() {
if original != destination {
self.nebula_saved_hosts.retain(|host| host != original);
self.nebula_pinned_hosts.retain(|host| host != original);
if !self.nebula_hidden_hosts.iter().any(|host| host == original) {
self.nebula_hidden_hosts.push(original.to_owned());
}
#[cfg(windows)]
{
let _ = crate::ssh_credentials::forget_password(original);
}
}
}
// Saving/editing is an explicit request to surface this destination,
// so it also undoes a previous Delete of the same address.
self.nebula_hidden_hosts.retain(|host| host != &destination);
self.nebula_saved_hosts.retain(|host| host != &destination);
self.nebula_saved_hosts.insert(0, destination.clone());
self.nebula_saved_hosts.truncate(20);
self.nebula_ssh_hosts = merge_ssh_hosts(
&self.nebula_saved_hosts,
&self.nebula_pinned_hosts,
&self.nebula_hidden_hosts,
);
let profile_path = nebula_data_dir().join("ssh_profiles.json");
let mut profiles =
crate::ssh_profiles::SshProfiles::load(&profile_path).unwrap_or_else(|err| {
warn!("加载 SSH Profile 失败,将创建新文件: {err}");
crate::ssh_profiles::SshProfiles::default()
});
if let Some(original) = editor.original_destination.as_deref() {
if original != destination {
profiles.rename(original, &destination);
}
}
profiles.upsert(crate::ssh_profiles::SshProfileAuth {
destination: destination.clone(),
auth: editor.auth,
private_keys: editor.private_keys.clone(),
// 空标签存成 None 而不是空串:两者语义相同,但 None 会被跳过序列化,
// 于是没起名字的主机在配置文件里干干净净。
label: Some(editor.label.trim().to_owned()).filter(|label| !label.is_empty()),
});
if let Err(err) = profiles.save(&profile_path) {
editor.error = Some(format!("保存 SSH Profile 失败: {err}"));
self.nebula_ssh_editor = Some(editor);
self.pending_update.dirty = true;
self.window.request_redraw();
return;
}
// 只在存盘成功后才刷新缓存:写失败时侧栏应当继续显示旧名字,而不是
// 显示一个磁盘上并不存在的标签。
self.nebula_ssh_labels = profiles.labels();
if ssh_ui::auth_sections(editor.auth).0
&& editor.save_password
&& !editor.password.is_empty()
{
#[cfg(windows)]
{
let _ = crate::ssh_credentials::store_password(
&destination,
editor.password.as_bytes(),
);
}
}
// 凭据落盘后立即清除内存中的明文,但保留其余内容完成短退出动画。
editor.password.clear();
self.persist_nebula_settings();
self.nebula_ssh_editor = Some(editor);
self.close_ssh_editor();
}
}
File diff suppressed because it is too large. Load diff
+225 -4
View File
@@ -4,13 +4,17 @@
//! rule explicit: a pending deletion owns credential cleanup and only Undo may
//! disarm it.
use super::text_input::SelectAllState;
use super::ui::text_field::TextCursor;
use crate::ssh_profiles::SshAuthMode;
use std::path::PathBuf;
/// How long a destructive SSH action stays reversible in the in-app bar.
pub const SSH_DELETE_UNDO_DURATION: std::time::Duration = std::time::Duration::from_secs(8);
/// 密码框的掩码字符。渲染和点击命中共用它:命中要按**看得见的那串**算列,
/// 否则密码里有一个全角字符,光标就会落到与手指差好几格的地方。
pub const PASSWORD_MASK: &str = "•";
pub fn auth_sections(mode: SshAuthMode) -> (bool, bool) {
match mode {
SshAuthMode::Auto => (true, true),
@@ -30,6 +34,61 @@ pub fn push_private_key(keys: &mut Vec<PathBuf>, path: PathBuf) -> bool {
}
}
/// 把一个地址串拆成「不含端口的地址」和「端口」,喂给编辑器的两个输入框。
///
/// 端口是 22 或根本没写时返回空串——空的端口框读作"用默认值",比预填一个
/// `22` 好:预填会让人以为这是自己设的,删掉反而不知道会发生什么。
///
/// 端口位置写了非数字(`host:abc`)时整串原样留在地址里,交给地址校验去报
/// 错。悄悄吞掉那一截,用户会看着自己粘进来的东西凭空少了一半。
pub fn split_destination_port(destination: &str) -> (String, String) {
let trimmed = destination.trim();
let (scheme, rest) = match trimmed.strip_prefix("ssh://") {
Some(rest) => ("ssh://", rest),
None => ("", trimmed),
};
let (user, host_port) = match rest.rsplit_once('@') {
Some((user, host_port)) => (Some(user), host_port),
None => (None, rest),
};
let (host, port) = if let Some(after) = host_port.strip_prefix('[') {
// IPv6 字面量:`[::1]:22`,中括号里的冒号属于地址。
match after.split_once(']') {
Some((host, suffix)) => {
(format!("[{host}]"), suffix.strip_prefix(':').unwrap_or_default())
},
None => (host_port.to_owned(), ""),
}
} else if let Some((host, port)) = host_port.rsplit_once(':') {
// 没加中括号的裸 IPv6 不能按 host:port 拆——最后一段是地址的一部分。
if host.contains(':') { (host_port.to_owned(), "") } else { (host.to_owned(), port) }
} else {
(host_port.to_owned(), "")
};
if !port.is_empty() && port.parse::<u16>().is_err() {
return (trimmed.to_owned(), String::new());
}
let mut address = String::from(scheme);
if let Some(user) = user {
address.push_str(user);
address.push('@');
}
address.push_str(&host);
(address, if port == "22" { String::new() } else { port.to_owned() })
}
/// 拼回存盘用的地址串。地址框里自带的端口优先——用户刚粘进去的那个才是意图,
/// 端口框里的可能是上一台主机的残留。
pub fn join_destination_port(address: &str, port: &str) -> String {
let (base, embedded) = split_destination_port(address);
let port = if embedded.is_empty() { port.trim() } else { embedded.as_str() };
// 默认端口不写进地址:给每台主机都缀上 `:22` 只是噪音,而且会让同一台
// 机器出现 `host` 和 `host:22` 两条记录、各自一份凭据。
if port.is_empty() || port == "22" { base } else { format!("{base}:{port}") }
}
#[derive(Debug)]
pub(super) struct SshDeleteUndo {
pub(super) host: String,
@@ -58,6 +117,11 @@ impl Drop for SshDeleteUndo {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SshEditorField {
Destination,
/// 端口。数据上仍然属于 destination(`host:2222`),拆成独立字段只是
/// 因为把端口埋在地址串里,用户既看不见默认值也不知道能改。
Port,
/// 列表里显示的名字。空则回落到地址本身。
Label,
Password,
}
@@ -66,6 +130,8 @@ pub enum SshEditorHit {
None,
Close,
Destination,
Port,
Label,
Password,
PasswordToggle,
Auth(SshAuthMode),
@@ -101,6 +167,10 @@ pub struct SshHostEditor {
/// Destination before editing, when this modal was opened from a row.
pub original_destination: Option<String>,
pub destination: String,
/// 端口输入框的内容。空 = 用默认 22,保存时不写进地址串。
pub port: String,
/// 列表显示名。空 = 显示地址。
pub label: String,
pub password: String,
pub save_password: bool,
pub show_password: bool,
@@ -111,14 +181,105 @@ pub struct SshHostEditor {
pub error: Option<String>,
/// 「测试连接」当前状态;见 [`SshTestState`]。
pub test: SshTestState,
pub(super) destination_selection: SelectAllState,
pub(super) password_selection: SelectAllState,
pub(super) destination_cursor: TextCursor,
pub(super) port_cursor: TextCursor,
pub(super) label_cursor: TextCursor,
pub(super) password_cursor: TextCursor,
}
impl SshHostEditor {
/// 某个字段的文本与光标。插入、退格、全选、点击定位对四个字段的处理完全
/// 一样,集中到这里之后,再加字段只需要补这一个 match,而不是把同样的分支
/// 在五个方法里各抄一遍——那种写法漏一处就是"某个框打不出字"。
pub(super) fn field_mut(&mut self, field: SshEditorField) -> (&mut String, &mut TextCursor) {
match field {
SshEditorField::Destination => (&mut self.destination, &mut self.destination_cursor),
SshEditorField::Port => (&mut self.port, &mut self.port_cursor),
SshEditorField::Label => (&mut self.label, &mut self.label_cursor),
SshEditorField::Password => (&mut self.password, &mut self.password_cursor),
}
}
/// 只读版本,给渲染与命中用。
pub(super) fn field_view(&self, field: SshEditorField) -> (&str, &TextCursor) {
match field {
SshEditorField::Destination => (&self.destination, &self.destination_cursor),
SshEditorField::Port => (&self.port, &self.port_cursor),
SshEditorField::Label => (&self.label, &self.label_cursor),
SshEditorField::Password => (&self.password, &self.password_cursor),
}
}
pub(super) fn active_field(&mut self) -> (&mut String, &mut TextCursor) {
self.field_mut(self.field)
}
pub(super) fn active_text(&self) -> &str {
self.field_view(self.field).0
}
/// 清掉全部四个字段的选区,但保留各自的光标位置。切字段时用:选区是
/// "此刻正在操作的一段",跨字段留着它只会让人以为那边还选着东西。
pub(super) fn clear_selections(&mut self) {
self.destination_cursor.clear_selection();
self.port_cursor.clear_selection();
self.label_cursor.clear_selection();
self.password_cursor.clear_selection();
}
/// 距文字起点 `offset_x` 像素处落在第几个字符的缝隙上。
pub(super) fn index_at(&self, field: SshEditorField, offset_x: f32, cell_w: f32) -> usize {
let text = self.field_view(field).0;
if field == SshEditorField::Password && !self.show_password {
let masked = PASSWORD_MASK.repeat(text.chars().count());
return super::ui::text_field::index_at(&masked, offset_x, cell_w);
}
super::ui::text_field::index_at(text, offset_x, cell_w)
}
/// 实际画在框里的那串字,供渲染和光标定位共用。空串时返回空——占位文案
/// 不参与光标定位,否则光标会跑到"留空则连接时询问"的末尾去。
pub(super) fn display_text(&self, field: SshEditorField) -> String {
let text = self.field_view(field).0;
if field == SshEditorField::Password && !self.show_password {
return PASSWORD_MASK.repeat(text.chars().count());
}
text.to_owned()
}
}
/// 输入框里文字的排布,由渲染写入、命中读取。
///
/// 光标要落在字符缝隙上,就必须和文字用同一个起点和同一个格宽。两边各算一次
/// 的写法在全角字符和居中字段上必然漂移,而漂移几像素恰恰是最难被报告、也
/// 最伤手感的那类问题——所以这里让绘制把算好的值交出来,命中直接用。
#[derive(Debug, Clone, Copy, Default)]
pub struct SshFieldMetrics {
pub destination_x: f32,
pub port_x: f32,
pub label_x: f32,
pub password_x: f32,
/// 一列的推进宽度(物理像素)。
pub cell_w: f32,
}
impl SshFieldMetrics {
pub fn origin(&self, field: SshEditorField) -> f32 {
match field {
SshEditorField::Destination => self.destination_x,
SshEditorField::Port => self.port_x,
SshEditorField::Label => self.label_x,
SshEditorField::Password => self.password_x,
}
}
}
#[derive(Debug, Clone)]
pub struct SshEditorRects {
pub close: (f32, f32, f32, f32),
pub destination: (f32, f32, f32, f32),
pub port: (f32, f32, f32, f32),
pub label: (f32, f32, f32, f32),
pub password: (f32, f32, f32, f32),
pub password_toggle: (f32, f32, f32, f32),
pub auth: [(SshAuthMode, (f32, f32, f32, f32)); 4],
@@ -130,11 +291,26 @@ pub struct SshEditorRects {
pub test_status: (f32, f32, f32, f32),
pub primary: (f32, f32, f32, f32),
pub cancel: (f32, f32, f32, f32),
pub metrics: SshFieldMetrics,
}
impl SshEditorRects {
/// 输入框矩形 + 文字起点,供点击定位用。返回 `None` 表示这个 hit 不是
/// 可编辑字段。
pub fn field_of(&self, hit: SshEditorHit) -> Option<(SshEditorField, (f32, f32, f32, f32))> {
match hit {
SshEditorHit::Destination => Some((SshEditorField::Destination, self.destination)),
SshEditorHit::Port => Some((SshEditorField::Port, self.port)),
SshEditorHit::Label => Some((SshEditorField::Label, self.label)),
SshEditorHit::Password => Some((SshEditorField::Password, self.password)),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::{auth_sections, push_private_key};
use super::{auth_sections, join_destination_port, push_private_key, split_destination_port};
use crate::ssh_profiles::SshAuthMode;
use std::path::PathBuf;
@@ -154,4 +330,49 @@ mod tests {
assert!(push_private_key(&mut keys, PathBuf::from(r"C:\Keys\second")));
assert_eq!(keys, vec![PathBuf::from(r"C:\Keys\first"), PathBuf::from(r"C:\Keys\second")]);
}
#[test]
fn destination_splits_into_address_and_port() {
assert_eq!(
split_destination_port("dev@example.com:2222"),
("dev@example.com".to_owned(), "2222".to_owned())
);
// 默认端口不回填到端口框。
assert_eq!(
split_destination_port("dev@example.com:22"),
("dev@example.com".to_owned(), String::new())
);
assert_eq!(
split_destination_port("ssh://dev@example.com:2222"),
("ssh://dev@example.com".to_owned(), "2222".to_owned())
);
assert_eq!(
split_destination_port("example.com"),
("example.com".to_owned(), String::new())
);
}
#[test]
fn destination_split_keeps_ipv6_and_bad_ports_intact() {
assert_eq!(
split_destination_port("dev@[fe80::1]:2222"),
("dev@[fe80::1]".to_owned(), "2222".to_owned())
);
// 裸 IPv6 的末段是地址,不是端口。
assert_eq!(split_destination_port("fe80::1"), ("fe80::1".to_owned(), String::new()));
// 端口位置不是数字:整串留在地址里让校验去报错,不能悄悄截掉。
assert_eq!(
split_destination_port("dev@example.com:abc"),
("dev@example.com:abc".to_owned(), String::new())
);
}
#[test]
fn destination_join_prefers_the_port_typed_into_the_address() {
assert_eq!(join_destination_port("dev@example.com", "2222"), "dev@example.com:2222");
assert_eq!(join_destination_port("dev@example.com", ""), "dev@example.com");
assert_eq!(join_destination_port("dev@example.com", "22"), "dev@example.com");
// 地址框里粘进来的端口压过端口框里的旧值。
assert_eq!(join_destination_port("dev@example.com:2022", "2222"), "dev@example.com:2022");
}
}
+12 -4
View File
@@ -191,7 +191,7 @@ mod tests {
这正是「配色/圆角改不干净」的结构性原因。",
"radius::OVERLAY * scale(浮层)/ radius::CONTROL * scale(控件)\n\
/ radius::CHIP * scale(chip 键帽),或直接用 surface::push_* 配方",
94,
69,
hits,
);
}
@@ -215,7 +215,7 @@ mod tests {
"手写描边普遍内外用同一个半径,圆角处描边会变粗——「边缘发毛」的\n\
来源之一。push_stroke 让外半径 = 内半径 + 描边宽,内外弧同心。",
"surface::push_stroke(quads, rect, corner, scale, sk.hairline)",
56,
47,
hits,
);
}
@@ -225,8 +225,16 @@ mod tests {
let mut hits = Vec::new();
for path in source_files() {
let Ok(src) = std::fs::read_to_string(&path) else { continue };
for (index, line) in src.lines().enumerate() {
if line.contains("UiQuad::glow(") {
let lines: Vec<&str> = src.lines().collect();
for (index, line) in lines.iter().enumerate() {
if !line.contains("UiQuad::glow(") {
continue;
}
// 星云品牌位的粒子/脉冲本来就该发光——那是画面主体,不是在给
// 浮层垫高度。豁免必须在紧邻的上一行显式写出标记,所以它不会
// 悄悄扩散:想用就得先写下理由,reviewer 一眼看得见。
let exempt = index > 0 && lines[index - 1].contains("brand-glow:");
if !exempt {
hits.push((label(&path), index + 1, "用 glow 冒充阴影".to_owned()));
}
}
+2
View File
@@ -28,6 +28,7 @@
//! - [`tokens`] —— 间距、圆角、字阶、动效、层级的常量目录
//! - [`theme`] —— [`Skin`](theme::Skin):主题派生的墨色与表面色
//! - [`surface`] —— 面板/卡片/描边/遮罩的配方(本层的核心)
//! - [`text_field`] —— 输入框的光标、选区、命中测试(唯一有可变状态的组件)
//! - [`widgets`] —— slider / toggle / combobox / spinner
//! - [`keycap`] —— 键帽 chip
//! - [`icons`] —— 图标墨迹
@@ -52,6 +53,7 @@ pub mod guardrails;
pub mod icons;
pub mod keycap;
pub mod surface;
pub mod text_field;
pub mod theme;
pub mod tokens;
pub mod widgets;
+97 -1
View File
@@ -89,6 +89,18 @@ pub fn fade(color: Rgba, progress: f32) -> Rgba {
Rgba::new(color.r, color.g, color.b, (color.a as f32 * progress.clamp(0.0, 1.0)).round() as u8)
}
/// `top` 压在 `base` 上的合成色,alpha 取 `base` 的。
///
/// 用来把"半透明叠加色 + 不透明底"预先算成一个色,画一个 quad 而不是两个。
/// 这不只是省一次绘制:见 [`push_group`],中间那层必须不透明,否则底下的
/// 描边环会从整块面板里渗出来。
#[inline]
pub fn over(top: Rgba, base: Rgba) -> Rgba {
let a = top.a as f32 / 255.0;
let mix = |t: u8, b: u8| (t as f32 * a + b as f32 * (1.0 - a)).round().clamp(0.0, 255.0) as u8;
Rgba::new(mix(top.r, base.r), mix(top.g, base.g), mix(top.b, base.b), base.a)
}
/// 发丝描边环。替代散落各处的手写 `(x-1, y-1, w+2, h+2)`。
///
/// 外圆角自动取 `radius + hairline`,保证内外弧**同心**——手写处常常内外用同一
@@ -120,12 +132,51 @@ pub fn push_surface(
sk: &Skin,
level: Elevation,
progress: f32,
) {
push_surface_in(
quads,
rect,
(0.0, 0.0, viewport.0, viewport.1),
0.0,
scale,
sk,
level,
progress,
);
}
/// 同 [`push_surface`],但遮罩只铺满 `veil_rect` 而不是整窗,并带 `veil_radius`
/// 的圆角。
///
/// 用于那些语义上属于某块区域的模态:SSH 主机编辑器是"对终端做的事",遮罩
/// 盖住终端卡就够了。连侧栏和标题栏一起罩黑会把它读成"整个应用被阻断",而
/// 那时侧栏其实仍然是可见的上下文。
///
/// 圆角是必须的:终端是一张圆角卡,直角遮罩会在四角各露出一块,读起来像糊
/// 歪了一层贴纸。
#[allow(clippy::too_many_arguments)]
pub fn push_surface_in(
quads: &mut Vec<UiQuad>,
rect: Rect,
veil_rect: Rect,
veil_radius: f32,
scale: f32,
sk: &Skin,
level: Elevation,
progress: f32,
) {
let (x, y, w, h) = rect;
let corner = radius::OVERLAY * scale;
if level.dims_background() {
quads.push(UiQuad::solid(0.0, 0.0, viewport.0, viewport.1, 0.0, fade(sk.veil, progress)));
quads.push(UiQuad::solid(
veil_rect.0,
veil_rect.1,
veil_rect.2,
veil_rect.3,
veil_radius,
fade(sk.veil, progress),
));
}
// 外阴影承担 Z 轴层级;发丝环只负责在浅色背景上定义边界。两者分工不同,
@@ -168,6 +219,24 @@ pub fn push_card(quads: &mut Vec<UiQuad>, rect: Rect, scale: f32, sk: &Skin, sta
quads.push(UiQuad::solid(x, y, w, h, corner, fill));
}
/// 带发丝描边的分组卡片:把同组字段收成一块可扫读的区域。
///
/// **不要写成 `push_stroke` + `push_card`。** [`push_stroke`] 画的是一个比
/// 目标大 1px 的**实心**圆角矩形,靠上层填充盖住中心才形成"环"。`panel` 和
/// `input` 都不透明,盖得住;而 `card` 只有 4.5% 不透明度,盖不住——描边色
/// 有 95% 透过整张卡片,把它压深整整一档。
///
/// 2026-07-31 量到的实迹(Nebula 深色):panel (29,31,40) 经描边泄漏变成
/// (50,52,62),再叠 card 得 (62,65,74),而原型是 (56,61,73)。所以这里把
/// `card` 与 `panel` 预先合成成一个**不透明**色,一个 quad 画完,描边环也
/// 就只剩它该有的那 1px。
pub fn push_group(quads: &mut Vec<UiQuad>, rect: Rect, scale: f32, sk: &Skin, progress: f32) {
let (x, y, w, h) = rect;
let corner = radius::OVERLAY * scale;
push_stroke(quads, rect, corner, scale, fade(sk.hairline, progress));
quads.push(UiQuad::solid(x, y, w, h, corner, fade(over(sk.card, sk.panel), progress)));
}
/// 下沉表面(文本输入框)。聚焦时描边换成强调色——焦点不能只靠颜色深浅表示。
pub fn push_input(quads: &mut Vec<UiQuad>, rect: Rect, scale: f32, sk: &Skin, focused: bool) {
let (x, y, w, h) = rect;
@@ -190,6 +259,33 @@ mod tests {
NebulaTheme::SilverLight.skin()
}
#[test]
fn a_group_card_seals_the_stroke_ring_with_an_opaque_fill() {
// 这条锁的是 2026-07-31 那个 bug:`push_stroke` 画的是实心矩形,
// 靠上层填充盖住中心才成环。card 只有 4.5% 不透明度,直接叠上去
// 会让描边色渗满整张卡片,把它压深一整档。所以内芯必须不透明。
for skin in [light(), NebulaTheme::Nebula.skin()] {
let mut quads = Vec::new();
push_group(&mut quads, (40.0, 40.0, 300.0, 120.0), 1.0, &skin, 1.0);
assert_eq!(quads.len(), 2, "只该有描边环和内芯两个 quad");
assert_eq!(quads[1].color0.a, 255, "内芯必须不透明,否则描边渗出来");
}
}
#[test]
fn a_group_card_reads_one_step_above_its_panel() {
// card 是"比底板亮一档"的叠加色。合成之后这个方向必须还在——
// 深色主题叠白、浅色主题叠 slate,两边的符号是相反的。
let dark = NebulaTheme::Nebula.skin();
let fill = over(dark.card, dark.panel);
assert!(fill.r > dark.panel.r, "深色下卡片要比面板亮");
let sun = light();
let fill = over(sun.card, sun.panel);
assert!(fill.r < sun.panel.r, "浅色下卡片要比面板暗");
}
#[test]
fn stroke_ring_stays_concentric_with_its_fill() {
// 内外弧同心的判据:外半径 - 内半径 == 描边宽度。不满足时描边在圆角
+385
View File
@@ -0,0 +1,385 @@
//! 自绘输入框的光标、选区与命中测试。
//!
//! # 为什么在组件层
//!
//! 在此之前每个输入框只有一个 `bool`("整个内容是否被全选"),光标永远画在
//! 文本末尾。于是点击框内某处只能聚焦、不能定位,拖拽不能选中,方向键无处
//! 可去——这不是没接线,是**模型里根本没有"光标在哪"这个概念**。
//!
//! 这类能力是输入框的地板而不是天花板:Windows 上任何一个 `<input>` 都自带
//! 它们,用户不会认为这是功能,只会认为坏了。所以它必须住在组件层,让每个
//! 新输入框自动获得,而不是各自实现一遍——各自实现的结局必然是"地址框能拖
//! 选、密码框不能"这种说不出理由的不一致。
//!
//! # 模型
//!
//! 一个字符索引 `caret` 加一个可选的 `anchor`。选区是 `[min, max)` 的半开
//! 区间,`anchor == caret` 视作没有选区。索引单位是**字符**而不是字节,因为
//! 上层拿到的是"第几个字";字节偏移只在真正要切 `String` 时才换算。
//!
//! 所有查询都对当前文本做 clamp。文本可能被外部改写(粘贴地址时自动拆出
//! 端口、切换认证方式时清空密码),此时旧的 caret 会越界——clamp 让它退化
//! 成"落在末尾",而不是 panic。
use super::caret;
use super::theme::Skin;
use super::tokens::{radius, space};
use crate::renderer::ui::{Rgba, UiQuad};
use unicode_width::UnicodeWidthChar;
/// 输入框里的光标与选区。
#[derive(Debug, Clone, Default)]
pub struct TextCursor {
caret: usize,
anchor: Option<usize>,
}
impl TextCursor {
/// 光标位置,已 clamp 到 `text` 的长度内。
pub fn caret(&self, text: &str) -> usize {
self.caret.min(char_count(text))
}
/// 选区的 `[start, end)`;没有选中任何字符时返回 `None`。
pub fn range(&self, text: &str) -> Option<(usize, usize)> {
let len = char_count(text);
let anchor = self.anchor?.min(len);
let caret = self.caret.min(len);
(anchor != caret).then(|| (anchor.min(caret), anchor.max(caret)))
}
pub fn has_selection(&self, text: &str) -> bool {
self.range(text).is_some()
}
pub fn selected_text(&self, text: &str) -> Option<String> {
let (start, end) = self.range(text)?;
Some(text.chars().skip(start).take(end - start).collect())
}
pub fn select_all(&mut self, text: &str) {
let len = char_count(text);
// 空串上不留锚点:否则 `has_selection` 为真而选区宽度为零,渲染会画
// 出一条贴边的高亮,读起来像多了个看不懂的竖条。
self.anchor = (len > 0).then_some(0);
self.caret = len;
caret::note_activity();
}
/// 单击定位:光标落在 `index`,清掉选区。
pub fn place(&mut self, text: &str, index: usize) {
self.caret = index.min(char_count(text));
self.anchor = None;
caret::note_activity();
}
/// 拖拽或 Shift+移动:保留锚点(没有就把当前光标当锚点),把光标拉到
/// `index`。
pub fn extend_to(&mut self, text: &str, index: usize) {
let len = char_count(text);
self.anchor.get_or_insert(self.caret.min(len));
self.caret = index.min(len);
caret::note_activity();
}
/// 光标塌到末尾。Tab 切进一个字段时用这个,而不是 `select_all`——
/// 全选会让紧接着的一次按键把已有内容整个吃掉。
pub fn collapse_to_end(&mut self, text: &str) {
self.caret = char_count(text);
self.anchor = None;
caret::note_activity();
}
pub fn clear_selection(&mut self) {
self.anchor = None;
caret::note_activity();
}
/// 左右移动一格。
///
/// 有选区且不按 Shift 时,方向键是**塌陷到选区的那一端**而不是移动一格
/// ——这是所有原生输入框的行为:选中一段后按 →,光标去选区右端,不是右
/// 端再往右一个字符。
pub fn step(&mut self, text: &str, forward: bool, extend: bool) {
if !extend {
if let Some((start, end)) = self.range(text) {
self.caret = if forward { end } else { start };
self.anchor = None;
caret::note_activity();
return;
}
}
let len = char_count(text);
let caret = self.caret.min(len);
let next = if forward { (caret + 1).min(len) } else { caret.saturating_sub(1) };
if extend {
self.extend_to(text, next);
} else {
self.place(text, next);
}
}
/// Home / End。
pub fn jump(&mut self, text: &str, to_end: bool, extend: bool) {
let index = if to_end { char_count(text) } else { 0 };
if extend {
self.extend_to(text, index);
} else {
self.place(text, index);
}
}
/// 在光标处插入,先删掉选区。控制字符被丢弃——粘贴多行文本时把换行
/// 带进单行输入框,会得到一个看不见却会被存盘的字符。
pub fn insert(&mut self, text: &mut String, incoming: &str) {
self.delete_selection(text);
let filtered: String = incoming.chars().filter(|c| !c.is_control()).collect();
if !filtered.is_empty() {
let at = byte_index(text, self.caret.min(char_count(text)));
let inserted = char_count(&filtered);
text.insert_str(at, &filtered);
self.caret = self.caret.min(char_count(text)) + inserted;
self.anchor = None;
}
caret::note_activity();
}
/// Backspace:有选区删选区,否则删光标前一个字符。
pub fn backspace(&mut self, text: &mut String) {
if !self.delete_selection(text) {
let caret = self.caret.min(char_count(text));
if caret > 0 {
let from = byte_index(text, caret - 1);
let to = byte_index(text, caret);
text.replace_range(from..to, "");
self.caret = caret - 1;
}
}
caret::note_activity();
}
/// Delete:有选区删选区,否则删光标后一个字符。
pub fn delete_forward(&mut self, text: &mut String) {
if !self.delete_selection(text) {
let caret = self.caret.min(char_count(text));
if caret < char_count(text) {
let from = byte_index(text, caret);
let to = byte_index(text, caret + 1);
text.replace_range(from..to, "");
}
}
caret::note_activity();
}
/// 删掉选区并把光标收到选区起点。返回是否真的删了东西,让调用方决定
/// 要不要接着做"没有选区时"的那件事。
fn delete_selection(&mut self, text: &mut String) -> bool {
let Some((start, end)) = self.range(text) else {
return false;
};
let from = byte_index(text, start);
let to = byte_index(text, end);
text.replace_range(from..to, "");
self.caret = start;
self.anchor = None;
true
}
}
fn char_count(text: &str) -> usize {
text.chars().count()
}
/// 第 `index` 个字符的字节偏移;越界时给 `text.len()`,于是"在末尾插入"是
/// 自然的退化行为。
fn byte_index(text: &str, index: usize) -> usize {
text.char_indices().nth(index).map_or(text.len(), |(at, _)| at)
}
/// 一段文本占多少显示列。等宽网格上一个全角字符占两列,所以不能用字符数。
pub fn columns(text: &str) -> usize {
text.chars().map(|c| c.width().unwrap_or(1)).sum()
}
/// 前 `index` 个字符占多少列。光标和选区的像素位置都由它算,绘制与命中因此
/// 共用同一套换算——两边各算一次必然会在全角字符上漂移。
pub fn columns_before(text: &str, index: usize) -> usize {
text.chars().take(index).map(|c| c.width().unwrap_or(1)).sum()
}
/// 落在 `offset_x`(相对文本起点的像素)处的字符边界。
///
/// 判据是"过半进位":点在一个字符的左半边,光标停在它**前面**;右半边,停在
/// 它后面。这是原生输入框的手感——点击的意图是"插到这里",不是"选中这个字"。
pub fn index_at(text: &str, offset_x: f32, cell_w: f32) -> usize {
if cell_w <= 0.0 {
return 0;
}
let mut x = 0.0;
for (index, character) in text.chars().enumerate() {
let width = character.width().unwrap_or(1) as f32 * cell_w;
if offset_x < x + width * 0.5 {
return index;
}
x += width;
}
char_count(text)
}
/// 画选区高亮或光标竖线。
///
/// `display` 是**实际画在框里的那串字**:密码框传掩码,空框传空串(不要传
/// 占位文案,否则光标会跑到占位文字的末尾去)。索引与真文本一一对应,因为
/// 掩码是逐字符替换的。
///
/// `text_x` 由调用方给,因为对齐方式是调用方的事(端口居中、其余靠左)——
/// 组件层只负责让光标落在与文字同一套换算上。
#[allow(clippy::too_many_arguments)]
pub fn push_cursor(
quads: &mut Vec<UiQuad>,
field_y: f32,
field_h: f32,
text_x: f32,
display: &str,
cursor: &TextCursor,
cell_w: f32,
scale: f32,
sk: &Skin,
) {
let inset = space::XXS * scale;
if let Some((start, end)) = cursor.range(display) {
let from = text_x + columns_before(display, start) as f32 * cell_w;
let width = (columns_before(display, end) - columns_before(display, start)) as f32 * cell_w;
quads.push(UiQuad::solid(
from,
field_y + inset,
width,
field_h - inset * 2.0,
radius::CHIP * scale,
sk.accent_soft,
));
return;
}
// 没有选区才画竖线。相位来自共享节律:打字时常亮、停手后才呼吸,
// 且聚焦的瞬间必定是亮的。
if !caret::is_on() {
return;
}
let x = text_x + columns_before(display, cursor.caret(display)) as f32 * cell_w;
let thickness = (1.5 * scale).max(1.0);
quads.push(UiQuad::solid(
x,
field_y + inset,
thickness,
field_h - inset * 2.0,
0.0,
Rgba::new(sk.ink_strong.r, sk.ink_strong.g, sk.ink_strong.b, 235),
));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn typing_lands_at_the_caret_not_the_end() {
let mut text = "abcd".to_owned();
let mut cursor = TextCursor::default();
cursor.place(&text, 2);
cursor.insert(&mut text, "XY");
assert_eq!(text, "abXYcd");
// 光标跟着插入的内容走,接着打字才会续在后面。
assert_eq!(cursor.caret(&text), 4);
}
#[test]
fn typing_replaces_the_selection() {
let mut text = "root@example.com".to_owned();
let mut cursor = TextCursor::default();
cursor.place(&text, 0);
cursor.extend_to(&text, 4);
assert_eq!(cursor.selected_text(&text).as_deref(), Some("root"));
cursor.insert(&mut text, "admin");
assert_eq!(text, "admin@example.com");
assert!(!cursor.has_selection(&text));
}
#[test]
fn backspace_eats_the_selection_before_the_character() {
let mut text = "abcdef".to_owned();
let mut cursor = TextCursor::default();
cursor.place(&text, 1);
cursor.extend_to(&text, 4);
cursor.backspace(&mut text);
assert_eq!(text, "aef");
assert_eq!(cursor.caret(&text), 1);
// 选区没了,这次才轮到删单个字符。
cursor.backspace(&mut text);
assert_eq!(text, "ef");
}
#[test]
fn arrow_collapses_a_selection_to_its_edge() {
let text = "abcdef".to_owned();
let mut cursor = TextCursor::default();
cursor.place(&text, 1);
cursor.extend_to(&text, 4);
cursor.step(&text, true, false);
assert_eq!(cursor.caret(&text), 4, "按右应该跳到选区右端,不是右端再右一格");
cursor.place(&text, 1);
cursor.extend_to(&text, 4);
cursor.step(&text, false, false);
assert_eq!(cursor.caret(&text), 1);
}
#[test]
fn shift_arrow_keeps_extending_from_the_anchor() {
let text = "abcdef".to_owned();
let mut cursor = TextCursor::default();
cursor.place(&text, 3);
cursor.step(&text, false, true);
cursor.step(&text, false, true);
assert_eq!(cursor.range(&text), Some((1, 3)));
}
#[test]
fn a_stale_caret_degrades_to_the_end_instead_of_panicking() {
let mut text = "192.168.1.1:2222".to_owned();
let mut cursor = TextCursor::default();
cursor.collapse_to_end(&text);
// 粘贴地址后上层把端口拆走了,文本比光标记得的短。
text = "192.168.1.1".to_owned();
assert_eq!(cursor.caret(&text), char_count(&text));
cursor.insert(&mut text, "x");
assert_eq!(text, "192.168.1.1x");
}
#[test]
fn clicking_picks_the_nearer_character_boundary() {
let text = "abcd";
// 一格宽 10:点在第 2 格的左半 → 停在它前面(索引 1)。
assert_eq!(index_at(text, 11.0, 10.0), 1);
// 右半 → 停在它后面(索引 2)。
assert_eq!(index_at(text, 19.0, 10.0), 2);
// 点在文字右边的空白里 → 落到末尾。
assert_eq!(index_at(text, 999.0, 10.0), 4);
assert_eq!(index_at(text, -5.0, 10.0), 0);
}
#[test]
fn wide_characters_advance_two_columns() {
let text = "生产数据库";
assert_eq!(columns(text), 10);
assert_eq!(columns_before(text, 2), 4);
// 点在第三个字的左半边,光标要停在它前面而不是被算成第 5 个字符。
assert_eq!(index_at(text, 41.0, 10.0), 2);
}
#[test]
fn paste_strips_control_characters() {
let mut text = String::new();
let mut cursor = TextCursor::default();
cursor.insert(&mut text, "dev@host\r\n");
assert_eq!(text, "dev@host");
}
}
+50 -24
View File
@@ -427,32 +427,45 @@ impl NebulaTheme {
let p = self.palette();
let a = self.accent();
let t = p.term_bg;
// 浮层相对**内容层**(终端)提亮 14 级。用 term_bg 而不是 p.panel:
// p.panel 是窗口外壳色,比终端还暗(Steel 外壳 22,24,30 vs 终端
// 26,28,36),拿它当浮层底会让弹窗陷进背景里,方向正好是反的。
// 浮层底从**窗口外壳** `shell_bg` 提亮 14 级,不是从终端底色
// (2026-07-31 修正)。
//
// 之前用的是 `term_bg`,理由写的是"浮层要比内容层亮"。方向没错,
// 起点错了:Nebula 的外壳 (34,38,48) 比终端 (15,17,26) 亮 20,于是
// 从终端起跳的浮层落在 (29,31,40),比它实际漂浮的那张外壳还暗 5 级
// ——弹窗陷进背景里,正是它想避免的那件事。原型的 (48,53,65) 就是
// 外壳 +14。
//
// 当初避开 `p.panel` 是对的(那是 chrome 面板色,Steel 下比终端暗),
// 但 `shell_bg` 是另一个字段,四个深色主题下都能让浮层高出终端 9–33
// 级,方向对得上。
let lift = |c: u8| c.saturating_add(14);
let shell = p.shell_bg;
if p.is_light {
Skin {
// 明度阶梯(2026-07-29 裁定):相邻层必须差 8–14 级,
// 低于 8 级肉眼分不开,界面就只能靠
// 画边框救场——边框一多画面就碎,这才是"脏"的观感来源。
// 浅色的三层不是单调递增的明度阶梯,而是**凹槽**(2026-07-31
// 裁定,推翻 07-29 的"浮层比内容更暗"):浮层底与输入面同为
// 纯白,中间的分组卡片压暗一档挖出凹槽,输入框从凹槽里浮回
// 表面。
//
// 浅色下浮层的方向是**比内容更暗**(参照产品的浅色命令面板底
// 是 #F7F7F7,盖住的终端区是纯白;Fluent 同构:base #F3F3F3
// → layer #FFFFFF)。此前 panel 250 与 card 255 只差 5 级,等于
// 没有层级。现在 241 → 254 差 13,且比纯白终端暗 14。
// 07-29 那版把方向做反了——panel 241 / card 254 / input 240,
// 卡片成了最亮的一层,输入框反而陷进卡片里。视觉上读成"白盒
// 子套白盒子",输入框只能靠一条淡描边撑出来,而描边一多画面
// 就碎。阶梯本身没错,错在浅色下**最亮的那层应该是可输入的
// 表面**,不是承载它的容器。
//
// 这不推翻"白色打底"的裁定:slate-100 依然是近白冷调,不是回到
// 主题族的银/岩灰底;变的是它与卡片之间终于有了可见的台阶。
panel: Rgba::new(241, 245, 249, 252), // slate-100
// A white inset on light panels: the hairline carries the
// "sunken" read, the fill stays cleaner than a gray wash.
input: Rgba::new(255, 255, 255, 240),
card: Rgba::new(255, 255, 255, 244),
// 压暗用 slate 叠加而不是降低白的明度:纯黑叠白底得到零色相
// 的死灰,掺蓝的冷调灰才不显脏(同 hairline/surface 的理由)。
// alpha 取 255:原型的 panel 是不透明的,而且 `push_stroke`
// 靠上层填充盖住中心才形成描边环——底板只要留一丝透明,那圈
// 描边就会从整块面板里渗出来。
panel: Rgba::new(255, 255, 255, 255),
input: Rgba::new(255, 255, 255, 255),
card: Rgba::new(100, 116, 139, 11), // slate-500 @ 4.5%
// modal 专用(popover 不画遮罩,见设计文档裁定三)。冷调压暗
// 而不是白雾:白雾 75% 把背景提亮到和白弹窗同明度,弹窗反而
// 浮不起来,且糊掉全部上下文。20% 压暗下背景仍然可读。
veil: Rgba::new(15, 23, 42, 51), // slate-900 @ 20%
veil: Rgba::new(15, 23, 42, 56), // slate-900 @ 22%
ink: Rgb::new(51, 65, 85), // slate-700
ink_dim: Rgb::new(100, 116, 139), // slate-500
ink_strong: Rgb::new(15, 23, 42), // slate-900
@@ -461,9 +474,18 @@ impl NebulaTheme {
ink_on_accent: Rgb::new(248, 250, 252),
icon: Rgb::new(71, 85, 105), // slate-600
icon_hover: Rgb::new(15, 23, 42), // slate-900
accent: a,
accent_soft: Rgba::new(a.r, a.g, a.b, 34),
danger: Rgba::new(196, 74, 88, 255),
// 浅色下的强调色是**中性深灰**,不跟主题走(2026-07-31 用户裁定,
// 对齐原型)。浅色底本身亮度就高,饱和的主题色压上去会抢过内容
// ——焦点框、主按钮、勾选框同时用一个艳色,画面立刻吵起来。原型
// 里这三处全是同一个深灰,"柔和"就是从这来的。
//
// 深色主题不受影响:那边底暗,主题 accent 是唯一的亮点,反而
// 是画面的锚。
accent: Rgb::new(73, 80, 87),
accent_soft: Rgba::new(73, 80, 87, 36), // 14%
// 浅色的红要比深色那份更沉:同一个红压在白底上,明度对比大得多,
// 照搬深色值会艳到从整张卡片里跳出来。
danger: Rgba::new(178, 58, 72, 255),
// 2026-07-29 用户裁定:中性灰在屏幕上永远显脏。此前这几个
// 叠加色是纯黑 rgba(0,0,0,.05~.19),而纯黑叠在白底上只能得
// 到**零色相**的死灰——这就是"不干净"的物理来源。现在改叠
@@ -488,12 +510,16 @@ impl NebulaTheme {
// (深色命令面板比背景亮,浅色命令面板比纯白终端暗)。
//
// 阶梯:终端 26 → panel 40 → card 54,每层差 14。
panel: Rgba::new(lift(t.r), lift(t.g), lift(t.b), 250),
//
// alpha 取 255 而不是 250:原型的 panel 是不透明的,而且
// `push_stroke` 靠上层填充盖住中心才形成描边环——底板只要
// 留一丝透明,那圈描边就会从整块面板里渗出来。
panel: Rgba::new(lift(shell.r), lift(shell.g), lift(shell.b), 255),
// Derive the inset/input surface from the terminal background
// so it stays in-family on every dark theme (blue-black on
// Nebula, pure gray on Coal) instead of one fixed navy.
input: Rgba::new(t.r, t.g, t.b, 220),
card: Rgba::new(255, 255, 255, 16),
input: Rgba::new(t.r, t.g, t.b, 219), // 86%
card: Rgba::new(255, 255, 255, 11), // 4.5%
// modal 专用(popover 不画遮罩)。原值 alpha 150(59%)在本就
// 很暗的底上接近全黑,把上下文糊没了;36% 足够传达"被阻断",
// 背景仍可读。冷调 slate-950 而不是纯黑,与整套灰同色温。
+9 -1
View File
@@ -48,8 +48,16 @@ use crate::config::window::{Identity, WindowConfig};
use crate::display::SizeInfo;
/// Window icon for `_NET_WM_ICON` property.
///
/// Three levels up, not two: this file sits in `src/display/`, so `../../`
/// would land on `nebula_app/extra` — which is a stray 8-byte regular file
/// holding the text `../extra` (a symlink that got committed as content under
/// `core.symlinks=false`, mode 100644). It is not a real symlink on any
/// platform, so the two-level path can never resolve. Windows never noticed
/// because this const is gated out there. Sibling `display/mod.rs` already uses
/// the correct `../../../`.
#[cfg(all(feature = "x11", not(any(target_os = "macos", windows))))]
const WINDOW_ICON: &[u8] = include_bytes!("../../extra/logo/nebula.png");
const WINDOW_ICON: &[u8] = include_bytes!("../../../extra/logo/nebula.png");
/// This should match the definition of IDI_ICON from `nebula.rc`.
#[cfg(windows)]
+12
View File
@@ -692,6 +692,17 @@ impl ApplicationHandler<Event> for Processor {
window_context.display.window.request_redraw();
}
},
// 连接阶段推进:只更新拥有该 pane 的窗口。事件自带 tab_id,
// 没有 tab_id 的(不该出现)直接丢弃而不是误绘到别的 pane 上。
(EventType::SshConnect(stage), Some(window_id)) => {
if let (Some(window_context), Some(pane)) =
(self.windows.get_mut(window_id), tab_id)
{
window_context.ssh_connect_stage(pane, stage);
window_context.dirty = true;
window_context.display.window.request_redraw();
}
},
// Toast click: surface the window (and pane) the toast came from.
// Must be consumed here — the generic Some(window_id) forwarding
// below would park it in a window's event queue instead.
@@ -2472,6 +2483,7 @@ impl input::Processor<EventProxy, ActionContext<'_, Notifier, EventProxy>> {
| EventType::SshDeleteUndoExpired
| EventType::QuickTerminalHotkeyChanged { .. }
| EventType::SshTestDone { .. }
| EventType::SshConnect(_)
| EventType::SftpUpdated => (),
// AI hook events are handled at the Processor level (they may
// target any window's pane); FocusWindow, fix results and
+3
View File
@@ -93,6 +93,9 @@ pub enum EventType {
message: String,
elapsed_ms: u64,
},
/// 直连 SSH 会话的连接阶段推进(后台 runtime → 窗口线程)。事件自带
/// `tab_id`,接收侧据此定位 pane,无需在负载里重复 pane id。
SshConnect(crate::ssh_session::SshStage),
SftpUpdated,
AiHook(crate::ai_hook::AiHookEvent),
/// 助手修复请求完成(后台线程 → 主循环)。`fix: None` = 沉默(失败、
+5 -5
View File
@@ -15,13 +15,13 @@ pub struct StoredFont {
pub created: bool,
}
/// 导入字体的存放目录。**纯拼路径,不创建**——写入侧(`store_font`)自己
/// `create_dir_all` 并把失败报给用户,读取侧(`imported_font_files`)容忍
/// 目录不存在。这里若顺手创建,既让读路径每次多一次无谓 IO,也会让写侧
/// 那条错误提示永不触发。
#[cfg(windows)]
pub fn imported_font_directory() -> PathBuf {
let base = std::env::var_os("APPDATA")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
base.join("Nebula").join("fonts")
crate::platform::dirs::data_dir().join("fonts")
}
#[cfg(windows)]
+29
View File
@@ -363,6 +363,35 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
// Right-side drawer (directory tree / git): header tabs switch views,
// directory rows expand/collapse. Sits under the modal layers, so
// only when no modal owns the pointer.
// SSH 连接卡片:遮罩盖住整个 pane,所以卡片在场时 pane 内的点击
// 全归它——一个字节也不许漏进终端去起拖选(侧栏拖拽残影那个 bug
// 就是"未命中的按压漏进选区")。落在卡片外的空白同样被吞掉。
if self.ctx.display().ssh_connect_active()
&& !self.ctx.display().settings_open()
&& self.ctx.display().nebula_confirm.is_none()
{
let x = self.ctx.mouse().x as f32;
let y = self.ctx.mouse().y as f32;
if self.ctx.display().ssh_connect_covers(x, y) {
if button == MouseButton::Left {
use crate::display::ssh_connect::SshConnectHit;
match self.ctx.display().ssh_connect_hit(x, y) {
SshConnectHit::Logs => {
self.ctx.display().ssh_connect_toggle_logs();
self.ctx.mark_dirty();
},
// 取消与关闭是同一个动作:这个 pane 除了这条连接
// 没有别的内容,留着它没有意义。
SshConnectHit::Cancel | SshConnectHit::Close => {
self.ctx.nebula_tab(crate::event::TabRequest::Close);
},
SshConnectHit::None => {},
}
}
return;
}
}
if button == MouseButton::Left
&& self.ctx.display().nebula_sftp_panel.is_some()
&& !self.ctx.display().settings_open()
+17
View File
@@ -365,6 +365,23 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
self.ctx.display().ssh_editor_next_field(mods.shift_key())
},
Key::Named(NamedKey::Backspace) => self.ctx.display().ssh_editor_backspace(),
Key::Named(NamedKey::Delete) => {
self.ctx.display().ssh_editor_delete_forward()
},
// 光标导航。按住 Shift 是扩选——这套组合键在 Windows 上
// 属于肌肉记忆,缺一个都会被读成"这个输入框是坏的"。
Key::Named(NamedKey::ArrowLeft) => {
self.ctx.display().ssh_editor_move_caret(false, mods.shift_key())
},
Key::Named(NamedKey::ArrowRight) => {
self.ctx.display().ssh_editor_move_caret(true, mods.shift_key())
},
Key::Named(NamedKey::Home) => {
self.ctx.display().ssh_editor_jump_caret(false, mods.shift_key())
},
Key::Named(NamedKey::End) => {
self.ctx.display().ssh_editor_jump_caret(true, mods.shift_key())
},
Key::Character(c)
if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("a") =>
{
+27
View File
@@ -73,6 +73,13 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
// retained editor snapshot is released.
if self.ctx.display().nebula_ssh_editor.is_some() {
self.ctx.display().set_ssh_delete_undo_hover(false);
// 拖选进行中:光标跟着指针走。划出输入框也继续跟——拖拽的语义是
// "从按下那个字符拉到现在这个字符",中途手抖出框不该中断选择。
if self.ctx.display().ssh_editor_dragging()
&& self.ctx.display().ssh_editor_drag_to(x as f32)
{
self.ctx.mark_dirty();
}
let hit = if self.ctx.display().ssh_editor_active() {
self.ctx.display().ssh_editor_hit(x as f32, y as f32)
} else {
@@ -81,6 +88,8 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
self.ctx.display().set_ssh_editor_hover(hit);
let cursor = match hit {
crate::display::SshEditorHit::Destination
| crate::display::SshEditorHit::Port
| crate::display::SshEditorHit::Label
| crate::display::SshEditorHit::Password => CursorIcon::Text,
crate::display::SshEditorHit::SaveToggleBox
| crate::display::SshEditorHit::SaveToggleLabel
@@ -336,6 +345,21 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
return;
}
// SSH 连接卡片:遮罩盖住整个 pane,指针不能穿到下面的终端去变成
// I 形光标——那会读作"这里可以选文字",而此刻并不能。
if self.ctx.display().ssh_connect_active() {
let hit = self.ctx.display().ssh_connect_hit(x as f32, y as f32);
if self.ctx.display().ssh_connect_set_hover(hit) {
self.ctx.mark_dirty();
}
if self.ctx.display().ssh_connect_covers(x as f32, y as f32) {
let cursor =
if hit.is_none() { CursorIcon::Default } else { CursorIcon::Pointer };
self.ctx.window().set_mouse_cursor(cursor);
return;
}
}
// Drawer hover: rows / header tabs / action buttons light up, and the
// pointer picks the matching cursor. The drawer overlays the grid, so
// while the pointer is on it nothing below may react (no link hover,
@@ -717,6 +741,9 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
// even while a TUI has grabbed the mouse. A plain click (never dragged)
// returns `None` here and falls through to normal release handling.
if button == MouseButton::Left {
// 松开就结束输入框的拖选。放在最前面:后面任何一条 early return
// 都会把这个拖拽状态留下,于是下一次移动鼠标还在选字。
self.ctx.display().ssh_editor_end_drag();
if self.ctx.display().finish_settings_opacity_drag() {
self.ctx.mark_dirty();
return;
+1
View File
@@ -57,6 +57,7 @@ mod nebula_history;
mod notify;
#[cfg(windows)]
mod panic;
mod platform;
#[cfg(unix)]
mod polling;
mod process_tree;
+1 -7
View File
@@ -124,13 +124,7 @@ impl NebulaHistory {
/// Path to `nebula_history.jsonl` under the user data dir, creating the
/// directory if needed.
fn history_path() -> PathBuf {
let base = std::env::var_os("APPDATA")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
.unwrap_or_else(std::env::temp_dir);
let dir = base.join("Nebula");
let _ = std::fs::create_dir_all(&dir);
dir.join("nebula_history.jsonl")
crate::platform::dirs::data_dir().join("nebula_history.jsonl")
}
#[cfg(test)]
+4 -5
View File
@@ -322,12 +322,11 @@ mod win {
ensure_icon_file()
}
/// Write the embedded logo to `%APPDATA%\Nebula\toast_icon.png` (idempotent;
/// refreshed when the embedded bytes change size, e.g. after a logo swap).
/// Write the embedded logo to `toast_icon.png` in the user data dir
/// (idempotent; refreshed when the embedded bytes change size, e.g. after a
/// logo swap).
fn ensure_icon_file() -> Option<PathBuf> {
let dir = PathBuf::from(std::env::var_os("APPDATA")?).join("Nebula");
std::fs::create_dir_all(&dir).ok()?;
let path = dir.join("toast_icon.png");
let path = crate::platform::dirs::data_dir().join("toast_icon.png");
let stale = std::fs::metadata(&path)
.map(|meta| meta.len() != ICON_PNG.len() as u64)
.unwrap_or(true);
+173
View File
@@ -0,0 +1,173 @@
//! 用户目录解析(005 §5 L1)。
//!
//! 这里是 Nebula 全部落盘路径的唯一真相。改造前同一段
//! 「`APPDATA` → `HOME/.config` → `Nebula`」的逻辑在仓库里被复制了 5 份
//! (`display/mod.rs`、`nebula_history.rs`、`notify.rs`、`font_install.rs`、
//! `nebula_terminal/tty/windows`),其中 `notify.rs` 只读 `APPDATA`——在
//! Unix 上直接返回 `None`,通知图标永远写不出来。
//!
//! ## 各平台落点
//!
//! | 平台 | 目录 |
//! | :-- | :-- |
//! | Windows | `%APPDATA%\Nebula` |
//! | macOS | `~/Library/Application Support/Nebula` |
//! | Linux | `$XDG_CONFIG_HOME/nebula`,未设则 `~/.config/nebula` |
//!
//! **Windows 路径与改造前逐字节相同**——存量用户零迁移,这是硬约束。
//!
//! Linux 改用 XDG 规范目录、且目录名转小写(`nebula` 而非 `Nebula`),
//! 是因为 Linux 目标从未编译成功过(见 004 的探测结论:交叉编译死在
//! fontconfig/ring 的 native build script),**绝无存量用户**,此刻是唯一
//! 能免费修正的时机。macOS 同理,从 `~/.config` 改到系统规定的
//! Application Support。
//!
//! ## 为什么 config 与 data 暂不分家
//!
//! XDG 分 `XDG_CONFIG_HOME` 与 `XDG_DATA_HOME`,但 Windows 侧现在把设置、
//! 历史、会话、字体全放在同一个 `%APPDATA%\Nebula` 下。此刻拆开就要给
//! Windows 做数据迁移,风险远大于收益。先统一成一个正确的入口,拆分留到
//! 真有需求时(那时只需改这个文件)。
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// 覆盖数据目录。便携模式与自动化测试用——`ui_probe` 之类的工具跑起来
/// 会写设置文件,指到临时目录就不会污染真实配置。
const DIR_OVERRIDE_ENV: &str = "NEBULA_CONFIG_DIR";
/// 用户主目录。Windows `USERPROFILE`,Unix `HOME`。
///
/// 存在的理由是仓库里有 6 处只读 `USERPROFILE` 而没有 `HOME` 回落
/// (`ai_hook.rs` 找 `.claude` / `.codex` / `.pi` 的四处最典型),那些路径
/// 在 Linux/macOS 上恒为 `None`,对应功能静默消失。
pub fn home_dir() -> Option<PathBuf> {
#[cfg(windows)]
let raw = std::env::var_os("USERPROFILE");
#[cfg(unix)]
let raw = std::env::var_os("HOME");
raw.map(PathBuf::from).filter(|path| !path.as_os_str().is_empty())
}
/// Nebula 的配置与数据目录,必要时创建。
///
/// 结果缓存在 `OnceLock`:改造前 26 个调用点每次都要读环境变量 +
/// `create_dir_all` 一次。缓存同时保证进程内路径恒定——半途改环境变量
/// 不会让一部分文件写到旧目录、另一部分写到新目录。
pub fn data_dir() -> &'static Path {
static DIR: OnceLock<PathBuf> = OnceLock::new();
DIR.get_or_init(|| {
let dir = resolve_data_dir();
let _ = std::fs::create_dir_all(&dir);
dir
})
}
fn resolve_data_dir() -> PathBuf {
if let Some(raw) = std::env::var_os(DIR_OVERRIDE_ENV) {
let path = PathBuf::from(raw);
if !path.as_os_str().is_empty() {
return path;
}
}
#[cfg(windows)]
{
// 与改造前逐字节一致:`%APPDATA%\Nebula`。
std::env::var_os("APPDATA")
.map(PathBuf::from)
.or_else(home_dir)
.unwrap_or_else(std::env::temp_dir)
.join("Nebula")
}
#[cfg(target_os = "macos")]
{
home_dir()
.map(|home| home.join("Library").join("Application Support"))
.unwrap_or_else(std::env::temp_dir)
.join("Nebula")
}
#[cfg(all(unix, not(target_os = "macos")))]
{
// XDG 规定:相对路径的 XDG_CONFIG_HOME 必须当作未设置。
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.or_else(|| home_dir().map(|home| home.join(".config")))
.unwrap_or_else(std::env::temp_dir)
.join("nebula")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn home_dir_rejects_empty_env() {
// 空字符串环境变量在实践中出现过(某些登录管理器),当作未设置。
// 这里只验证过滤器本身——直接改进程环境会污染其他并发测试。
let empty = PathBuf::from("");
assert!(Some(empty).filter(|p: &PathBuf| !p.as_os_str().is_empty()).is_none());
}
#[test]
fn data_dir_is_absolute_and_named() {
let dir = data_dir();
assert!(dir.is_absolute(), "数据目录必须是绝对路径:{dir:?}");
// 覆盖环境变量下叶子名可以是任意的,只在默认路径上校验命名。
if std::env::var_os(DIR_OVERRIDE_ENV).is_none() {
let leaf = dir.file_name().and_then(|name| name.to_str()).unwrap_or_default();
assert!(
leaf.eq_ignore_ascii_case("nebula"),
"默认数据目录应以 nebula 结尾,实际 {leaf:?}"
);
}
}
/// Windows 侧路径是硬约束:必须逐字节等于改造前的 `%APPDATA%\Nebula`,
/// 否则存量用户的设置、历史、会话快照集体失联。
#[cfg(windows)]
#[test]
fn windows_path_matches_pre_refactor_layout() {
if std::env::var_os(DIR_OVERRIDE_ENV).is_some() {
return;
}
let Some(appdata) = std::env::var_os("APPDATA") else { return };
assert_eq!(data_dir(), PathBuf::from(appdata).join("Nebula"));
}
/// Linux 侧遵守 XDG:相对路径的 `XDG_CONFIG_HOME` 按规范视为未设置。
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn linux_path_follows_xdg() {
if std::env::var_os(DIR_OVERRIDE_ENV).is_some() {
return;
}
let dir = data_dir();
assert_eq!(dir.file_name().and_then(|name| name.to_str()), Some("nebula"));
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from) {
if xdg.is_absolute() {
assert_eq!(dir, xdg.join("nebula"));
return;
}
}
if let Some(home) = home_dir() {
assert_eq!(dir, home.join(".config").join("nebula"));
}
}
/// macOS 有自己的规矩:Application Support,不是 `~/.config`。
#[cfg(target_os = "macos")]
#[test]
fn macos_path_uses_application_support() {
if std::env::var_os(DIR_OVERRIDE_ENV).is_some() {
return;
}
let Some(home) = home_dir() else { return };
assert_eq!(data_dir(), home.join("Library").join("Application Support").join("Nebula"));
}
}
+39
View File
@@ -0,0 +1,39 @@
//! 平台闸门层(docs/specs/005-cross-platform-foundation.md)。
//!
//! 目的是让业务代码不再自己写 `#[cfg(windows)]`。三平台的差异全部收在
//! 这里的子模块中,对外只暴露平台无关的签名。
//!
//! 设计裁定(005 §4.1):**不用 trait 对象**。Tabby 的 `PlatformService`
//! 是 OOP + DI 的产物,Rust 里照搬一个 20 方法的 `dyn` trait 只会换来强制
//! 动态分发和造假实现的测试负担——平台实现本就是编译期确定的。这里改用
//! 「按能力切模块 + 模块内 `#[cfg]` 分派」,保留 Tabby 真正值钱的两点:
//! 单一入口,以及能力探测(让 UI 隐藏入口,而不是让功能在别的平台报错)。
pub mod dirs;
/// 运行平台。与「配置平台」分离(005 §4.3):前者是我真正跑在哪,后者是
/// 该套用哪份键位/修饰键默认值——Mac 用户在 Windows 上可以选 ⌘ 语义。
/// 配置平台待 L5 键位分文件时落地。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Platform {
Windows,
MacOS,
Linux,
}
impl Platform {
pub const fn current() -> Self {
#[cfg(windows)]
{
Self::Windows
}
#[cfg(target_os = "macos")]
{
Self::MacOS
}
#[cfg(all(unix, not(target_os = "macos")))]
{
Self::Linux
}
}
}
+280 -260
View File
@@ -1,260 +1,280 @@
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Deserializer, Serialize};
use windows_sys::Win32::Storage::FileSystem::{
MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW,
};
const PROFILE_VERSION: u32 = 1;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SshAuthMode {
#[default]
Auto,
Password,
PublicKey,
KeyboardInteractive,
}
impl<'de> Deserialize<'de> for SshAuthMode {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Ok(match value.as_str() {
"password" => Self::Password,
"public_key" => Self::PublicKey,
// v0.5 移除了 Agent;旧配置回退到 Auto,避免升级后配置失效。
"agent" => Self::Auto,
"keyboard_interactive" => Self::KeyboardInteractive,
_ => Self::Auto,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SshProfileAuth {
pub destination: String,
#[serde(default)]
pub auth: SshAuthMode,
#[serde(default)]
pub private_keys: Vec<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SshProfiles {
#[serde(default = "profile_version")]
version: u32,
#[serde(default)]
profiles: Vec<SshProfileAuth>,
}
impl Default for SshProfiles {
fn default() -> Self {
Self { version: PROFILE_VERSION, profiles: Vec::new() }
}
}
impl SshProfiles {
pub fn load(path: &Path) -> io::Result<Self> {
let data = match std::fs::read(path) {
Ok(data) => data,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
Err(err) => return Err(err),
};
let mut profiles: Self = serde_json::from_slice(&data)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
for profile in &mut profiles.profiles {
deduplicate_key_paths(&mut profile.private_keys);
}
profiles.version = PROFILE_VERSION;
Ok(profiles)
}
pub fn save(&self, path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let data = serde_json::to_vec_pretty(self).map_err(io::Error::other)?;
let temporary = path.with_extension(format!("nebula-tmp-{}", std::process::id()));
std::fs::write(&temporary, data)?;
let result = replace_file(&temporary, path);
if result.is_err() {
let _ = std::fs::remove_file(&temporary);
}
result
}
pub fn for_destination(&self, destination: &str) -> SshProfileAuth {
self.profiles
.iter()
.find(|profile| profile.destination == destination)
.cloned()
.unwrap_or_else(|| SshProfileAuth {
destination: destination.to_owned(),
auth: SshAuthMode::Auto,
private_keys: Vec::new(),
})
}
pub fn upsert(&mut self, mut profile: SshProfileAuth) {
deduplicate_key_paths(&mut profile.private_keys);
if let Some(existing) =
self.profiles.iter_mut().find(|existing| existing.destination == profile.destination)
{
*existing = profile;
} else {
self.profiles.push(profile);
}
}
pub fn remove(&mut self, destination: &str) {
self.profiles.retain(|profile| profile.destination != destination);
}
pub fn rename(&mut self, old: &str, new: &str) {
let Some(mut profile) =
self.profiles.iter().find(|profile| profile.destination == old).cloned()
else {
return;
};
self.remove(old);
profile.destination = new.to_owned();
self.upsert(profile);
}
}
fn profile_version() -> u32 {
PROFILE_VERSION
}
fn deduplicate_key_paths(paths: &mut Vec<PathBuf>) {
let mut seen = Vec::<String>::new();
paths.retain(|path| {
let normalized = path.to_string_lossy().to_lowercase();
if seen.contains(&normalized) {
false
} else {
seen.push(normalized);
true
}
});
}
fn replace_file(source: &Path, destination: &Path) -> io::Result<()> {
let source = wide_path(source);
let destination = wide_path(destination);
let ok = unsafe {
MoveFileExW(
source.as_ptr(),
destination.as_ptr(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
)
};
if ok == 0 { Err(io::Error::last_os_error()) } else { Ok(()) }
}
fn wide_path(path: &Path) -> Vec<u16> {
use std::os::windows::ffi::OsStrExt;
path.as_os_str().encode_wide().chain(std::iter::once(0)).collect()
}
#[cfg(test)]
mod tests {
use super::{SshAuthMode, SshProfileAuth, SshProfiles};
use std::path::PathBuf;
#[test]
fn missing_profile_defaults_to_auto_without_keys() {
let profiles = SshProfiles::default();
assert_eq!(profiles.for_destination("dev@example.com").auth, SshAuthMode::Auto);
assert!(profiles.for_destination("dev@example.com").private_keys.is_empty());
}
#[test]
fn profile_round_trip_preserves_mode_and_key_order() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ssh_profiles.json");
let mut profiles = SshProfiles::default();
profiles.upsert(SshProfileAuth {
destination: "dev@example.com".to_owned(),
auth: SshAuthMode::PublicKey,
private_keys: vec![PathBuf::from(r"C:\Keys\first"), PathBuf::from(r"C:\Keys\second")],
});
profiles.save(&path).unwrap();
let loaded = SshProfiles::load(&path).unwrap();
let profile = loaded.for_destination("dev@example.com");
assert_eq!(profile.auth, SshAuthMode::PublicKey);
assert_eq!(
profile.private_keys,
vec![PathBuf::from(r"C:\Keys\first"), PathBuf::from(r"C:\Keys\second")]
);
}
#[test]
fn duplicate_windows_key_paths_are_removed_without_reordering() {
let mut profiles = SshProfiles::default();
profiles.upsert(SshProfileAuth {
destination: "dev@example.com".to_owned(),
auth: SshAuthMode::Auto,
private_keys: vec![
PathBuf::from(r"C:\Keys\id_ed25519"),
PathBuf::from(r"c:\keys\ID_ED25519"),
PathBuf::from(r"C:\Keys\id_rsa"),
],
});
assert_eq!(
profiles.for_destination("dev@example.com").private_keys,
vec![PathBuf::from(r"C:\Keys\id_ed25519"), PathBuf::from(r"C:\Keys\id_rsa")]
);
}
#[test]
fn renaming_profile_moves_auth_metadata() {
let mut profiles = SshProfiles::default();
profiles.upsert(SshProfileAuth {
destination: "old@example.com".to_owned(),
auth: SshAuthMode::PublicKey,
private_keys: vec![PathBuf::from(r"C:\Keys\id_ed25519")],
});
profiles.rename("old@example.com", "new@example.com");
assert_eq!(profiles.for_destination("old@example.com").auth, SshAuthMode::Auto);
assert_eq!(profiles.for_destination("new@example.com").auth, SshAuthMode::PublicKey);
}
#[test]
fn legacy_agent_mode_migrates_to_auto() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ssh_profiles.json");
std::fs::write(
&path,
r#"{"version":1,"profiles":[{"destination":"dev@example.com","auth":"agent","private_keys":[]}]}"#,
)
.unwrap();
let loaded = SshProfiles::load(&path).unwrap();
assert_eq!(loaded.for_destination("dev@example.com").auth, SshAuthMode::Auto);
}
#[test]
fn unknown_auth_mode_falls_back_to_auto() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ssh_profiles.json");
std::fs::write(
&path,
r#"{"version":1,"profiles":[{"destination":"dev@example.com","auth":"future-mode","private_keys":[]}]}"#,
)
.unwrap();
let loaded = SshProfiles::load(&path).unwrap();
assert_eq!(loaded.for_destination("dev@example.com").auth, SshAuthMode::Auto);
}
}
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Deserializer, Serialize};
use windows_sys::Win32::Storage::FileSystem::{
MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW,
};
const PROFILE_VERSION: u32 = 1;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SshAuthMode {
#[default]
Auto,
Password,
PublicKey,
KeyboardInteractive,
}
impl<'de> Deserialize<'de> for SshAuthMode {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Ok(match value.as_str() {
"password" => Self::Password,
"public_key" => Self::PublicKey,
// v0.5 移除了 Agent;旧配置回退到 Auto,避免升级后配置失效。
"agent" => Self::Auto,
"keyboard_interactive" => Self::KeyboardInteractive,
_ => Self::Auto,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SshProfileAuth {
pub destination: String,
#[serde(default)]
pub auth: SshAuthMode,
#[serde(default)]
pub private_keys: Vec<PathBuf>,
/// 列表里显示的名字。`None` / 空串都表示回落到地址本身,所以旧配置文件
/// 不需要迁移就能读——`serde(default)` 让缺字段等价于"没起名字"。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SshProfiles {
#[serde(default = "profile_version")]
version: u32,
#[serde(default)]
profiles: Vec<SshProfileAuth>,
}
impl Default for SshProfiles {
fn default() -> Self {
Self { version: PROFILE_VERSION, profiles: Vec::new() }
}
}
impl SshProfiles {
pub fn load(path: &Path) -> io::Result<Self> {
let data = match std::fs::read(path) {
Ok(data) => data,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
Err(err) => return Err(err),
};
let mut profiles: Self = serde_json::from_slice(&data)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
for profile in &mut profiles.profiles {
deduplicate_key_paths(&mut profile.private_keys);
}
profiles.version = PROFILE_VERSION;
Ok(profiles)
}
pub fn save(&self, path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let data = serde_json::to_vec_pretty(self).map_err(io::Error::other)?;
let temporary = path.with_extension(format!("nebula-tmp-{}", std::process::id()));
std::fs::write(&temporary, data)?;
let result = replace_file(&temporary, path);
if result.is_err() {
let _ = std::fs::remove_file(&temporary);
}
result
}
pub fn for_destination(&self, destination: &str) -> SshProfileAuth {
self.profiles
.iter()
.find(|profile| profile.destination == destination)
.cloned()
.unwrap_or_else(|| SshProfileAuth {
destination: destination.to_owned(),
auth: SshAuthMode::Auto,
private_keys: Vec::new(),
label: None,
})
}
/// 地址 → 显示名。侧栏每帧都要画主机行,不能每帧读一次 JSON,所以调用方
/// 把结果缓存起来,只在编辑器保存后刷新。
pub fn labels(&self) -> std::collections::HashMap<String, String> {
self.profiles
.iter()
.filter_map(|profile| {
let label = profile.label.as_deref()?.trim();
(!label.is_empty()).then(|| (profile.destination.clone(), label.to_owned()))
})
.collect()
}
pub fn upsert(&mut self, mut profile: SshProfileAuth) {
deduplicate_key_paths(&mut profile.private_keys);
if let Some(existing) =
self.profiles.iter_mut().find(|existing| existing.destination == profile.destination)
{
*existing = profile;
} else {
self.profiles.push(profile);
}
}
pub fn remove(&mut self, destination: &str) {
self.profiles.retain(|profile| profile.destination != destination);
}
pub fn rename(&mut self, old: &str, new: &str) {
let Some(mut profile) =
self.profiles.iter().find(|profile| profile.destination == old).cloned()
else {
return;
};
self.remove(old);
profile.destination = new.to_owned();
self.upsert(profile);
}
}
fn profile_version() -> u32 {
PROFILE_VERSION
}
fn deduplicate_key_paths(paths: &mut Vec<PathBuf>) {
let mut seen = Vec::<String>::new();
paths.retain(|path| {
let normalized = path.to_string_lossy().to_lowercase();
if seen.contains(&normalized) {
false
} else {
seen.push(normalized);
true
}
});
}
fn replace_file(source: &Path, destination: &Path) -> io::Result<()> {
let source = wide_path(source);
let destination = wide_path(destination);
let ok = unsafe {
MoveFileExW(
source.as_ptr(),
destination.as_ptr(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
)
};
if ok == 0 { Err(io::Error::last_os_error()) } else { Ok(()) }
}
fn wide_path(path: &Path) -> Vec<u16> {
use std::os::windows::ffi::OsStrExt;
path.as_os_str().encode_wide().chain(std::iter::once(0)).collect()
}
#[cfg(test)]
mod tests {
use super::{SshAuthMode, SshProfileAuth, SshProfiles};
use std::path::PathBuf;
#[test]
fn missing_profile_defaults_to_auto_without_keys() {
let profiles = SshProfiles::default();
assert_eq!(profiles.for_destination("dev@example.com").auth, SshAuthMode::Auto);
assert!(profiles.for_destination("dev@example.com").private_keys.is_empty());
}
#[test]
fn profile_round_trip_preserves_mode_and_key_order() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ssh_profiles.json");
let mut profiles = SshProfiles::default();
profiles.upsert(SshProfileAuth {
destination: "dev@example.com".to_owned(),
auth: SshAuthMode::PublicKey,
private_keys: vec![PathBuf::from(r"C:\Keys\first"), PathBuf::from(r"C:\Keys\second")],
label: None,
});
profiles.save(&path).unwrap();
let loaded = SshProfiles::load(&path).unwrap();
let profile = loaded.for_destination("dev@example.com");
assert_eq!(profile.auth, SshAuthMode::PublicKey);
assert_eq!(
profile.private_keys,
vec![PathBuf::from(r"C:\Keys\first"), PathBuf::from(r"C:\Keys\second")]
);
}
#[test]
fn duplicate_windows_key_paths_are_removed_without_reordering() {
let mut profiles = SshProfiles::default();
profiles.upsert(SshProfileAuth {
destination: "dev@example.com".to_owned(),
auth: SshAuthMode::Auto,
private_keys: vec![
PathBuf::from(r"C:\Keys\id_ed25519"),
PathBuf::from(r"c:\keys\ID_ED25519"),
PathBuf::from(r"C:\Keys\id_rsa"),
],
label: None,
});
assert_eq!(
profiles.for_destination("dev@example.com").private_keys,
vec![PathBuf::from(r"C:\Keys\id_ed25519"), PathBuf::from(r"C:\Keys\id_rsa")]
);
}
#[test]
fn renaming_profile_moves_auth_metadata() {
let mut profiles = SshProfiles::default();
profiles.upsert(SshProfileAuth {
destination: "old@example.com".to_owned(),
auth: SshAuthMode::PublicKey,
private_keys: vec![PathBuf::from(r"C:\Keys\id_ed25519")],
label: None,
});
profiles.rename("old@example.com", "new@example.com");
assert_eq!(profiles.for_destination("old@example.com").auth, SshAuthMode::Auto);
assert_eq!(profiles.for_destination("new@example.com").auth, SshAuthMode::PublicKey);
}
#[test]
fn legacy_agent_mode_migrates_to_auto() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ssh_profiles.json");
std::fs::write(
&path,
r#"{"version":1,"profiles":[{"destination":"dev@example.com","auth":"agent","private_keys":[]}]}"#,
)
.unwrap();
let loaded = SshProfiles::load(&path).unwrap();
assert_eq!(loaded.for_destination("dev@example.com").auth, SshAuthMode::Auto);
}
#[test]
fn unknown_auth_mode_falls_back_to_auto() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ssh_profiles.json");
std::fs::write(
&path,
r#"{"version":1,"profiles":[{"destination":"dev@example.com","auth":"future-mode","private_keys":[]}]}"#,
)
.unwrap();
let loaded = SshProfiles::load(&path).unwrap();
assert_eq!(loaded.for_destination("dev@example.com").auth, SshAuthMode::Auto);
}
}
+75 -6
View File
@@ -7,6 +7,7 @@ use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::Receiver;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
@@ -27,6 +28,39 @@ type SessionError = Box<dyn std::error::Error + Send + Sync>;
type ClientSession = client::Handle<ClientHandler>;
type SharedSession = Arc<tokio::sync::Mutex<ClientSession>>;
/// 直连 SSH 会话的连接阶段。
///
/// 这些取值不是估算的进度百分比——因为我们用 russh 自己实现客户端,而不是
/// spawn `ssh.exe` 再解析 `-v` 输出,每个阶段都对应一个真实的调用点:
/// [`SshDestination::resolve`] → [`client::connect`] → [`authenticate`] →
/// `channel_open_session` + `request_pty` + `request_shell`。
///
/// 连接池命中时 [`authenticated_session`] 直接返回既有连接,`Connect` 与
/// `Authenticate` 都不会上报:复用是瞬时的,连接卡片也就不会浮出来。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SshStage {
/// 正在解析地址(含 `ssh -G` 读 `~/.ssh/config`)。
Resolve,
/// 正在建立 TCP 连接并完成协议握手/密钥交换。
Connect,
/// 正在认证。
Authenticate,
/// 正在打开 channel、申请 pty 与 shell。
OpenShell,
/// 会话已就绪:卡片让位给真实终端。
Ready,
/// 连接失败,附带面向用户的原因。
Failed(String),
}
/// 向拥有该 pane 的窗口上报连接阶段。[`EventProxy`] 自带 `tab_id`,所以
/// 后台 runtime 不需要知道 pane id 或 window id。
fn report_stage(progress: Option<&EventProxy>, stage: SshStage) {
if let Some(proxy) = progress {
proxy.send_event(crate::event::EventType::SshConnect(stage));
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum AuthMethod {
PrivateKey(PathBuf),
@@ -314,6 +348,9 @@ pub fn spawn_session(
let (sender, receiver) = EventLoopSender::standalone()?;
let profiles_path = crate::display::nebula_data_dir().join("ssh_profiles.json");
runtime()?.spawn(async move {
// 地址解析是第一个真实阶段:`ssh -G` 要读 ~/.ssh/config,慢链路上
// 这一步本身就可能耗时。
report_stage(Some(&event_proxy), SshStage::Resolve);
let raw = destination.clone();
let resolved = tokio::task::spawn_blocking(move || {
let resolved = SshDestination::resolve(&raw)?;
@@ -327,6 +364,10 @@ pub fn spawn_session(
Ok::<_, io::Error>((resolved, profiles.for_destination(&raw)))
})
.await;
// 会话是否曾经就绪。它把两种 `Err` 分开:连接建立**之前**失败要把
// 卡片留在屏幕上让用户读原因;建立**之后**断开则是普通的会话结束,
// 必须照常退出 pane,否则会留下一个连不上也关不掉的空壳。
let ready = Arc::new(AtomicBool::new(false));
let result = match resolved {
Ok(Ok((destination, profile))) => {
run_session_async(
@@ -336,17 +377,33 @@ pub fn spawn_session(
terminal.clone(),
event_proxy.clone(),
receiver,
ready.clone(),
)
.await
},
Ok(Err(err)) => Err(err.into()),
Err(err) => Err(format!("SSH 地址解析任务失败: {err}").into()),
};
if let Err(err) = result {
error!("直连 SSH 会话失败 {destination}: {err}");
render_error(&terminal, &event_proxy, &format!("SSH 连接失败: {err}"));
match result {
Err(err) if !ready.load(Ordering::Relaxed) => {
// 这里刻意**不用** `error!`:它会额外推一条红色 message bar,
// 而连接卡片已经把同一条原因连同阶段和日志一起呈现了。同一个
// 错误报两次,其中一次还被卡片的遮罩切成半残的红条。
info!("直连 SSH 会话失败 {destination}: {err}");
report_stage(Some(&event_proxy), SshStage::Failed(err.to_string()));
// 错误也写进 grid:卡片关掉之后它仍然留在回滚里。
render_error(&terminal, &event_proxy, &format!("SSH 连接失败: {err}"));
// 这里**不** exit:pane 一退出 tab 就关了,卡片和它携带的
// 失败原因会一起消失。收尾交给卡片上的「关闭」。
},
Err(err) => {
// 会话中途断开时没有卡片在场,message bar 是唯一的提示渠道。
error!("直连 SSH 会话中断 {destination}: {err}");
render_error(&terminal, &event_proxy, &format!("SSH 连接失败: {err}"));
terminal.lock().exit();
},
Ok(()) => terminal.lock().exit(),
}
terminal.lock().exit();
event_proxy.send_event(TerminalEvent::Wakeup.into());
});
Ok(sender)
@@ -359,12 +416,14 @@ async fn run_session_async(
terminal: Arc<FairMutex<Term<EventProxy>>>,
event_proxy: EventProxy,
receiver: Receiver<Msg>,
ready: Arc<AtomicBool>,
) -> Result<(), SessionError> {
if let Some(proxy_jump) = destination.proxy_jump.as_deref() {
return Err(format!("当前直连模式尚未接入跳板机 {proxy_jump}").into());
}
let session = authenticated_session(&destination, &profile).await?;
let session = authenticated_session(&destination, &profile, Some(&event_proxy)).await?;
report_stage(Some(&event_proxy), SshStage::OpenShell);
let mut channel = {
let session = session.lock().await;
session.channel_open_session().await?
@@ -383,6 +442,10 @@ async fn run_session_async(
let hook_token = remote_hook_token()?;
channel.set_env(false, "NEBULA_REMOTE_HOOK_TOKEN", hook_token.clone()).await?;
channel.request_shell(true).await?;
// Shell 已就绪:连接卡片到此让位给真实终端,持续重绘随之停止。此后再
// 出错就是会话中途断开,不该复活卡片。
ready.store(true, Ordering::Relaxed);
report_stage(Some(&event_proxy), SshStage::Ready);
let (input_tx, mut input_rx) = tokio::sync::mpsc::unbounded_channel();
tokio::task::spawn_blocking(move || {
@@ -441,11 +504,14 @@ async fn wait_for_sync(deadline: Option<std::time::Instant>) {
async fn authenticated_session(
destination: &SshDestination,
profile: &crate::ssh_profiles::SshProfileAuth,
progress: Option<&EventProxy>,
) -> Result<SharedSession, SessionError> {
let key = destination.pool_key();
if let Some(existing) = connection_pool().lock().await.get(&key).cloned() {
if !existing.lock().await.is_closed() {
info!("复用已认证 SSH 连接: {key}");
// 复用不上报 Connect/Authenticate:这条路径是瞬时的,交给
// 350ms 门槛把卡片整个吃掉,用户看到的就是直接出 prompt。
return Ok(existing);
}
connection_pool().lock().await.remove(&key);
@@ -458,8 +524,10 @@ async fn authenticated_session(
..Default::default()
});
let handler = ClientHandler { host: destination.host.clone(), port: destination.port };
report_stage(progress, SshStage::Connect);
let mut session =
client::connect(config, (destination.host.as_str(), destination.port), handler).await?;
report_stage(progress, SshStage::Authenticate);
authenticate(&mut session, destination, profile).await?;
let session = Arc::new(tokio::sync::Mutex::new(session));
@@ -579,7 +647,8 @@ pub(crate) async fn open_sftp(
return Err(format!("当前 SFTP 模式尚未接入跳板机 {proxy_jump}").into());
}
let session = authenticated_session(&destination, &profile).await?;
// SFTP 面板自己有加载态,不参与终端 pane 的连接卡片。
let session = authenticated_session(&destination, &profile, None).await?;
let channel = {
let session = session.lock().await;
session.channel_open_session().await?
+19 -3
View File
@@ -122,11 +122,11 @@ fn preferred_initial_cwd(
/// per-frame allocation is introduced.
#[inline]
fn chrome_clock_interval(
any_tab_running: bool,
spinner_running: bool,
editor_active: bool,
chrome_animating: bool,
) -> Duration {
if any_tab_running {
if spinner_running {
Duration::from_micros(16_667)
} else if editor_active || chrome_animating {
Duration::from_millis(125)
@@ -1783,6 +1783,16 @@ impl WindowContext {
self.panes.iter().position(|p| p.id == id)
}
/// 把后台 SSH runtime 的阶段上报转给 display,顺带补上这个 pane 的目标
/// 地址——事件本身只带 pane 身份,地址在 pane 上。
pub fn ssh_connect_stage(&mut self, pane: PaneId, stage: crate::ssh_session::SshStage) {
let destination = self
.pane_index(pane)
.and_then(|index| self.panes[index].ssh_destination.clone())
.unwrap_or_default();
self.display.ssh_connect_stage(pane, destination, stage);
}
/// Working directory of the focused pane (from the shell's `NEBULA|cwd|…`
/// title report) for a new tab/split to inherit. `None` if unknown.
fn focused_cwd(&self) -> Option<std::path::PathBuf> {
@@ -2079,6 +2089,9 @@ impl WindowContext {
let mut ids = Vec::new();
entry.layout.leaves(&mut ids);
for id in ids {
// 连接中的 tab 被关掉:丢弃它的卡片状态,否则 HashMap 会随
// 会话累积,而那个 pane 再也不会回来。
self.display.forget_ssh_connect(id);
if let Some(i) = self.pane_index(id) {
let pane = self.panes.remove(i);
let _ = pane.notifier.0.send(Msg::Shutdown);
@@ -2233,7 +2246,7 @@ impl WindowContext {
// the cadence class changes.
let clock_timer = TimerId::new(Topic::NebulaClock, self.display.window.id());
let interval = chrome_clock_interval(
self.display.any_tab_running(),
self.display.any_tab_running() || self.display.ssh_test_running(),
self.display.chrome_editor_active(),
self.display.chrome_animating(),
);
@@ -2303,6 +2316,9 @@ impl WindowContext {
return;
}
// 连接卡片只画在聚焦 pane 里,display 侧只有几何、没有身份。
self.display.set_focused_pane(focused);
if pane_rects.len() <= 1 {
let id = pane_rects.first().map(|(id, _)| *id).unwrap_or(focused);
if let Some(idx) = self.pane_index(id) {
+1
View File
@@ -506,6 +506,7 @@ impl WindowContext {
match self.tabs[tab_idx].layout.remove_leaf(id) {
RemoveResult::WasRoot => self.close_tab(tab_idx),
RemoveResult::Collapsed(focus) => {
self.display.forget_ssh_connect(id);
if let Some(i) = self.pane_index(id) {
let pane = self.panes.remove(i);
let _ = pane.notifier.0.send(Msg::Shutdown);
+21 -2
View File
@@ -8,7 +8,17 @@ param(
[switch] $SkipBuild,
[switch] $Force,
[string] $OutputDirectory
[string] $OutputDirectory,
# Cargo target directory to build into and package from. Defaults to the
# repo's own `target/`.
#
# Windows refuses to overwrite a running exe, so packaging while a Nebula
# instance is live out of `target/release/nebula.exe` dies with a bare
# "拒绝访问 (os error 5)" that names the linker, not the real cause. Point
# this at a separate directory to build a package without touching — let
# alone killing — the instance the user is working in.
[string] $TargetDirectory
)
$ErrorActionPreference = 'Stop'
@@ -20,8 +30,14 @@ if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
} elseif (-not [System.IO.Path]::IsPathRooted($OutputDirectory)) {
$OutputDirectory = Join-Path $repo $OutputDirectory
}
if ([string]::IsNullOrWhiteSpace($TargetDirectory)) {
$TargetDirectory = Join-Path $repo 'target'
} elseif (-not [System.IO.Path]::IsPathRooted($TargetDirectory)) {
$TargetDirectory = Join-Path $repo $TargetDirectory
}
$cargoTargetRoot = [System.IO.Path]::GetFullPath($TargetDirectory)
$outputRoot = [System.IO.Path]::GetFullPath($OutputDirectory)
$targetRoot = Join-Path $repo "target\$Configuration"
$targetRoot = Join-Path $cargoTargetRoot $Configuration
$stage = Join-Path $outputRoot ".stage-$Version-$PID"
$zipPath = Join-Path $outputRoot "NebulaTerminal-v$Version-windows-x64.zip"
$temporaryZip = Join-Path $outputRoot ".NebulaTerminal-v$Version-windows-x64-$PID.tmp.zip"
@@ -71,7 +87,9 @@ function Remove-StageSafely([string] $Path) {
if (-not $SkipBuild) {
Push-Location $repo
$previousTargetDirectory = $env:CARGO_TARGET_DIR
try {
$env:CARGO_TARGET_DIR = $cargoTargetRoot
if ($Configuration -eq 'release') {
& cargo build --workspace --release
} else {
@@ -81,6 +99,7 @@ if (-not $SkipBuild) {
throw "Cargo build failed with exit code $LASTEXITCODE"
}
} finally {
$env:CARGO_TARGET_DIR = $previousTargetDirectory
Pop-Location
}
}
+50
View File
@@ -0,0 +1,50 @@
# Isolated probe instance for the SSH connect card.
#
# One APPDATA isolates every mutable file, mux.port included -- without that
# the launch just pings the existing instance and exits, so no new window.
# USERPROFILE / CLAUDE_CONFIG_DIR / CODEX_HOME / XDG_CONFIG_HOME are isolated
# too, so the AI hook self-heal cannot rewrite the real user config.
#
# NOTE: comments must stay pure ASCII. Windows PowerShell 5.1 reads a BOM-less
# UTF-8 script as ANSI (GBK here), which corrupts non-ASCII bytes and breaks
# the parser -- same trap as .cmd files.
param([switch]$Kill)
$root = 'D:\temp_build\.probe-ssh'
if ($Kill) {
Get-Process nebula -ErrorAction SilentlyContinue |
Where-Object { $_.Path -like '*\target\debug\nebula.exe' } |
Stop-Process -Force
return
}
Remove-Item -Recurse -Force $root -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path "$root\appdata\Nebula", "$root\home" | Out-Null
# saved_hosts is what actually populates the sidebar list; pinned_hosts only
# reorders it (see merge_ssh_hosts). Writing as ASCII on purpose: PS 5.1's
# -Encoding UTF8 emits a BOM, and the BOM would end up glued to the first key
# so the settings parser would never match it.
#
# 192.0.2.1 is RFC 5737 TEST-NET-1 and unroutable: the connection sits in the
# Connect stage for a long time, which is exactly what we need to watch the
# particles. 127.0.0.1 has no sshd here, so it fails fast -- that covers the
# failure state.
$settings = @(
'saved_hosts=root@192.0.2.1,root@127.0.0.1',
'pinned_hosts=root@192.0.2.1',
'keep_session=false'
) -join [Environment]::NewLine
Set-Content -Path "$root\appdata\Nebula\nebula_settings.txt" -Value $settings -Encoding ascii
$env:APPDATA = "$root\appdata"
$env:USERPROFILE = "$root\home"
$env:CLAUDE_CONFIG_DIR = "$root\home\.claude"
$env:CODEX_HOME = "$root\home\.codex"
$env:XDG_CONFIG_HOME = "$root\home\.config"
$env:NEBULA_DEBUG_LOG = '1'
$exe = 'D:\temp_build\nebula\target\debug\nebula.exe'
$proc = Start-Process -FilePath $exe -PassThru
Write-Output ("pid=" + $proc.Id)