diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index 5fc6283..a627cc0 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -1993,6 +1993,7 @@ impl Display { auth: crate::ssh_profiles::SshAuthMode::Auto, private_keys: Vec::new(), field: SshEditorField::Destination, + focus: crate::ux::FocusIndex::default(), }); self.nebula_ssh_editor_rects = None; self.nebula_ssh_editor_open = true; @@ -2026,6 +2027,7 @@ impl Display { auth: profile.auth, private_keys: profile.private_keys, field: SshEditorField::Destination, + focus: crate::ux::FocusIndex::default(), }); self.nebula_ssh_editor_rects = None; self.nebula_ssh_editor_open = true; @@ -2139,19 +2141,31 @@ impl Display { } } - pub fn ssh_editor_next_field(&mut self) { + 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; - editor.field = match editor.field { - SshEditorField::Destination if shows_password => SshEditorField::Password, - SshEditorField::Destination => SshEditorField::Destination, - SshEditorField::Password => SshEditorField::Destination, + let count = if shows_password { 4 } else { 3 }; + 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.close_ssh_editor(), + (true, 3) | (false, 2) => 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; @@ -2162,6 +2176,7 @@ impl Display { match self.ssh_editor_hit(x, y) { SshEditorHit::Destination => { if let Some(editor) = self.nebula_ssh_editor.as_mut() { + editor.focus.set(0, 4); editor.destination_selection.clear(); editor.password_selection.clear(); editor.field = SshEditorField::Destination; @@ -2174,6 +2189,7 @@ impl Display { }, SshEditorHit::Password => { if let Some(editor) = self.nebula_ssh_editor.as_mut() { + editor.focus.set(1, 4); editor.destination_selection.clear(); editor.password_selection.clear(); editor.field = SshEditorField::Password; @@ -2211,8 +2227,26 @@ impl Display { SshEditorHit::SaveToggleBox | SshEditorHit::SaveToggleLabel => { self.ssh_editor_toggle_save(); }, - SshEditorHit::Cancel => self.close_ssh_editor(), - SshEditorHit::Primary => self.save_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 { 2 } else { 1 }, + if shows_password { 4 } else { 3 }, + ); + } + 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 { 3 } else { 2 }, + if shows_password { 4 } else { 3 }, + ); + } + self.save_ssh_editor(); + }, SshEditorHit::None => return false, } self.pending_update.dirty = true; diff --git a/nebula_app/src/display/sftp_panel.rs b/nebula_app/src/display/sftp_panel.rs index 30e6308..31d0847 100644 --- a/nebula_app/src/display/sftp_panel.rs +++ b/nebula_app/src/display/sftp_panel.rs @@ -565,12 +565,34 @@ pub(super) fn draw_text( ); if let Some(error) = panel.editor_error.as_deref().or(snapshot.error.as_deref()) { + let user_error = crate::ux::UserFacingError::new( + "远端文件操作失败", + error, + "检查网络和目录权限,然后点击刷新重试。", + ) + .retry(crate::ux::RetryAction::Retry); renderer.draw_chrome_text( size, layout.panel.0 + s(14.0), layout.list_y, Rgb::new(skin.danger.r, skin.danger.g, skin.danger.b), - &super::truncate_tab_label(error, 30), + &super::truncate_tab_label(&user_error.title, 30), + glyph_cache, + ); + renderer.draw_chrome_text( + size, + layout.panel.0 + s(14.0), + layout.list_y + s(20.0), + skin.ink_dim, + &super::truncate_tab_label(&format!("原因:{}", user_error.cause), 30), + glyph_cache, + ); + renderer.draw_chrome_text( + size, + layout.panel.0 + s(14.0), + layout.list_y + s(40.0), + skin.accent, + "建议:检查权限后点击刷新", glyph_cache, ); } else if matches!(snapshot.phase, SftpPhase::Connecting | SftpPhase::Loading) { @@ -624,12 +646,41 @@ pub(super) fn draw_text( ); } if panel.visible_entries().is_empty() { + let empty = if panel.filter.is_empty() { + crate::ux::EmptyState::new( + "此目录为空", + "远端目录中还没有文件或子目录。", + "使用上方上传按钮添加第一个文件。", + ) + } else { + crate::ux::EmptyState::new( + "没有匹配文件", + "当前筛选条件未匹配任何远端条目。", + "修改筛选词或清空筛选框。", + ) + }; renderer.draw_chrome_text( size, layout.panel.0 + s(14.0), layout.list_y, skin.ink_faint, - if panel.filter.is_empty() { "此目录为空" } else { "没有匹配文件" }, + &empty.title, + glyph_cache, + ); + renderer.draw_chrome_text( + size, + layout.panel.0 + s(14.0), + layout.list_y + s(20.0), + skin.ink_dim, + &super::truncate_tab_label(&empty.reason, 31), + glyph_cache, + ); + renderer.draw_chrome_text( + size, + layout.panel.0 + s(14.0), + layout.list_y + s(40.0), + skin.accent, + &super::truncate_tab_label(&empty.action, 31), glyph_cache, ); } diff --git a/nebula_app/src/display/side_panel.rs b/nebula_app/src/display/side_panel.rs index e277fe0..334bb36 100644 --- a/nebula_app/src/display/side_panel.rs +++ b/nebula_app/src/display/side_panel.rs @@ -1317,6 +1317,45 @@ pub(super) fn draw_text( let name = super::truncate_tab_label(&row.name, name_cols); r.draw_chrome_text(size, name_x, ry, name_ink, &name, gc); } + if panel.file_rows().is_empty() { + let empty = if filtering { + crate::ux::EmptyState::new( + "没有匹配文件", + "当前筛选词未匹配工作区内容。", + "修改筛选词,或按 Esc 清空筛选。", + ) + } else if panel.root.is_none() { + crate::ux::EmptyState::new( + "没有可浏览的目录", + "当前终端尚未报告工作目录。", + "在终端中进入一个目录后点击刷新。", + ) + } else { + crate::ux::EmptyState::new( + "此目录为空", + "当前工作目录中没有可显示的文件。", + "在终端创建文件,或选择其他目录。", + ) + }; + let y = layout.list_y + s(8.0); + r.draw_chrome_text(size, px + text_pad, y, sk.ink_strong, &empty.title, gc); + r.draw_chrome_text( + size, + px + text_pad, + y + s(20.0), + sk.ink_dim, + &super::truncate_tab_label(&empty.reason, 32), + gc, + ); + r.draw_chrome_text( + size, + px + text_pad, + y + s(40.0), + sk.accent, + &super::truncate_tab_label(&empty.action, 32), + gc, + ); + } // Drag ghost label, riding the chip pushed by `push_quads`. // Same chip-width formula as there (that pass has no cell_w), then diff --git a/nebula_app/src/display/ssh_editor_render.rs b/nebula_app/src/display/ssh_editor_render.rs index 33a5aed..0cd29aa 100644 --- a/nebula_app/src/display/ssh_editor_render.rs +++ b/nebula_app/src/display/ssh_editor_render.rs @@ -324,6 +324,8 @@ impl Display { accent, &skin, scale, + editor.focus.current(), + show_password, ); for rect in [cancel_key_rect, primary_key_rect] { quads.push(UiQuad::solid( @@ -597,6 +599,8 @@ fn button_quads( accent: Rgba, skin: &theme::Skin, scale: f32, + focus: usize, + shows_password: bool, ) { let s = |value: f32| value * scale; let primary_edge = Rgba::new(accent.r, accent.g, accent.b, if skin.is_light { 72 } else { 92 }); @@ -610,6 +614,22 @@ fn button_quads( edge, )); } + let cancel_focus = if shows_password { 2 } else { 1 }; + let primary_focus = if shows_password { 3 } else { 2 }; + for rect in + [(focus == cancel_focus).then_some(cancel), (focus == primary_focus).then_some(primary)] + .into_iter() + .flatten() + { + quads.push(UiQuad::solid( + rect.0 - s(2.0), + rect.1 - s(2.0), + rect.2 + s(4.0), + rect.3 + s(4.0), + s(10.0), + Rgba::new(skin.accent.r, skin.accent.g, skin.accent.b, 255), + )); + } quads.push(UiQuad::solid(cancel.0, cancel.1, cancel.2, cancel.3, s(8.0), skin.surface)); quads.push(UiQuad::solid(primary.0, primary.1, primary.2, primary.3, s(8.0), skin.accent_soft)); if hover == SshEditorHit::Cancel { diff --git a/nebula_app/src/display/ssh_ui.rs b/nebula_app/src/display/ssh_ui.rs index 27f23b3..46ae774 100644 --- a/nebula_app/src/display/ssh_ui.rs +++ b/nebula_app/src/display/ssh_ui.rs @@ -87,6 +87,7 @@ pub struct SshHostEditor { pub auth: SshAuthMode, pub private_keys: Vec, pub field: SshEditorField, + pub focus: crate::ux::FocusIndex, pub error: Option, pub(super) destination_selection: SelectAllState, pub(super) password_selection: SelectAllState, diff --git a/nebula_app/src/input/keyboard.rs b/nebula_app/src/input/keyboard.rs index 50b4a2f..8a071e7 100644 --- a/nebula_app/src/input/keyboard.rs +++ b/nebula_app/src/input/keyboard.rs @@ -279,8 +279,10 @@ impl> Processor { if self.ctx.display().ssh_editor_active() { match &key.logical_key { Key::Named(NamedKey::Escape) => self.ctx.display().close_ssh_editor(), - Key::Named(NamedKey::Enter) => self.ctx.display().save_ssh_editor(), - Key::Named(NamedKey::Tab) => self.ctx.display().ssh_editor_next_field(), + Key::Named(NamedKey::Enter) => self.ctx.display().ssh_editor_activate_focus(), + Key::Named(NamedKey::Tab) => { + self.ctx.display().ssh_editor_next_field(mods.shift_key()) + }, Key::Named(NamedKey::Backspace) => self.ctx.display().ssh_editor_backspace(), Key::Character(c) if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("a") => diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index 95d5ed7..2b10a09 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -36,9 +36,9 @@ mod daemon; mod display; mod event; #[cfg(windows)] -mod font_install; -#[cfg(windows)] mod file_uri; +#[cfg(windows)] +mod font_install; mod input; mod logging; #[cfg(target_os = "macos")] @@ -71,6 +71,7 @@ mod ssh_session; #[cfg(windows)] mod ssh_sftp; mod string; +mod ux; mod window_context; mod gl { diff --git a/nebula_app/src/message_bar.rs b/nebula_app/src/message_bar.rs index 73c3a7e..f2a38fd 100644 --- a/nebula_app/src/message_bar.rs +++ b/nebula_app/src/message_bar.rs @@ -35,12 +35,14 @@ impl Message { Message { text, ty, target: None } } + pub fn user_error(error: &crate::ux::UserFacingError) -> Message { + Message::new(error.message(), MessageType::Error) + } + /// Formatted message text lines. pub fn text(&self, size_info: &SizeInfo) -> Vec { let num_cols = size_info.columns(); - let total_lines = (size_info.height() - - size_info.padding_y() - - size_info.padding_bottom()) + let total_lines = (size_info.height() - size_info.padding_y() - size_info.padding_bottom()) / size_info.cell_height(); let max_lines = (total_lines as usize).saturating_sub(MIN_FREE_LINES); let button_len = CLOSE_BUTTON_TEXT.chars().count(); @@ -266,10 +268,10 @@ mod tests { let lines = message_buffer.message().unwrap().text(&size); - assert_eq!(lines, vec![ - String::from("hahahahahahahahaha [X]"), - String::from("[MESSAGE TRUNCATED] ") - ]); + assert_eq!( + lines, + vec![String::from("hahahahahahahahaha [X]"), String::from("[MESSAGE TRUNCATED] ")] + ); } #[test] @@ -355,11 +357,10 @@ mod tests { let lines = message_buffer.message().unwrap().text(&size); - assert_eq!(lines, vec![ - String::from("a [X]"), - String::from("bc "), - String::from("defg ") - ]); + assert_eq!( + lines, + vec![String::from("a [X]"), String::from("bc "), String::from("defg ")] + ); } #[test] @@ -371,11 +372,10 @@ mod tests { let lines = message_buffer.message().unwrap().text(&size); - assert_eq!(lines, vec![ - String::from("ab [X]"), - String::from("c 👩 d "), - String::from("fgh ") - ]); + assert_eq!( + lines, + vec![String::from("ab [X]"), String::from("c 👩 d "), String::from("fgh ")] + ); } #[test] diff --git a/nebula_app/src/ux.rs b/nebula_app/src/ux.rs new file mode 100644 index 0000000..4836ff9 --- /dev/null +++ b/nebula_app/src/ux.rs @@ -0,0 +1,229 @@ +//! Shared interaction-state contracts for Nebula's self-drawn UI. + +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RetryAction { + None, + Retry, + OpenSettings, + OpenLogs, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UserFacingError { + pub title: String, + pub cause: String, + pub suggestion: String, + pub retry: RetryAction, + pub details: Option, +} + +impl UserFacingError { + pub fn new( + title: impl Into, + cause: impl Into, + suggestion: impl Into, + ) -> Self { + Self { + title: title.into(), + cause: cause.into(), + suggestion: suggestion.into(), + retry: RetryAction::None, + details: None, + } + } + + pub fn retry(mut self, retry: RetryAction) -> Self { + self.retry = retry; + self + } + + pub fn details(mut self, details: impl Into) -> Self { + self.details = Some(details.into()); + self + } + + pub fn message(&self) -> String { + let mut message = + format!("{}\n原因:{}\n建议:{}", self.title, self.cause, self.suggestion); + let action = match self.retry { + RetryAction::None => None, + RetryAction::Retry => Some("操作:请重试"), + RetryAction::OpenSettings => Some("操作:打开设置检查配置"), + RetryAction::OpenLogs => Some("操作:打开日志查看诊断信息"), + }; + if let Some(action) = action { + message.push('\n'); + message.push_str(action); + } + if let Some(details) = &self.details { + message.push_str("\n详情:"); + message.push_str(details); + } + message + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TaskKind { + Determinate, + Indeterminate, + Short, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum TaskState { + Idle, + Running { + kind: TaskKind, + label: String, + started_at: Instant, + progress: Option, + cancellable: bool, + }, + Failed(UserFacingError), +} + +impl Default for TaskState { + fn default() -> Self { + Self::Idle + } +} + +impl TaskState { + pub fn running(kind: TaskKind, label: impl Into, cancellable: bool) -> Self { + Self::Running { + kind, + label: label.into(), + started_at: Instant::now(), + progress: None, + cancellable, + } + } + + pub fn show_busy(&self, now: Instant) -> bool { + matches!(self, Self::Running { started_at, .. } if now.saturating_duration_since(*started_at) >= Duration::from_millis(150)) + } + + pub fn show_stage(&self, now: Instant) -> bool { + matches!(self, Self::Running { started_at, .. } if now.saturating_duration_since(*started_at) >= Duration::from_secs(1)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmptyState { + pub title: String, + pub reason: String, + pub action: String, + pub shortcut: Option, +} + +impl EmptyState { + pub fn new( + title: impl Into, + reason: impl Into, + action: impl Into, + ) -> Self { + Self { title: title.into(), reason: reason.into(), action: action.into(), shortcut: None } + } +} + +/// Retains the last successful value while a refresh is in flight. +#[derive(Debug, Clone, PartialEq)] +pub struct Refreshing { + value: Option, + refreshing: bool, + error: Option, +} + +impl Default for Refreshing { + fn default() -> Self { + Self { value: None, refreshing: false, error: None } + } +} + +impl Refreshing { + pub fn begin(&mut self) { + self.refreshing = true; + self.error = None; + } + + pub fn succeed(&mut self, value: T) { + self.value = Some(value); + self.refreshing = false; + self.error = None; + } + + pub fn fail(&mut self, error: UserFacingError) { + self.refreshing = false; + self.error = Some(error); + } + + pub fn value(&self) -> Option<&T> { + self.value.as_ref() + } + + pub fn is_refreshing(&self) -> bool { + self.refreshing + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct FocusIndex { + current: usize, +} + +impl FocusIndex { + pub fn current(self) -> usize { + self.current + } + + pub fn set(&mut self, index: usize, count: usize) { + self.current = index.min(count.saturating_sub(1)); + } + + pub fn advance(&mut self, count: usize, reverse: bool) { + if count == 0 { + self.current = 0; + } else if reverse { + self.current = (self.current + count - 1) % count; + } else { + self.current = (self.current + 1) % count; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn structured_error_contains_cause_and_next_step() { + let error = UserFacingError::new("连接失败", "主机不可达", "检查地址后重试") + .retry(RetryAction::Retry) + .details("timeout"); + let message = error.message(); + assert!(message.contains("原因:主机不可达")); + assert!(message.contains("建议:检查地址后重试")); + assert!(message.contains("操作:请重试")); + } + + #[test] + fn refresh_keeps_last_successful_value_on_failure() { + let mut state = Refreshing::default(); + state.succeed(vec![1, 2]); + state.begin(); + state.fail(UserFacingError::new("刷新失败", "磁盘不可用", "稍后重试")); + assert_eq!(state.value(), Some(&vec![1, 2])); + } + + #[test] + fn focus_wraps_in_both_directions() { + let mut focus = FocusIndex::default(); + focus.advance(3, true); + assert_eq!(focus.current(), 2); + focus.advance(3, false); + assert_eq!(focus.current(), 0); + } +} diff --git a/nebula_app/src/window_context.rs b/nebula_app/src/window_context.rs index 4e319e9..3463c98 100644 --- a/nebula_app/src/window_context.rs +++ b/nebula_app/src/window_context.rs @@ -1030,12 +1030,14 @@ impl WindowContext { }, Err(err) => { error!("创建直连 SSH Pane 失败: {err}"); - self.message_buffer.push(crate::message_bar::Message::new( - format!( - "SSH {host} 连接创建失败:{err}。请检查地址/SSH 配置,右键编辑后重试。" - ), - crate::message_bar::MessageType::Error, - )); + let user_error = crate::ux::UserFacingError::new( + format!("SSH {host} 连接创建失败"), + "无法创建 SSH 会话,地址、认证方式或本机 SSH 配置可能无效。", + "检查主机地址和认证配置,右键编辑该主机后重试。", + ) + .retry(crate::ux::RetryAction::Retry) + .details(err.to_string()); + self.message_buffer.push(crate::message_bar::Message::user_error(&user_error)); self.dirty = true; self.display.window.request_redraw(); return;