feat: add unified UI state feedback
This commit is contained in:
1 parent
def82fa978
commit
4cf7380932
10 files changed
+415
-36
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") =>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user