SFTP:断线时操作远程侧弹出重连对话框,工具栏不换行,分栏对半并按标签记住

- 断线后对远程侧的操作(导航、刷新、删改、属性、所有传输)不再没有
  反应:弹出「与 会话名 的 SFTP 连接已断开」,说明原因,可直接「重新
  连接」;队列里有停止的批次或正在自动重连时只说明该怎么办。远程工具栏
  和菜单在未连接时保持可点,只有连接中才禁用
- 列表还没读到目录时显示「正在连接 SFTP…」「正在读取目录…」,不再
  先显示「空目录」
- 工具栏固定一行高,放不下的按钮从右往左整个隐藏,不换行,两侧列表
  始终对齐
- 新开的 SFTP 标签本地、远程对半分;分隔线位置(含队列)由标签自己
  持有,切换标签后不再被重置(不持久化)
- 去掉当前面板路径标签的蓝色下划线
- 双击任一中间区标签的标题,显示 / 隐藏左侧会话栏(同 ⌘B)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
XuzandClaude committed 2026-09-30 18:06:07 +08:00
1 parent bf72546c64
commit 1e81f85343
8 files changed
+559 -55

No files matched your search

+162 -3
View File
@@ -21,10 +21,11 @@ use crate::{
use gpui_kit::component::{
Disableable as _, Icon, IconName, Sizable as _, WindowExt as _,
button::Button,
dialog::DialogButtonProps,
dock::{BasePanel, Panel, PanelEvent, TabGroup},
menu::PopupMenu,
notification::Notification,
resizable::{h_resizable, resizable_panel, v_resizable},
resizable::{ResizableState, h_resizable, resizable_panel, v_resizable},
v_flex,
};
use gpui_kit::*;
@@ -77,6 +78,11 @@ pub struct ExplorerPanel {
custom_title: Option<SharedString>,
/// The pane that held focus last, which takes it back on activation.
last_remote: bool,
/// Where the user put the divider between the panes, and the one above
/// the queue. Kept here, not in element state, which goes the first
/// frame another tab is shown instead: coming back would reset them.
panes_split: Entity<ResizableState>,
queue_split: Entity<ResizableState>,
_subscriptions: Vec<Subscription>,
_events: Task<()>,
}
@@ -217,6 +223,8 @@ impl ExplorerPanel {
tab_group: None,
custom_title: None,
last_remote: true,
panes_split: cx.new(|_| ResizableState::default()),
queue_split: cx.new(|_| ResizableState::default()),
_subscriptions: subscriptions,
_events: events_task,
}
@@ -276,6 +284,75 @@ impl ExplorerPanel {
pub fn can_reconnect(&self) -> bool {
self.state != ConnectionState::Connecting && !self.engine_busy && !self.head_stopped()
}
/// Tell the user the remote side is disconnected, and why, with 重新连接
/// right there. When the queue owns reconnecting (a stopped batch waits
/// for 继续, or a transfer is reconnecting by itself), say so instead.
fn offer_reconnect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if window.has_active_dialog(cx) {
return;
}
let name = self
.store
.read(cx)
.session(self.session_id)
.map_or_else(|| "服务器".into(), |session| session.name.clone());
let reason = self
.remote
.read(cx)
.problem()
.unwrap_or("SFTP 已断开")
.to_string();
let can_reconnect = self.can_reconnect();
let description = if can_reconnect {
format!("{reason}。重新连接后再操作远程文件。")
} else if self.head_stopped() {
format!("{reason}。传输队列里有停止的批次,点它的「继续」会重新连接并接着传输。")
} else {
format!("{reason}。正在自动重新连接,请稍候。")
};
let (dispatch, sid, generation) = (self.dispatch.clone(), self.id, self.generation);
let focus = window.focused(cx);
window.open_alert_dialog(cx, move |dialog, _, _| {
let restore = {
let focus = focus.clone();
move |_: &ClickEvent, window: &mut Window, cx: &mut App| {
if let Some(focus) = &focus {
window.focus(focus, cx);
}
true
}
};
let dialog = dialog
.title(format!("与 {name} 的 SFTP 连接已断开"))
.description(description.clone());
if can_reconnect {
dialog
.button_props(
DialogButtonProps::default()
.ok_text("重新连接")
.cancel_text("取消"),
)
.show_cancel(true)
.on_cancel(restore.clone())
.on_ok({
let (dispatch, restore) = (dispatch.clone(), restore.clone());
move |event, window, cx| {
dispatch.dispatch_explorer_action(
&ExplorerAction::new(sid, ExplorerCommand::Reconnect)
.with_generation(generation),
window,
cx,
);
restore(event, window, cx)
}
})
} else {
dialog
.button_props(DialogButtonProps::default().ok_text("知道了"))
.on_ok(restore)
}
});
}
/// The engine's last report on the batch at the head of the queue.
pub fn progress(&self) -> Option<&TransferProgress> {
self.queue.head().and_then(QueueEntry::progress)
@@ -359,7 +436,9 @@ impl ExplorerPanel {
fn sync_available(&mut self, cx: &mut Context<Self>) {
let state = self.state;
// A batch confirmed while another runs waits its turn in the queue.
let transfer = state == ConnectionState::Connected;
// Disconnected, transfers are still asked for, and answered with the
// offer to reconnect; only while connecting do they wait.
let transfer = state != ConnectionState::Connecting;
self.local.update(cx, |pane, cx| {
pane.set_available(ConnectionState::Connected, transfer, cx)
});
@@ -516,6 +595,12 @@ impl ExplorerPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
// Disconnected, a command for the remote side says why nothing
// happens and offers to reconnect, rather than doing nothing.
if self.state == ConnectionState::Disconnected && needs_connection(command) {
self.offer_reconnect(window, cx);
return;
}
match command {
ExplorerCommand::Navigate { remote, path } => {
self.navigate(*remote, path.clone(), LoadIntent::Visit, window, cx)
@@ -882,6 +967,39 @@ impl RenamableTab for ExplorerPanel {
cx.notify();
}
}
/// Whether a command needs the SFTP connection: whatever reads or changes
/// the remote side, and every transfer. Selecting, bookmarks, copying a
/// path and the queue's own buttons do not.
fn needs_connection(command: &ExplorerCommand) -> bool {
use ExplorerCommand as C;
match command {
C::Navigate { remote, .. }
| C::Up { remote }
| C::Refresh { remote }
| C::Root { remote }
| C::Home { remote }
| C::Back { remote }
| C::Forward { remote }
| C::Open { remote }
| C::OpenDirectory { remote }
| C::Delete { remote }
| C::BeginDelete { remote, .. }
| C::Rename { remote }
| C::CommitRename { remote, .. }
| C::New { remote, .. }
| C::CommitNew { remote, .. }
| C::Properties { remote }
| C::ApplyPermissions { remote, .. } => *remote,
C::Transfer { .. }
| C::DownloadPaths { .. }
| C::BeginDownload { .. }
| C::ChooseFiles
| C::UploadPaths { .. }
| C::BeginUpload { .. } => true,
_ => false,
}
}
/// The status of an SFTP tab for the window's status line.
pub struct ExplorerStatus {
pub state: ConnectionState,
@@ -974,17 +1092,23 @@ impl Render for ExplorerPanel {
.child(
div().flex_1().min_h_0().child(
v_resizable(("explorer-queue", sid.0))
.with_state(&self.queue_split)
.child(
resizable_panel().child(
h_resizable(("explorer-panes", sid.0))
.with_state(&self.panes_split)
.child(
// Half and half when the tab opens; the
// state then keeps where the user puts
// the divider.
resizable_panel()
.size(px(480.))
.flex_basis(relative(0.5))
.size_range(px(320.)..Pixels::MAX)
.child(self.local.clone()),
)
.child(
resizable_panel()
.flex_basis(relative(0.5))
.size_range(px(280.)..Pixels::MAX)
.child(self.remote.clone()),
),
@@ -1016,3 +1140,38 @@ fn transfer_command(job: &TransferJob) -> anyhow::Result<SftpCommand> {
.map(SftpCommand::Download),
}
}
#[cfg(test)]
mod tests {
use super::needs_connection;
use crate::app::ExplorerCommand;
#[test]
fn remote_side_and_transfers_need_the_connection() {
assert!(needs_connection(&ExplorerCommand::Refresh { remote: true }));
assert!(!needs_connection(&ExplorerCommand::Refresh {
remote: false
}));
assert!(needs_connection(&ExplorerCommand::Delete { remote: true }));
assert!(!needs_connection(&ExplorerCommand::Delete {
remote: false
}));
// Either way a transfer goes over it.
assert!(needs_connection(&ExplorerCommand::Transfer {
remote: false
}));
assert!(needs_connection(&ExplorerCommand::ChooseFiles));
// Choosing, bookmarks and the queue do not.
assert!(!needs_connection(&ExplorerCommand::SelectAll {
remote: true
}));
assert!(!needs_connection(&ExplorerCommand::CopyPath {
remote: true
}));
assert!(!needs_connection(&ExplorerCommand::AddBookmark {
remote: true,
path: None
}));
assert!(!needs_connection(&ExplorerCommand::Reconnect));
}
}
+33 -22
View File
@@ -32,6 +32,8 @@ pub(super) struct ListingContext {
pub menu_hit: Rc<RefCell<Option<MenuHit>>>,
/// Where the rows are, for the selection rectangle.
pub geometry: Rc<ListGeometry>,
/// What the list says while it has no rows.
pub placeholder: SharedString,
}
/// Where the list and its rows are on screen, recorded as they paint, so a
@@ -103,6 +105,7 @@ impl Default for ListingContext {
pane: None,
menu_hit: Rc::default(),
geometry: Rc::default(),
placeholder: SharedString::default(),
}
}
}
@@ -117,30 +120,34 @@ pub struct FileListing {
context: ListingContext,
}
/// WinSCP's columns for each side. Column widths are an API boundary that
/// takes `Pixels`.
fn columns(side: PaneSide) -> Vec<Column> {
match side {
PaneSide::Local => vec![
name_column(px(260.)),
Column::new("size", "大小").width(px(100.)).sortable(),
Column::new("type", "类型").width(px(100.)).sortable(),
Column::new("modified", "修改时间")
.width(px(150.))
.sortable(),
],
PaneSide::Remote => vec![
name_column(px(240.)),
Column::new("size", "大小").width(px(100.)).sortable(),
Column::new("modified", "修改时间")
.width(px(150.))
.sortable(),
Column::new("rights", "权限").width(px(96.)).sortable(),
Column::new("owner", "所有者").width(px(80.)).sortable(),
],
}
}
impl FileListing {
pub fn new(side: PaneSide, rows: Vec<FileEntry>) -> Self {
// Column widths are an API boundary that takes `Pixels`.
let columns = match side {
PaneSide::Local => vec![
name_column(px(260.)),
Column::new("size", "大小").width(px(100.)).sortable(),
Column::new("type", "类型").width(px(100.)).sortable(),
Column::new("modified", "修改时间")
.width(px(150.))
.sortable(),
],
PaneSide::Remote => vec![
name_column(px(240.)),
Column::new("size", "大小").width(px(100.)).sortable(),
Column::new("modified", "修改时间")
.width(px(150.))
.sortable(),
Column::new("rights", "权限").width(px(96.)).sortable(),
Column::new("owner", "所有者").width(px(80.)).sortable(),
],
};
let mut listing = Self {
columns,
columns: columns(side),
rows: Vec::new(),
sort: None,
side,
@@ -602,14 +609,18 @@ impl TableDelegate for FileListing {
_: &mut Window,
cx: &mut Context<TableState<Self>>,
) -> impl IntoElement {
let text = self.context.placeholder.clone();
div()
.id("list-placeholder")
.size_full()
.flex()
.items_center()
.justify_center()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("空目录")
.child(text.clone())
.test_support()
.aria_label(text)
}
}
+49 -8
View File
@@ -198,6 +198,9 @@ pub struct FilePane {
/// What went wrong reading this pane's directory, or why the remote
/// side dropped: the window's status line shows it, see `problem`.
error: Option<String>,
/// A directory has been read into the list at least once, so an empty
/// list is an empty directory.
listed: bool,
connection: ConnectionState,
transfer_enabled: bool,
/// A file operation on this pane is running.
@@ -276,6 +279,7 @@ impl FilePane {
slow_load: false,
slow_load_timer: None,
error: None,
listed: false,
connection: if side == PaneSide::Local {
ConnectionState::Connected
} else {
@@ -306,6 +310,12 @@ impl FilePane {
pub fn is_connected(&self) -> bool {
self.connection == ConnectionState::Connected
}
/// Whether the toolbar, menus and path label take commands. Disconnected
/// they do: the tab answers with the offer to reconnect. Only while
/// connecting is there nothing to do but wait.
pub fn takes_commands(&self) -> bool {
self.connection != ConnectionState::Connecting
}
pub fn is_current(&self) -> bool {
self.current
}
@@ -466,12 +476,34 @@ impl FilePane {
pane: Some(cx.entity().downgrade()),
menu_hit: self.menu_hit.clone(),
geometry: self.geometry.clone(),
placeholder: self.placeholder().into(),
};
self.table.update(cx, |table, cx| {
table.delegate_mut().configure(context);
cx.notify();
});
}
/// What the list says while it has no rows: 空目录 only once a directory
/// has been read, what is going on until then, in the status line's
/// words. An empty directory being read again keeps saying 空目录 until
/// the read is slow, like the status line, so a quick refresh does not
/// flicker.
fn placeholder(&self) -> &'static str {
if self.connection == ConnectionState::Connecting {
"正在连接 SFTP…"
} else if self.loading && (!self.listed || self.slow_load) {
"正在读取目录…"
} else if self.listed {
"空目录"
} else if self.connection == ConnectionState::Disconnected {
"未连接"
} else if self.error.is_some() {
"无法读取目录"
} else {
// Before the first read is asked for.
"正在读取目录…"
}
}
fn update_selection(
&mut self,
cx: &mut Context<Self>,
@@ -727,6 +759,7 @@ impl FilePane {
self.pending = None;
self.finish_loading();
self.set_error(Some(message), cx);
self.sync_listing(cx);
cx.notify();
}
/// What went wrong reading the directory, or why the remote side
@@ -745,6 +778,7 @@ impl FilePane {
self.pending = Some((self.request_id, intent, self.path.clone()));
self.loading = true;
self.set_error(None, cx);
self.sync_listing(cx);
// Most directories arrive at once; saying 正在读取 for those only
// flickers, so it waits.
let id = self.request_id;
@@ -754,6 +788,7 @@ impl FilePane {
let _ = this.update(cx, |this, cx| {
if this.loading && this.request_id == id {
this.slow_load = true;
this.sync_listing(cx);
cx.notify();
}
});
@@ -792,6 +827,7 @@ impl FilePane {
self.hovered_part = None;
}
self.path = listing.path().into();
self.listed = true;
self.set_error(None, cx);
let mut rows: Vec<_> = listing
.entries()
@@ -814,6 +850,7 @@ impl FilePane {
Err(error) => {
self.select_after_load = None;
self.set_error(Some(error), cx);
self.sync_listing(cx);
}
}
cx.notify();
@@ -890,7 +927,7 @@ impl FilePane {
can_go_forward: self.history.forward_target().is_some(),
path: self.path.clone(),
bookmarks: self.bookmarks(cx),
can_modify: self.is_connected() && !self.busy,
can_modify: self.takes_commands() && !self.busy,
can_transfer: self.transfer_enabled,
}
}
@@ -904,7 +941,7 @@ impl Render for FilePane {
let context = self.side.key_context();
let state = self.menu_state(cx);
let selected = state.targets.len();
let navigable = self.is_connected();
let navigable = self.takes_commands();
// A toolbar button dispatches the same command as its key binding,
// and its tooltip shows that binding.
let tool = |id: &'static str, icon: Icon, tip: &'static str, command: ExplorerCommand| {
@@ -932,14 +969,18 @@ impl Render for FilePane {
.iter()
.filter(|e| !e.is_parent())
.count();
let toolbar = || {
h_flex()
.flex_wrap()
.gap_1()
// A row is one line high whatever the pane's width: what does not
// fit wraps onto a second line that the height cuts off, so buttons
// go whole, from the right, and the list never moves down. Their
// commands stay on the menus and keys.
let toolbar = || h_flex().flex_wrap().gap_1().h_6().overflow_hidden();
let frame = |row: Div| {
div()
.px_2()
.py_1()
.border_b_1()
.border_color(cx.theme().border)
.child(row)
};
let navigation = toolbar()
// `Select` fills its parent (`size_full`), so it needs a sized
@@ -1124,8 +1165,8 @@ impl Render for FilePane {
.test_support()
.size_full()
.min_w_0()
.child(navigation)
.child(operations)
.child(frame(navigation))
.child(frame(operations))
.child(self.render_path_label(window, cx))
.child(
div()
+6 -9
View File
@@ -68,19 +68,16 @@ impl FilePane {
) -> AnyElement {
let remote = self.side == PaneSide::Remote;
let theme = cx.theme();
// The current pane's label is underlined, as WinSCP marks its
// active one.
// Both labels look alike: an underline for the current pane read as
// "this side is active" and fell behind where commands went.
let background = theme.list_active;
let border = if self.current {
theme.list_active_border
} else {
theme.border
};
let border = theme.border;
let bar = h_flex()
.id(self.side.path_id())
.test_support()
.aria_label(format!("{}路径", self.side.label()))
// Marks WinSCP's current pane, the one the underline shows.
// WinSCP's current pane, which takes focus back when the tab
// is shown again; nothing marks it on screen.
.aria_selected(self.current)
// The directory shown, once its listing is in.
.when(!self.loading, |this| this.aria_value(self.path.clone()))
@@ -90,7 +87,7 @@ impl FilePane {
.border_b_1()
.border_color(border);
let enabled = self.is_connected();
let enabled = self.takes_commands();
let parts = self.shown_parts(window);
let hot = self
.hovered_part
+14 -3
View File
@@ -9,6 +9,8 @@ use gpui_kit::component::{
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::app::ToggleSessionPanel;
type MenuBuilder = Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>;
/// A dock tab title: a leading mark, the label and, for closable panels, a
@@ -20,9 +22,12 @@ type MenuBuilder = Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) ->
/// workspace wants every tab closable (an empty center shows the recent
/// sessions instead) and removes the panel from the dock itself.
///
/// The context menu hangs on the title rather than on the dock's `Tab`, which
/// the application does not render. The tab's own horizontal padding is
/// therefore outside it; a right click there does nothing.
/// Double-clicking the title shows or hides the session sidebar, the way a
/// double click on an editor tab gives it the room elsewhere.
///
/// The context menu and the double click hang on the title rather than on
/// the dock's `Tab`, which the application does not render. The tab's own
/// horizontal padding is therefore outside them; clicks there only select.
#[derive(IntoElement)]
pub struct ClosableTabTitle {
id: ElementId,
@@ -89,6 +94,12 @@ impl RenderOnce for ClosableTabTitle {
});
div()
.id(self.id)
// The first click selects the tab as usual.
.on_click(|event, window, cx| {
if event.click_count() == 2 {
window.dispatch_action(Box::new(ToggleSessionPanel), cx);
}
})
.test_support()
.aria_label(self.label)
.child(match self.menu {