SFTP:断线时操作远程侧弹出重连对话框,工具栏不换行,分栏对半并按标签记住
- 断线后对远程侧的操作(导航、刷新、删改、属性、所有传输)不再没有 反应:弹出「与 会话名 的 SFTP 连接已断开」,说明原因,可直接「重新 连接」;队列里有停止的批次或正在自动重连时只说明该怎么办。远程工具栏 和菜单在未连接时保持可点,只有连接中才禁用 - 列表还没读到目录时显示「正在连接 SFTP…」「正在读取目录…」,不再 先显示「空目录」 - 工具栏固定一行高,放不下的按钮从右往左整个隐藏,不换行,两侧列表 始终对齐 - 新开的 SFTP 标签本地、远程对半分;分隔线位置(含队列)由标签自己 持有,切换标签后不再被重置(不持久化) - 去掉当前面板路径标签的蓝色下划线 - 双击任一中间区标签的标题,显示 / 隐藏左侧会话栏(同 ⌘B) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
bf72546c64
commit
1e81f85343
8 files changed
+559
-55
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user