feat: add unified UI state feedback

This commit is contained in:
kuddev committed 2026-07-16 14:13:30 +08:00
1 parent def82fa978
commit 4cf7380932
10 files changed
+415 -36

No files matched your search

+41 -7
View File
@@ -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;
+53 -2
View File
@@ -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,
);
}
+39
View File
@@ -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
@@ -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 {
+1
View File
@@ -87,6 +87,7 @@ pub struct SshHostEditor {
pub auth: SshAuthMode,
pub private_keys: Vec<PathBuf>,
pub field: SshEditorField,
pub focus: crate::ux::FocusIndex,
pub error: Option<String>,
pub(super) destination_selection: SelectAllState,
pub(super) password_selection: SelectAllState,
+4 -2
View File
@@ -279,8 +279,10 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
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") =>
+3 -2
View File
@@ -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 {
+17 -17
View File
@@ -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<String> {
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]
+229
View File
@@ -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<String>,
}
impl UserFacingError {
pub fn new(
title: impl Into<String>,
cause: impl Into<String>,
suggestion: impl Into<String>,
) -> 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<String>) -> 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<f32>,
cancellable: bool,
},
Failed(UserFacingError),
}
impl Default for TaskState {
fn default() -> Self {
Self::Idle
}
}
impl TaskState {
pub fn running(kind: TaskKind, label: impl Into<String>, 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<String>,
}
impl EmptyState {
pub fn new(
title: impl Into<String>,
reason: impl Into<String>,
action: impl Into<String>,
) -> 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<T> {
value: Option<T>,
refreshing: bool,
error: Option<UserFacingError>,
}
impl<T> Default for Refreshing<T> {
fn default() -> Self {
Self { value: None, refreshing: false, error: None }
}
}
impl<T> Refreshing<T> {
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);
}
}
+8 -6
View File
@@ -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;