From 0ebe68a453e664159d9eee8b0f855a708ac3b2ac Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:24:32 +0800 Subject: [PATCH] feat(ssh): fold port forwarding and SFTP into the detail panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both SSH tools floated over the terminal: a tunnel icon and an SFTP icon pinned top-right, opening a 460px popover and a bottom dock. They are pane facts, so they now live where the pane's other facts already are. Port forwarding becomes a Forwards band on the Info tab, under Ports — one says what the pane listens on locally, the other what it routes across the connection. Rows take the panel's language: a mono kind letter, the bound port as the same chip a listening port gets, hover to remove, click to edit. The add form is inline, stacked to fit the column. The list re-lists on the Info tab's existing 2s poll, so a forward that dies remotely turns red on its own. SFTP becomes the Files tab's remote mode: the tab follows the detail pane, showing a local repository tree or that machine's filesystem. Same browsing model as before (breadcrumb, filter, `..`-led list, per-row right-click) relaid out for ~260px — the toolbar collapses to refresh plus a `⋯`, and the permissions column moves into the chmod form, which now names the mode it is editing. The header carries the hostname: the tab swaps between two filesystems as the pane changes, and it can rename and delete. Transfers become a footer on the panel column rather than a tray inside SFTP. It sits below every tab, so reading Info doesn't hide a running upload, and stays pane-scoped rather than aggregating every pane, which would quietly make the panel a window-level transfer centre. Opening the browser gained a step: the shell's cwd needs tty7's shell integration on the remote, which a freshly-connected host rarely has, so it fell through to `/`. A new SftpOp::Realpath resolves the login directory instead. Per-pane positions are recorded on arrival, so a first landing at `/` can no longer be remembered as a preference. With nothing floating over the terminal any more, the ⌘F find bar gets its top-right slot back — it used to be suppressed while those icons were up. --- assets/icons/refresh.svg | 1 + src/core/actions.rs | 7 +- src/daemon/protocol.rs | 16 +- src/daemon/ssh/sftp.rs | 7 + src/ui/app.rs | 110 +++--- src/ui/assets.rs | 6 + src/ui/forwards.rs | 544 ++++++++++++-------------- src/ui/keymap.rs | 8 +- src/ui/palette.rs | 10 +- src/ui/right_panel.rs | 200 ++++++++-- src/ui/sftp.rs | 811 ++++++++++++++++++++++++--------------- 11 files changed, 1033 insertions(+), 687 deletions(-) create mode 100644 assets/icons/refresh.svg diff --git a/assets/icons/refresh.svg b/assets/icons/refresh.svg new file mode 100644 index 00000000..81178e79 --- /dev/null +++ b/assets/icons/refresh.svg @@ -0,0 +1 @@ + diff --git a/src/core/actions.rs b/src/core/actions.rs index f5eac7e7..386ade9d 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -73,8 +73,13 @@ actions!( ShowRightPanelFiles, OpenSettings, RestartDaemon, - // Toggle the SFTP file panel for the focused native-SSH pane (WS5). + // Show the detail panel's Files tab, which browses the focused pane's + // remote filesystem over SFTP when that pane is native SSH (WS5). ToggleSftp, + // Open the detail panel's Info tab on the focused native-SSH pane with + // the add-forward form expanded (WS4). The band itself is always on that + // tab; this is the way in that doesn't require the panel to be open. + ShowSshForwards, // Toggle the code panel: a full-body overlay of [file tree | editor] // covering the terminal (settings-overlay style). ToggleCodePanel, diff --git a/src/daemon/protocol.rs b/src/daemon/protocol.rs index f6f3707f..38bfe891 100644 --- a/src/daemon/protocol.rs +++ b/src/daemon/protocol.rs @@ -334,7 +334,7 @@ pub struct SftpEntry { } /// A metadata / namespace operation on the remote filesystem. Recursive delete -/// (`RemoveDir`) recurses daemon-side. `Stat`/`Readlink` return data in the +/// (`RemoveDir`) recurses daemon-side. `Stat`/`Readlink`/`Realpath` return data in the /// [`SftpOpResult`]; the rest just succeed or fail. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -370,6 +370,16 @@ pub enum SftpOp { Readlink { path: String, }, + /// Resolve `path` against the SFTP session's own working directory and return + /// it absolute (SFTP's REALPATH), as [`SftpOpResult::Link`]. + /// + /// Exists for one job: `Realpath { path: "." }` is how the browser learns the + /// login directory. A remote shell only reports its cwd if tty7's shell + /// integration is installed over there, which on a host you just connected to + /// it usually isn't — and `/` is a poor place to open a file browser. + Realpath { + path: String, + }, } /// The reply to a [`SftpOp`]. `Done` for side-effecting ops; `Stat`/`Link` carry @@ -1557,6 +1567,10 @@ mod tests { path: "/link".into(), }, }, + ClientMsg::SftpOp { + pane_id: 4, + op: SftpOp::Realpath { path: ".".into() }, + }, ClientMsg::SftpTransferStart(SftpTransferSpec { pane_id: 4, kind: SftpTransferKind::Upload, diff --git a/src/daemon/ssh/sftp.rs b/src/daemon/ssh/sftp.rs index 6b40b22a..2813c9c5 100644 --- a/src/daemon/ssh/sftp.rs +++ b/src/daemon/ssh/sftp.rs @@ -632,6 +632,13 @@ async fn run_op(sftp: &SftpSession, op: &SftpOp) -> Result .map_err(|e| format!("{e}"))?; SftpOpResult::Link(target) } + SftpOp::Realpath { path } => { + let resolved = sftp + .canonicalize(path.clone()) + .await + .map_err(|e| format!("{e}"))?; + SftpOpResult::Link(resolved) + } }) } diff --git a/src/ui/app.rs b/src/ui/app.rs index cb7466ca..ce0b0710 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -323,9 +323,13 @@ pub(crate) struct Renaming { } pub(crate) struct LoopbackForwardPanelState { - pub(crate) open_pane_id: Option, + /// The pane whose add/edit form is expanded under the Info tab's Forwards + /// band, or `None` while the band is just its list. Per-pane rather than a + /// bare flag so switching panes with a form open doesn't offer the new pane + /// a form half-filled with the old one's values. + pub(crate) form_pane_id: Option, /// The unified forwards list (Local/Remote/Dynamic, including auto localhost - /// forwards) for the open native-SSH pane (WS4). + /// forwards) for the pane the Info tab is showing (WS4). pub(crate) managed: Vec, /// Add-forward form state (native-SSH panes only). pub(crate) mf_kind: crate::daemon::protocol::SshForwardKind, @@ -735,7 +739,7 @@ impl Tty7App { home_focus: cx.focus_handle(), detected_shells: Vec::new(), loopback_panel: LoopbackForwardPanelState { - open_pane_id: None, + form_pane_id: None, managed: Vec::new(), mf_kind: crate::daemon::protocol::SshForwardKind::Local, mf_bind_host, @@ -1841,6 +1845,10 @@ impl Tty7App { let _ = crate::terminal::RemoteTerminal::remove_forward(pane_id, old_id); } self.loopback_panel.managed = crate::terminal::RemoteTerminal::add_forward(pane_id, rule); + // The new row *is* the confirmation, so the form folds away rather than + // sitting there re-inviting an add nobody asked for. (Only on the success + // path — every validation failure above returns early with it still open.) + self.loopback_panel.form_pane_id = None; // Reset the value-carrying fields; keep bind host default. for input in [ &self.loopback_panel.mf_bind_port, @@ -1863,6 +1871,9 @@ impl Tty7App { ) { self.loopback_panel.mf_kind = forward.kind; self.loopback_panel.mf_editing = Some(forward.id); + // Clicking a row is the only way in, and the form is where the values + // land — so expand it on the row's own pane. + self.loopback_panel.form_pane_id = Some(forward.pane_id); let target_port = if forward.target_port == 0 { String::new() } else { @@ -1923,20 +1934,50 @@ impl Tty7App { cx.notify(); } - pub(crate) fn toggle_loopback_forward_panel(&mut self, pane_id: u64, cx: &mut Context) { - let should_open = self.loopback_panel.open_pane_id != Some(pane_id); - if should_open { - self.loopback_panel.open_pane_id = Some(pane_id); - self.refresh_managed_forwards(pane_id, cx); - } else { - self.loopback_panel.open_pane_id = None; + /// `ShowSshForwards` / the palette's "SSH: Port Forwarding": land on the + /// pane's forwards wherever you were. The band lives on the Info tab, so this + /// opens the panel there and expands the add form — the one entry point that + /// works with the panel closed, which is why it exists at all. + /// + /// A no-op on anything but a connected native-SSH pane: without a connection + /// there is nothing to forward over, and opening an empty form on a local + /// shell would only be a puzzle. + pub(crate) fn show_ssh_forwards(&mut self, window: &mut Window, cx: &mut Context) { + let Some((pane_id, _)) = self.active_connected_native_ssh_pane(window, cx) else { + return; + }; + self.set_right_panel_tab(crate::core::config::RightPanelTab::Info, cx); + if self.loopback_panel.form_pane_id != Some(pane_id) { + self.toggle_managed_forward_form(pane_id, window, cx); } - cx.notify(); } - pub(crate) fn close_loopback_forward_panel(&mut self, cx: &mut Context) { - self.loopback_panel.open_pane_id = None; - cx.notify(); + /// The Forwards band's `+`: expand the add form for `pane_id`, or collapse it + /// if it's already this pane's. Collapsing goes through the same reset as + /// Cancel, so a form abandoned mid-edit can't come back still in edit mode. + pub(crate) fn toggle_managed_forward_form( + &mut self, + pane_id: u64, + window: &mut Window, + cx: &mut Context, + ) { + if self.loopback_panel.form_pane_id == Some(pane_id) { + self.close_managed_forward_form(window, cx); + return; + } + self.loopback_panel.form_pane_id = Some(pane_id); + self.cancel_managed_forward_edit(window, cx); + self.refresh_managed_forwards(pane_id, cx); + } + + /// Collapse the add/edit form, clearing it back to the add defaults. + pub(crate) fn close_managed_forward_form( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + self.loopback_panel.form_pane_id = None; + self.cancel_managed_forward_edit(window, cx); } /// Route a typed "SSH: Add Connection…" line to the native engine (PRD §3.1/ @@ -3154,6 +3195,7 @@ impl Tty7App { OpenSettings => self.toggle_settings(window, cx), RestartDaemon => self.restart_daemon(window, cx), ToggleSftp => self.toggle_sftp(window, cx), + ShowSshForwards => self.show_ssh_forwards(window, cx), ToggleCodePanel => self.toggle_code_panel(window, cx), RestartSshSession => self.restart_ssh_session(window, cx), SetTheme(i) => { @@ -4473,21 +4515,6 @@ impl Render for Tty7App { let rail = vertical && !cx.global::().sidebar_collapsed; let strip = self.tab_strip(!vertical, window, cx); let sidebar = rail.then(|| self.tab_sidebar(window, cx)); - // Gate the pane action buttons (tunnel / SFTP) + their panels to a - // connected native-SSH pane; a foreground `ssh` or a still-connecting - // session shows only the top-left status strip, no action buttons. - let active_ssh_pane = self.active_connected_native_ssh_pane(window, cx); - // The Cmd+F find bar pins to the same top-right slot as these action - // buttons and, being deep inside the pane tree, paints *under* them - // (gpui stacks by child order, and this overlay is a later sibling of - // `body`). Give the find bar that slot: while it's open on the focused - // pane, suppress the tunnel/SFTP icons so they don't bleed through. - let search_open = self - .tabs - .get(self.active) - .and_then(|t| t.pane.focused_or_first(window, cx)) - .map(|leaf| leaf.read(cx).search.is_some()) - .unwrap_or(false); // Native-SSH status strip / reconnect notice for the focused pane (E1/E4). let ssh_status = self .tabs @@ -4538,22 +4565,12 @@ impl Render for Tty7App { .relative() .overflow_hidden() .child(body) - // Pane-contextual tunnel / SFTP action buttons, pinned top-right of - // the terminal area when the active pane is a connected native SSH - // session (the tunnel button also drives the forwards panel). - .when_some(active_ssh_pane, |this, (pane_id, remote)| { - // Hide the top-right tunnel/SFTP icons while the find bar owns - // that slot; the bottom-docked SFTP panel is unaffected. - this.when(!search_open, |this| { - this.child(self.render_loopback_forward_overlay(pane_id, &remote, cx)) - }) - // Pane-contextual SFTP panel (WS5), docked right when open for - // this (native-SSH) pane. - .when_some( - self.render_sftp_overlay(pane_id, &remote, window, cx), - |this, panel| this.child(panel), - ) - }) + // Nothing of the SSH tooling floats over the terminal any more: port + // forwarding is a band on the detail panel's Info tab, the remote file + // browser is its Files tab, and transfers are the panel's footer. That + // also gives the ⌘F find bar the top-right slot back — it used to have + // to fight the tunnel/SFTP icons for it. + // // In-pane native-SSH auth / host-key sheet (WS3), shown over the pane // that raised the prompt. .when_some(self.render_ssh_prompt_overlay(window, cx), |this, el| { @@ -4858,6 +4875,9 @@ impl Render for Tty7App { cx.listener(|this, _: &RestartDaemon, window, cx| this.restart_daemon(window, cx)), ) .on_action(cx.listener(|this, _: &ToggleSftp, window, cx| this.toggle_sftp(window, cx))) + .on_action(cx.listener(|this, _: &ShowSshForwards, window, cx| { + this.show_ssh_forwards(window, cx) + })) .on_action(cx.listener(|this, _: &ToggleCodePanel, window, cx| { this.toggle_code_panel(window, cx) })) diff --git a/src/ui/assets.rs b/src/ui/assets.rs index 0122bb34..6bd54ed0 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -120,6 +120,12 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { // about *About*. No upstream `IconName` maps here, so it's referenced by // path (see `settings.rs`). "icons/circle-info.svg" => include_bytes!("../../assets/icons/circle-info.svg"), + // The Files tab's remote (SFTP) mode needs a refresh it doesn't need + // locally: the local tree runs a recursive filesystem watcher and + // invalidates itself, a remote listing has nothing watching it. Drawn to + // the circle rule above (2.7→21.3) so it sits level with the `eye` beside + // it rather than lucide's r=9 `rotate-cw`, which reads a step small. + "icons/refresh.svg" => include_bytes!("../../assets/icons/refresh.svg"), "icons/agents/claude.svg" => include_bytes!("../../assets/icons/agents/claude.svg"), "icons/agents/codex.svg" => include_bytes!("../../assets/icons/agents/codex.svg"), "icons/agents/gemini.svg" => include_bytes!("../../assets/icons/agents/gemini.svg"), diff --git a/src/ui/forwards.rs b/src/ui/forwards.rs index 82310419..34df3278 100644 --- a/src/ui/forwards.rs +++ b/src/ui/forwards.rs @@ -2,17 +2,20 @@ //! //! Settings owns persistent preferences; this module owns the live forwarding //! dashboard that only makes sense beside a concrete SSH pane. +//! +//! The dashboard is a **band in the detail panel's Info tab**, not a popover over +//! the terminal: a pane's forwards are one of its facts, so they belong beside its +//! cwd, processes and ports rather than in a floating panel of their own. The +//! rendering helpers here are called from `right_panel`'s Info body. -use gpui::{AnyElement, Context, Div, Entity, FontWeight, div, prelude::*, px}; -use gpui_component::Selectable as _; -use gpui_component::badge::Badge; +use gpui::{AnyElement, Context, Div, Entity, FontWeight, Stateful, div, prelude::*, px}; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::Input; -use gpui_component::{ActiveTheme as _, IconName, Sizable as _, h_flex, v_flex}; +use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; -use crate::daemon::protocol::{ForwardStatus, ManagedForward, RemoteContext, SshForwardKind}; +use crate::daemon::protocol::{ForwardStatus, ManagedForward, SshForwardKind}; use crate::terminal::view::TerminalView; -use crate::ui::app::Tty7App; +use crate::ui::app::{CONTENT_INSET, Tty7App}; impl Tty7App { /// The in-pane native-SSH notice (PRD FR-E4): a dead pane shows a @@ -154,148 +157,39 @@ impl Tty7App { ) } - /// Pane-contextual action buttons for a connected native-SSH pane, pinned - /// top-right of the terminal body: a **tunnel** icon that toggles the port - /// forwarding panel and an **SFTP** icon that toggles the file browser. The - /// panels themselves are unchanged; these are just discoverable entry points - /// beside the top-left ` SSH ` status strip (status vs. actions). The tunnel - /// icon carries a small count badge when one or more forwards are active. + /// The Info tab's **Forwards** band: what this pane routes across its + /// connection, sitting under Ports, which says what it listens on locally. + /// `None` for anything but a connected native-SSH pane — the band doesn't + /// exist rather than showing an empty section on every local shell. /// - /// The caller gates this to a connected native pane (see `app.rs` render), so - /// the buttons never appear for a plain foreground `ssh` or a still-connecting - /// session. - pub(crate) fn render_loopback_forward_overlay( + /// The rows are the daemon's list, re-fetched on the Info tab's own poll (see + /// `right_panel::sync_procs`), so a forward that dies out from under us turns + /// red here without anyone clicking anything. + pub(crate) fn forwards_section( &self, - pane_id: u64, - remote: &RemoteContext, + pane_id: Option, cx: &mut Context, - ) -> AnyElement { - let foreground = cx.theme().foreground; - let active_count = self - .loopback_panel - .managed - .iter() - .filter(|m| m.pane_id == pane_id) - .count(); - let panel_open = self.loopback_panel.open_pane_id == Some(pane_id); - let sftp_open = self.sftp_panel.open_pane_id == Some(pane_id); + ) -> Option { + let pane_id = pane_id?; + let open = self.loopback_panel.form_pane_id == Some(pane_id); + // The `+` toggles the add form open. It's the band's only control, so it + // takes the header's trailing slot rather than a row of its own. + let add = crate::ui::tab_strip::chrome_tile( + Button::new(("ssh-forward-add-toggle", pane_id)) + .icon(Icon::empty().path("icons/plus.svg").size(px(13.))), + open, + cx, + ) + .xsmall() + .w(px(24.)) + .h(px(24.)) + .rounded_md() + .tooltip(if open { "Cancel" } else { "Add forward" }) + .on_click(cx.listener(move |this, _, window, cx| { + this.toggle_managed_forward_form(pane_id, window, cx) + })) + .into_any_element(); - // Tunnel (port forwarding). ExternalLink is the closest network/arrows - // glyph the icon set ships — it reads as "traffic forwarded out". - let tunnel_button = Button::new(("ssh-forward-icon", pane_id)) - .icon(IconName::ExternalLink) - .ghost() - .small() - .selected(panel_open) - .tooltip("Port forwarding") - .on_click(cx.listener(move |this, _, _window, cx| { - this.toggle_loopback_forward_panel(pane_id, cx) - })); - // A tiny count badge when ≥1 forward is active; the bare icon otherwise. - let tunnel: AnyElement = if active_count > 0 { - Badge::new() - .count(active_count) - .child(tunnel_button) - .into_any_element() - } else { - tunnel_button.into_any_element() - }; - - // SFTP (file browser). Folder is the natural glyph. - let sftp_button = Button::new(("ssh-sftp-icon", pane_id)) - .icon(IconName::Folder) - .ghost() - .small() - .selected(sftp_open) - .tooltip("SFTP") - .on_click(cx.listener(move |this, _, window, cx| this.toggle_sftp(window, cx))); - - div() - .absolute() - .top_2() - .right_4() - .flex() - .flex_col() - .items_end() - .gap_2() - .child( - h_flex() - .items_center() - .gap_1() - .child(tunnel) - .child(sftp_button), - ) - .when(panel_open, |this| { - this.child(self.render_loopback_forward_panel(pane_id, remote, cx)) - }) - .text_color(foreground) - .into_any_element() - } - - /// The port-forwarding panel: a single unified forwards list plus one L/R/D add - /// form (Tabby-like). Auto forwards created by Cmd-clicking a `localhost:PORT` - /// link (FR-F4) arrive as plain Local rows in this same list. - fn render_loopback_forward_panel( - &self, - pane_id: u64, - remote: &RemoteContext, - cx: &mut Context, - ) -> Div { - let popover = cx.theme().popover; - let border = cx.theme().border; - let foreground = cx.theme().foreground; - let muted_foreground = cx.theme().muted_foreground; - let close = Button::new(("ssh-forward-panel-close", pane_id)) - .icon(IconName::Close) - .ghost() - .small() - .tooltip("Close") - .on_click(cx.listener(|this, _, _w, cx| this.close_loopback_forward_panel(cx))); - - v_flex() - .w(px(460.)) - .max_h(px(560.)) - .gap_3() - .p_3() - .overflow_hidden() - .bg(popover) - .border_1() - .border_color(border) - .rounded_lg() - .shadow_lg() - .child( - h_flex() - .items_start() - .justify_between() - .gap_3() - .child( - v_flex() - .gap_0p5() - .child( - div() - .text_sm() - .font_weight(FontWeight::MEDIUM) - .text_color(foreground) - .child("SSH forwards"), - ) - .child( - div() - .text_xs() - .text_color(muted_foreground) - .child(remote.target.clone()), - ), - ) - .child(close), - ) - .child(self.render_managed_forwards_section(pane_id, cx)) - } - - /// The single port-forwarding section for a native-SSH pane: an add form with a - /// Local/Remote/Dynamic kind selector and the live forward rows (including the - /// auto localhost-link forwards, which read as Local rows). - fn render_managed_forwards_section(&self, pane_id: u64, cx: &mut Context) -> Div { - let foreground = cx.theme().foreground; - let muted_foreground = cx.theme().muted_foreground; let managed: Vec = self .loopback_panel .managed @@ -304,35 +198,163 @@ impl Tty7App { .cloned() .collect(); - let body = if managed.is_empty() { - v_flex().child( - div() - .text_sm() - .text_color(muted_foreground) - .child("No forwards yet."), - ) - } else { - let mut list = v_flex().gap_2(); - for forward in &managed { - list = list.child(self.render_managed_forward_row(forward, cx)); - } - list - }; + let mono = cx.theme().mono_font_family.clone(); + // Rows inset themselves rather than the list, so the hover capsule bleeds + // into the same 12px gutter the Changes rows use. + let mut list = v_flex().px(px(CONTENT_INSET - 4.)).py(px(2.)).gap(px(1.)); + for forward in &managed { + list = list.child(self.forward_row(forward, &mono, cx)); + } - v_flex() - .gap_2() - .child( - div() - .text_sm() - .font_weight(FontWeight::MEDIUM) - .text_color(foreground) - .child("Port forwarding"), - ) - .child(self.render_managed_forward_form(pane_id, cx)) - .child(body) + Some( + v_flex() + .child(self.panel_subtitle("Forwards", true, Some(add), cx)) + // The empty line is suppressed while the form is open: the form + // *is* the answer to "nothing here yet". + .when(managed.is_empty() && !open, |this| { + this.child( + div() + .px(px(CONTENT_INSET)) + .py(px(2.)) + .text_size(px(12.)) + .text_color(cx.theme().muted_foreground) + .child("None."), + ) + }) + .when(!managed.is_empty(), |this| this.child(list)) + .when(open, |this| this.child(self.forward_form(pane_id, cx))) + .into_any_element(), + ) } - fn render_managed_forward_form(&self, pane_id: u64, cx: &mut Context) -> Div { + /// One forward, in the Info list's language: a mono kind letter, the bound + /// port as the same chip a listening port gets, and the destination trailing + /// it. Click to load it into the form (edit = re-establish); the `×` revealed + /// on hover tears it down. A description, where one was typed, takes a second + /// muted line — the only thing on the row that isn't derivable from the rule. + fn forward_row( + &self, + forward: &ManagedForward, + mono: &gpui::SharedString, + cx: &mut Context, + ) -> Stateful
{ + let theme = cx.theme(); + let muted = theme.muted_foreground; + let letter = match forward.kind { + SshForwardKind::Local => "L", + SshForwardKind::Remote => "R", + SshForwardKind::Dynamic => "D", + }; + let errored = matches!(forward.status, ForwardStatus::Error(_)); + // A bind host worth naming is one that isn't the loopback default — + // `0.0.0.0` means "reachable from the network", which the row must not + // hide behind a bare port number. + let bind = if matches!(forward.bind_host.as_str(), "127.0.0.1" | "localhost" | "") { + forward.bind_port.to_string() + } else { + format!("{}:{}", forward.bind_host, forward.bind_port) + }; + // The tail carries the error where there is one: an error is what you + // need to read, and the destination is still on the row you clicked from. + let tail = match &forward.status { + ForwardStatus::Error(msg) => msg.clone(), + ForwardStatus::Listening => match forward.kind { + SshForwardKind::Dynamic => "SOCKS".to_string(), + _ => format!("→ {}:{}", forward.target_host, forward.target_port), + }, + }; + let pane_id = forward.pane_id; + let forward_id = forward.id; + let forward_for_edit = forward.clone(); + let group = gpui::SharedString::from(format!("panel-forward-{forward_id}")); + + h_flex() + .id(("panel-forward", forward_id as usize)) + .group(group.clone()) + .items_center() + .gap(px(8.)) + .px(px(4.)) + .py(px(3.)) + .rounded(px(5.)) + .cursor_pointer() + .hover(|s| s.bg(theme.sidebar_accent.opacity(0.55))) + .on_click(cx.listener(move |this, _, window, cx| { + this.edit_managed_forward(forward_for_edit.clone(), window, cx) + })) + .child(crate::ui::right_panel::git_badge( + letter, + if errored { theme.danger } else { muted }, + mono, + )) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap(px(1.)) + .child( + h_flex() + .items_center() + .gap(px(6.)) + .child(crate::ui::right_panel::info_chip( + &bind, + theme.accent, + theme.foreground, + mono, + )) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(px(12.)) + .font_family(mono.clone()) + .text_color(if errored { theme.danger } else { muted }) + .child(tail), + ), + ) + .when_some(forward.description.clone(), |this, desc| { + this.child( + div() + .truncate() + .text_size(px(11.)) + .text_color(muted) + .child(desc), + ) + }), + ) + .child( + // Revealed on row hover — the same progressive disclosure the + // sidebar's rows use, so a list of forwards stays a list. + div() + .flex_shrink_0() + .opacity(0.) + .group_hover(group, |s| s.opacity(1.)) + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + crate::ui::tab_strip::chrome_tile( + Button::new(("panel-forward-del", forward_id as usize)) + .icon(IconName::Close) + .xsmall(), + false, + cx, + ) + .w(px(18.)) + .h(px(18.)) + .rounded(px(4.)) + .tooltip("Remove") + .on_click(cx.listener( + move |this, _, _window, cx| { + this.remove_managed_forward(pane_id, forward_id, cx) + }, + )), + ), + ) + } + + /// The add/edit form, inline under the band. The 460px three-column layout the + /// old popover used doesn't survive a 260px column, so the fields stack: kind, + /// then one line each for bind and target, each `host : port`. + fn forward_form(&self, pane_id: u64, cx: &mut Context) -> Div { let theme = cx.theme(); let muted = theme.muted_foreground; let kind = self.loopback_panel.mf_kind; @@ -342,28 +364,35 @@ impl Tty7App { SshForwardKind::Remote => 1, SshForwardKind::Dynamic => 2, }; - // Dynamic (SOCKS) forwards have no fixed target — grey the target inputs. + // Dynamic (SOCKS) forwards have no fixed target — grey the target line. let needs_target = kind != SshForwardKind::Dynamic; - let bind_host = div() - .w(px(150.)) - .child(Input::new(&self.loopback_panel.mf_bind_host).small()); - let bind_port = div() - .w(px(80.)) - .child(Input::new(&self.loopback_panel.mf_bind_port).small()); - let target_host = div() - .w(px(150.)) - .child(Input::new(&self.loopback_panel.mf_target_host).small()); - let target_port = div() - .w(px(80.)) - .child(Input::new(&self.loopback_panel.mf_target_port).small()); - let description = div() - .w_full() - .child(Input::new(&self.loopback_panel.mf_description).small()); + // `host : port` on one line, the port sized to four digits and the host + // taking what's left. + let pair = |label: &'static str, + host: &Entity, + port: &Entity| { + h_flex() + .items_center() + .gap(px(4.)) + .child( + div() + .flex_none() + .w(px(30.)) + .text_size(px(11.)) + .text_color(muted) + .child(label), + ) + .child(div().flex_1().min_w_0().child(Input::new(host).xsmall())) + .child(div().text_size(px(11.)).text_color(muted).child(":")) + .child(div().w(px(52.)).child(Input::new(port).xsmall())) + }; v_flex() - .gap_2() - .py_1() + .px(px(CONTENT_INSET)) + .pt(px(6.)) + .pb(px(2.)) + .gap(px(5.)) .child(self.segmented( "ssh-managed-forward-kind", &["Local", "Remote", "Dynamic"], @@ -378,133 +407,44 @@ impl Tty7App { this.set_managed_forward_kind(kind, cx); }, )) + .child(pair( + "bind", + &self.loopback_panel.mf_bind_host, + &self.loopback_panel.mf_bind_port, + )) .child( - h_flex() - .items_center() - .gap_1() - .child(div().w(px(48.)).text_xs().text_color(muted).child("bind")) - .child(bind_host) - .child(div().text_sm().text_color(muted).child(":")) - .child(bind_port), - ) - .child( - h_flex() - .items_center() - .gap_1() + div() .opacity(if needs_target { 1.0 } else { 0.4 }) - .child( - div() - .w(px(48.)) - .text_xs() - .text_color(muted) - .child(if needs_target { "target" } else { "SOCKS" }), - ) - .child(target_host) - .child(div().text_sm().text_color(muted).child(":")) - .child(target_port), + .child(pair( + if needs_target { "to" } else { "SOCKS" }, + &self.loopback_panel.mf_target_host, + &self.loopback_panel.mf_target_port, + )), ) + .child(Input::new(&self.loopback_panel.mf_description).xsmall()) .child( h_flex() - .items_center() - .gap_2() - .child(description) - .when(editing, |row| { - row.child( - Button::new(("ssh-managed-forward-cancel", pane_id)) - .label("Cancel") - .small() - .on_click(cx.listener(move |this, _, window, cx| { - this.cancel_managed_forward_edit(window, cx) - })), - ) - }) + .justify_end() + .gap(px(4.)) + .pt(px(1.)) + .child( + Button::new(("ssh-managed-forward-cancel", pane_id)) + .label("Cancel") + .ghost() + .xsmall() + .on_click(cx.listener(move |this, _, window, cx| { + this.close_managed_forward_form(window, cx) + })), + ) .child( Button::new(("ssh-managed-forward-add", pane_id)) .label(if editing { "Save" } else { "Add" }) - .small() .primary() + .xsmall() .on_click(cx.listener(move |this, _, window, cx| { this.add_managed_forward(pane_id, window, cx) })), ), ) } - - fn render_managed_forward_row(&self, forward: &ManagedForward, cx: &mut Context) -> Div { - let theme = cx.theme(); - let (badge, badge_color) = match forward.kind { - SshForwardKind::Local => ("L", theme.info), - SshForwardKind::Remote => ("R", theme.warning), - SshForwardKind::Dynamic => ("D", theme.success), - }; - let bind = format!("{}:{}", forward.bind_host, forward.bind_port); - let flow = if forward.kind == SshForwardKind::Dynamic { - format!("{bind} (SOCKS)") - } else { - format!("{bind} -> {}:{}", forward.target_host, forward.target_port) - }; - let (status_text, status_color) = match &forward.status { - ForwardStatus::Listening => ("listening".to_string(), theme.success), - ForwardStatus::Error(msg) => (format!("error: {msg}"), theme.danger), - }; - let pane_id = forward.pane_id; - let forward_id = forward.id; - let forward_for_edit = forward.clone(); - - h_flex() - .items_center() - .gap_3() - .px_3() - .py_2() - .border_1() - .border_color(theme.border) - .rounded_md() - .child( - div() - .flex_none() - .w(px(20.)) - .h(px(20.)) - .flex() - .items_center() - .justify_center() - .rounded_md() - .bg(badge_color.opacity(0.15)) - .text_xs() - .font_weight(FontWeight::BOLD) - .text_color(badge_color) - .child(badge), - ) - .child( - v_flex() - .gap_0p5() - .flex_1() - .min_w_0() - .child(div().text_sm().text_color(theme.foreground).child(flow)) - .when_some(forward.description.clone(), |el, desc| { - el.child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(desc), - ) - }) - .child(div().text_xs().text_color(status_color).child(status_text)), - ) - .child( - Button::new(("ssh-managed-forward-edit", forward_id as usize)) - .label("Edit") - .small() - .on_click(cx.listener(move |this, _, window, cx| { - this.edit_managed_forward(forward_for_edit.clone(), window, cx) - })), - ) - .child( - Button::new(("ssh-managed-forward-del", forward_id as usize)) - .label("Delete") - .small() - .on_click(cx.listener(move |this, _, _window, cx| { - this.remove_managed_forward(pane_id, forward_id, cx) - })), - ) - } } diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index c7ceab8d..2cd2c402 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -203,9 +203,12 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { // Like Terminal.app / iTerm2 / Ghostty ⌘K: wipe the screen + scrollback. ("ClearScrollback", "secondary-k"), ("OpenSettings", "secondary-,"), - // No default chord — reachable from the command palette ("SFTP Panel") and - // bindable in Settings like any other action. + // No default chord — reachable from the command palette ("SSH: Remote + // Files") and bindable in Settings like any other action. ("ToggleSftp", ""), + // No default chord either — the palette ("SSH: Port Forwarding") and the + // Info tab's own `+` are the primary ways in. + ("ShowSshForwards", ""), // The code panel (file tree + editor overlay), on VS Code's explorer // chord. ⌘⇧E is free (no existing binding or preset uses it). ("ToggleCodePanel", "secondary-shift-e"), @@ -510,6 +513,7 @@ fn make_binding(action: &str, keystroke: &str) -> Option { "ClearScrollback" => KeyBinding::new(keystroke, ClearScrollback, Some("Terminal")), "OpenSettings" => KeyBinding::new(keystroke, OpenSettings, None), "ToggleSftp" => KeyBinding::new(keystroke, ToggleSftp, None), + "ShowSshForwards" => KeyBinding::new(keystroke, ShowSshForwards, None), "ToggleCodePanel" => KeyBinding::new(keystroke, ToggleCodePanel, None), "EditorSave" => KeyBinding::new(keystroke, EditorSave, None), "OpenSshProfiles" => KeyBinding::new(keystroke, OpenSshProfiles, None), diff --git a/src/ui/palette.rs b/src/ui/palette.rs index 906c1253..5370d080 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -60,8 +60,12 @@ pub enum CommandKind { ReopenClosedTab, OpenSettings, RestartDaemon, - /// Toggle the SFTP file panel for the focused native-SSH pane (WS5). + /// Show the focused native-SSH pane's remote filesystem — the detail + /// panel's Files tab, which browses over SFTP for a remote pane (WS5). ToggleSftp, + /// Show the focused native-SSH pane's forwards in the detail panel's Info + /// tab, add form open (WS4). + ShowSshForwards, /// Toggle the code panel (file tree + editor overlay over the terminal). ToggleCodePanel, /// Reconnect a dead native-SSH pane in place (WS6, FR-E4). @@ -153,6 +157,7 @@ impl CommandKind { OpenSettings => "OpenSettings", RestartDaemon => "RestartDaemon", ToggleSftp => "ToggleSftp", + ShowSshForwards => "ShowSshForwards", ToggleCodePanel => "ToggleCodePanel", RestartSshSession => "RestartSshSession", SendSelectionToAgent @@ -253,7 +258,8 @@ impl Command { Command::new("SSH: Add Connection…", OpenSshConnectInput), Command::new("SSH: Manage Profiles…", OpenSshProfiles), Command::new("Reconnect SSH Session", RestartSshSession), - Command::new("SFTP Panel", ToggleSftp), + Command::new("SSH: Remote Files", ToggleSftp), + Command::new("SSH: Port Forwarding", ShowSshForwards), Command::new("Code Panel", ToggleCodePanel), Command::new("Change Theme…", OpenThemePicker), Command::new("Open Settings", OpenSettings), diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index aae89639..3ef2a054 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -119,6 +119,19 @@ impl Tty7App { let width = self.right_panel_px(window, cx); let tab = cx.global::().right_panel_tab; + // The remote browser follows the detail pane on *every* paint, not only + // while Files is on screen. Opening it is the Files tab's job (no point + // listing a directory nobody asked to see), but retiring it can't be: + // the transfers footer below is pane-scoped and rides under all four + // tabs, so a pane switch made from Info has to drop the old pane's + // browser too — otherwise the footer would report a transfer belonging + // to a pane you're no longer looking at. + if let Some(open) = self.sftp_panel.open_pane_id + && self.remote_files_pane(window, cx).map(|(id, _)| id) != Some(open) + { + self.sftp_close_browser(cx); + } + let body = match tab { RightPanelTab::Info => self.render_panel_info(window, cx), RightPanelTab::Outline => self.render_panel_outline(window, cx), @@ -192,6 +205,10 @@ impl Tty7App { .child(self.window_chrome(window, cx)) }) .child(body) + // The transfers footer is a sibling of the body, not part of any + // tab: an SFTP transfer belongs to the pane, so reading Info or + // Changes must not make a running upload vanish. + .children(self.sftp_transfers_footer(cx)) .child(handle) .into_any_element(), ) @@ -300,7 +317,7 @@ impl Tty7App { /// label, plus an optional live count trailing it (files, commands, changed /// files) so the header states scale at a glance, and an optional control on /// the right. The count is the quiet mono tally the sidebar group headers use. - fn panel_title( + pub(crate) fn panel_title( &self, text: &str, count: Option, @@ -371,10 +388,16 @@ impl Tty7App { .into_any_element() } - /// The Files tab's filter box — the same borderless magnifier + input the tab - /// rail uses, so the two panels search the same way. Sits under the header - /// rather than in it: it's a full-width control, not a trailing tile. - fn files_search(&self, cx: &mut Context) -> AnyElement { + /// A tab's filter box — the same borderless magnifier + input the tab rail + /// uses, so everything in the window searches the same way. Sits under the + /// header rather than in it: it's a full-width control, not a trailing tile. + /// Takes the input so the local tree and the remote browser can each keep + /// their own query while sharing the one appearance. + pub(crate) fn panel_search( + &self, + input: &gpui::Entity, + cx: &mut Context, + ) -> AnyElement { h_flex() .flex_none() .items_center() @@ -390,7 +413,7 @@ impl Tty7App { div() .flex_1() .min_w_0() - .child(Input::new(&self.file_search).appearance(false).xsmall()), + .child(Input::new(input).appearance(false).xsmall()), ) .into_any_element() } @@ -436,6 +459,10 @@ impl Tty7App { // hang off the cwd, and the two lists get their own sub-headers below. let mut cwd_for_actions: Option = None; let mut pane_id: Option = None; + // Set only for a *connected native* SSH pane — the one kind that can carry + // forwards. A foreground `ssh` typed into a local shell has no connection + // to forward over, and a still-connecting one has nothing to list yet. + let mut forwards_pane: Option = None; if let Some(tab) = self.tabs.get(self.active) { if let Some(leaf) = tab.detail_pane(window, cx) { @@ -457,6 +484,16 @@ impl Tty7App { if let Some(ssh) = view.ssh_spec() { rows.push(("ssh", ssh.host.clone())); } + if view + .remote_context() + .is_some_and(|c| c.kind == crate::daemon::protocol::RemoteKind::NativeSsh) + && matches!( + view.ssh_phase(), + Some(crate::daemon::protocol::SshPhase::Connected) + ) + { + forwards_pane = Some(view.pane_id); + } } if let Some(git) = tab.git_status(Some(window), cx) { rows.push(("branch", git.branch.clone())); @@ -477,8 +514,9 @@ impl Tty7App { } // Keep the process/port query pointed at the pane on screen, and keep it - // ticking while this tab is the one being looked at. - self.sync_procs(pane_id, cx); + // ticking while this tab is the one being looked at. The same tick carries + // the pane's forwards when it has any to carry. + self.sync_procs(pane_id, forwards_pane.is_some(), cx); let mono = cx.theme().mono_font_family.clone(); let mut list = v_flex().px(px(CONTENT_INSET)).py(px(2.)).gap(px(3.)); @@ -515,13 +553,17 @@ impl Tty7App { // Three labelled bands — Session / Processes / Ports — instead of one // flat column, so the pane's facts, what it's running, and what it's // listening on read as distinct groups. - .child(self.panel_subtitle("Session", false, cx)) + .child(self.panel_subtitle("Session", false, None, cx)) .child(list) .when_some(cwd_for_actions, |this, cwd| { this.child(self.cwd_actions(cwd, cx)) }) .children(self.procs_section(pane_id, cx)) .children(self.ports_section(pane_id, cx)) + // Ports says what this pane listens on locally; Forwards says what it + // routes across the connection. Same family of fact, so it reads as + // the band after it rather than a feature bolted on. + .children(self.forwards_section(forwards_pane, cx)) .into_any_element(); self.panel_scroll(inner, title) } @@ -581,19 +623,49 @@ impl Tty7App { /// A small-caps band label inside a tab's body, for the sub-lists that hang /// off the Info tab. Lighter than [`panel_title`], which is the tab's own /// header. `divider` draws a hairline above it, so the second and third bands - /// separate from the one before; the first band passes `false`. - fn panel_subtitle(&self, text: &str, divider: bool, cx: &mut Context) -> AnyElement { - div() + /// separate from the one before; the first band passes `false`. `trailing` + /// carries a band's own control where it has one — the same slot + /// [`panel_title`](Self::panel_title) gives a tab, so a band's `+` sits on its + /// label's line instead of earning a row. + pub(crate) fn panel_subtitle( + &self, + text: &str, + divider: bool, + trailing: Option, + cx: &mut Context, + ) -> AnyElement { + h_flex() .when(divider, |d| { d.mt(px(6.)).border_t_1().border_color(cx.theme().border) }) - .px(px(CONTENT_INSET)) - .pt(px(if divider { 12. } else { 10. })) - .pb(px(4.)) - .text_size(px(10.5)) - .font_weight(gpui::FontWeight::SEMIBOLD) - .text_color(cx.theme().muted_foreground) - .child(text.to_uppercase()) + .items_center() + .justify_between() + .pl(px(CONTENT_INSET)) + // A trailing tile aligns on its glyph, not its hit box — same + // correction the tab header makes. + .pr(px(if trailing.is_some() { + CONTENT_INSET - crate::ui::app::TILE_PAD + } else { + CONTENT_INSET + })) + // A tile is 24px tall against a ~15px label, so the band's own top + // padding would push its glyph off the label's line; give the padding + // back as a shorter lead when one is present. + .pt(px(match (divider, trailing.is_some()) { + (true, false) => 12., + (true, true) => 8., + (false, false) => 10., + (false, true) => 6., + })) + .pb(px(if trailing.is_some() { 0. } else { 4. })) + .child( + div() + .text_size(px(10.5)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(cx.theme().muted_foreground) + .child(text.to_uppercase()), + ) + .when_some(trailing, |this, t| this.child(t)) .into_any_element() } @@ -639,7 +711,7 @@ impl Tty7App { } Some( v_flex() - .child(self.panel_subtitle("Processes", true, cx)) + .child(self.panel_subtitle("Processes", true, None, cx)) .child(list) .into_any_element(), ) @@ -679,7 +751,7 @@ impl Tty7App { } Some( v_flex() - .child(self.panel_subtitle("Ports", true, cx)) + .child(self.panel_subtitle("Ports", true, None, cx)) .child(list) .into_any_element(), ) @@ -697,13 +769,24 @@ impl Tty7App { /// Point the process query at `pane_id` and make sure the poll is running. /// Called from the Info tab's render, so the loop starts when the tab is /// looked at and dies when it isn't — see [`spawn_procs_query`]. - fn sync_procs(&mut self, pane_id: Option, cx: &mut Context) { + /// + /// `forwards` asks the same tick to re-list the pane's SSH forwards. It rides + /// this loop rather than owning one because it wants the identical lifetime + /// (Info on screen, this pane) and because a forward can change state without + /// the UI touching it — a remote bind that loses its listener goes to `Error` + /// on the daemon, and only a re-list finds out. Off for a non-SSH pane, so a + /// local shell doesn't pay for a round-trip that can only answer "none". + fn sync_procs(&mut self, pane_id: Option, forwards: bool, cx: &mut Context) { let Some(pane_id) = pane_id else { return }; if self.right_panel.procs_pane != Some(pane_id) { self.right_panel.procs_pane = Some(pane_id); // Drop the previous pane's answer rather than showing it under the new // pane's heading until the first tick lands. self.right_panel.procs = None; + // Same for the forwards: the list is one pane's, and the rows filter by + // pane id anyway, so leaving the old pane's in place would only flash + // them under the new pane's band until the tick lands. + self.loopback_panel.managed.clear(); // Retire the old pane's loop and free the guard so the new pane's loop // can start below; the retired tick bows out on the generation check. self.right_panel.procs_gen += 1; @@ -712,20 +795,36 @@ impl Tty7App { if !self.right_panel.procs_loading { self.right_panel.procs_loading = true; let generation = self.right_panel.procs_gen; - self.spawn_procs_query(pane_id, generation, cx); + self.spawn_procs_query(pane_id, generation, forwards, cx); } } /// One query, then reschedule — the poll loop. It reschedules only while the /// panel is open on Info, so the loop is self-terminating: close the panel or /// switch tabs and the next completion simply doesn't queue another. - fn spawn_procs_query(&mut self, pane_id: u64, generation: u64, cx: &mut Context) { + fn spawn_procs_query( + &mut self, + pane_id: u64, + generation: u64, + forwards: bool, + cx: &mut Context, + ) { // `procs_loading` is set by the caller (`sync_procs`) and deliberately // stays set across the whole cycle, including the timer wait below. cx.spawn(async move |this, cx| { - let procs = cx + // Both round-trips on the one background hop, so the tick costs one + // scheduling slot rather than two. + let (procs, managed) = cx .background_executor() - .spawn(async move { crate::terminal::RemoteTerminal::query_procs(pane_id) }) + .spawn(async move { + let procs = crate::terminal::RemoteTerminal::query_procs(pane_id); + let managed = if forwards { + crate::terminal::RemoteTerminal::list_forwards(pane_id) + } else { + Vec::new() + }; + (procs, managed) + }) .await; let keep_polling = this .update(cx, |app, cx| { @@ -735,6 +834,9 @@ impl Tty7App { return false; } app.right_panel.procs = Some(procs); + if forwards { + app.loopback_panel.managed = managed; + } cx.notify(); let cfg = cx.global::(); let wanted = @@ -759,7 +861,7 @@ impl Tty7App { let cfg = cx.global::(); let wanted = cfg.right_panel_visible && cfg.right_panel_tab == RightPanelTab::Info; if wanted { - app.spawn_procs_query(pane_id, generation, cx); + app.spawn_procs_query(pane_id, generation, forwards, cx); } else { app.right_panel.procs_loading = false; } @@ -1098,10 +1200,22 @@ impl Tty7App { /// The project tree, reusing the code panel's rows verbatim — same expand /// state, same click-to-open, so the panel and the editor overlay are two /// views of one tree rather than two trees. + /// The Files tab follows the pane: a local pane gets its repository tree, a + /// connected native-SSH pane gets that machine's filesystem over SFTP. One tab, + /// because "the files this pane is working in" is one idea — where they + /// physically live is a property of the pane, not a second feature. fn render_panel_files(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + let remote = self.remote_files_pane(window, cx); + let host = remote.as_ref().map(|(_, host)| host.clone()); + // Point the browser at this pane, or tear it down when the tab has moved + // back to a local one. Returns whether to render the remote mode. + if self.sftp_sync_pane(remote.map(|(id, _)| id), window, cx) { + return self.render_panel_sftp(host.unwrap_or_default(), window, cx); + } + let controls = self.files_controls(cx); let title = self.panel_title("Files", None, Some(controls), cx); - let search = self.files_search(cx); + let search = self.panel_search(&self.file_search.clone(), cx); let rows = self.render_file_tree_rows(window, cx); v_flex() .flex_1() @@ -1111,12 +1225,33 @@ impl Tty7App { .child(rows) .into_any_element() } + + /// The detail pane and its host name when it's a *connected native* SSH pane — + /// the gate for the Files tab's remote mode. A foreground `ssh` typed into a + /// local shell has no connection to browse, and a still-connecting one has + /// nothing to list, so both keep the local tree. + fn remote_files_pane( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option<(u64, String)> { + use crate::daemon::protocol::{RemoteKind, SshPhase}; + let leaf = self.tabs.get(self.active)?.detail_pane(window, cx)?; + let view = leaf.read(cx); + let remote = view.remote_context()?; + if remote.kind != RemoteKind::NativeSsh + || !matches!(view.ssh_phase(), Some(SshPhase::Connected)) + { + return None; + } + Some((view.pane_id, remote.target)) + } } /// A small status letter (`M`/`U`/…) for a change row. The *kind* is told by the /// glyph in the mono face, not by colour, so the list stays monochrome; callers /// pass a muted tone and reserve real hue for the `+N −M` counts beside it. -fn git_badge(letter: &str, color: gpui::Hsla, mono: &gpui::SharedString) -> AnyElement { +pub(crate) fn git_badge(letter: &str, color: gpui::Hsla, mono: &gpui::SharedString) -> AnyElement { div() .flex_none() .w(px(14.)) @@ -1131,7 +1266,12 @@ fn git_badge(letter: &str, color: gpui::Hsla, mono: &gpui::SharedString) -> AnyE /// A pid / port pill: a mono number on the soft-grey capsule the rest of the /// chrome uses, so a numeric datum reads as a tag rather than loose text. -fn info_chip(text: &str, bg: gpui::Hsla, fg: gpui::Hsla, mono: &gpui::SharedString) -> AnyElement { +pub(crate) fn info_chip( + text: &str, + bg: gpui::Hsla, + fg: gpui::Hsla, + mono: &gpui::SharedString, +) -> AnyElement { div() .flex_none() .px(px(5.)) diff --git a/src/ui/sftp.rs b/src/ui/sftp.rs index 539abcc0..b0285d26 100644 --- a/src/ui/sftp.rs +++ b/src/ui/sftp.rs @@ -29,23 +29,30 @@ use gpui::{ }; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::{Input, InputEvent, InputState}; -use gpui_component::menu::{ContextMenuExt as _, PopupMenuItem}; +use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenuItem}; use gpui_component::{ - ActiveTheme as _, Icon, IconName, InteractiveElementExt as _, Selectable as _, Sizable as _, - h_flex, v_flex, + ActiveTheme as _, Icon, IconName, InteractiveElementExt as _, Sizable as _, h_flex, v_flex, }; use crate::daemon::protocol::{ - RemoteContext, RemoteKind, SftpEntry, SftpEntryKind, SftpJobProgress, SftpJobState, SftpOp, - SftpOpResult, SftpTransferKind, SftpTransferSpec, + SftpEntry, SftpEntryKind, SftpJobProgress, SftpJobState, SftpOp, SftpOpResult, + SftpTransferKind, SftpTransferSpec, }; use crate::daemon::ssh::sftp::{remote_basename, remote_join, remote_parent, safe_local_name}; use crate::terminal::RemoteTerminal; -use crate::ui::app::Tty7App; +use crate::ui::app::{CONTENT_INSET, Tty7App}; -/// The panel docks along the bottom of the terminal body (tabby-style) and -/// takes this fraction of its height, leaving the shell visible above. -const SFTP_PANEL_HEIGHT_FRAC: f32 = 0.7; +/// The remote Files header's `⋯` entries. The directory-wide actions that used +/// to be a row of toolbar buttons; per-row actions stay on the row's own +/// right-click menu. +#[derive(Clone, Copy)] +enum SftpMenuAction { + NewFolder, + NewFile, + Upload, + GotoShellCwd, + ToggleHistory, +} /// One in-progress inline edit form in the panel. pub(crate) enum SftpEdit { @@ -57,15 +64,28 @@ pub(crate) enum SftpEdit { }, Chmod { path: String, + /// The entry's current mode as `rwxr-xr-x`, shown beside the form's octal + /// field. The row itself no longer has room for a permissions column, so + /// this is where the readable form lives now. + readable: String, input: gpui::Entity, }, } -/// State for the SFTP side panel. One panel at a time, bound to a pane id. +/// State for the remote file browser. One pane's listing at a time, bound to a +/// pane id — the detail panel shows one pane, so the browser follows it. pub(crate) struct SftpPanelState { + /// The pane whose listing is on screen, or `None` while the Files tab is + /// showing a local tree. Set by the Files render path from the detail pane, + /// not by a toggle: the browser is a *view of the pane*, so which pane you're + /// looking at is the only thing that decides it. pub(crate) open_pane_id: Option, /// The remote directory currently listed (absolute POSIX path). pub(crate) cwd: String, + /// Where each pane was last browsing, so switching panes — or tabs — and + /// coming back lands where you left rather than back at the shell cwd. Keyed + /// by pane id and dropped with the pane. + pub(crate) cwds: std::collections::HashMap, pub(crate) entries: Vec, pub(crate) filter_input: gpui::Entity, /// Last listing error, shown in place of the list. @@ -75,9 +95,12 @@ pub(crate) struct SftpPanelState { /// Job ids the user dismissed from the tray; filtered out until a fresh /// transfer (a new id) reopens it. Cleared when the panel closes/reopens. dismissed_jobs: HashSet, - /// When set, the transfers tray is pinned open and shows the full history - /// (every job, including dismissed ones), toggled by the header button. + /// When set, the transfers footer is pinned open and shows the full history + /// (every job, including dismissed ones), toggled from the Files `⋯` menu. show_history: bool, + /// Whether the transfers footer is showing its per-job list. Collapsed by + /// default: a running transfer is a glance, not a watch. + tray_expanded: bool, /// A directory listing is in flight (the daemon round-trip runs off-thread, /// so the UI never blocks). Guards feedback while the old listing stays up. pub(crate) loading: bool, @@ -107,12 +130,14 @@ impl SftpPanelState { Self { open_pane_id: None, cwd: "/".to_string(), + cwds: std::collections::HashMap::new(), entries: Vec::new(), filter_input, error: None, jobs: Vec::new(), dismissed_jobs: HashSet::new(), show_history: false, + tray_expanded: false, loading: false, nav_gen: 0, editing: None, @@ -218,34 +243,52 @@ fn local_download_dir() -> PathBuf { // --------------------------------------------------------------------------- impl Tty7App { - /// Toggle the SFTP panel for the focused SSH pane. Every native-SSH pane has a - /// russh connection to browse over; a pane with no native connection (e.g. a - /// foreground `ssh` typed into a local shell) has nothing to list, so the - /// toggle simply doesn't open. A non-SSH focused pane closes any open panel. - pub(crate) fn toggle_sftp(&mut self, window: &mut Window, cx: &mut Context) { - let Some((pane_id, remote)) = self.active_ssh_pane(window, cx) else { - self.close_sftp_panel(cx); - return; - }; - if self.sftp_panel.open_pane_id == Some(pane_id) { - self.close_sftp_panel(cx); - return; - } - if remote.kind == RemoteKind::NativeSsh { - self.sftp_open_at(pane_id, window, cx); - } else { - // No native connection to browse (a manually-typed foreground ssh). - self.close_sftp_panel(cx); - } + /// `ToggleSftp` / the palette's "SFTP Panel": show the remote browser, which + /// means putting the detail panel on its Files tab. The tab renders the + /// browser by itself once it's looking at a native-SSH pane, so this is only + /// ever "take me there" — there is no separate panel to open. + /// + /// A pane with no native connection (a foreground `ssh` typed into a local + /// shell, or a plain PTY) has nothing to list; the Files tab shows its local + /// tree instead, which is the right answer rather than an error. + pub(crate) fn toggle_sftp(&mut self, _window: &mut Window, cx: &mut Context) { + self.set_right_panel_tab(crate::core::config::RightPanelTab::Files, cx); } - pub(crate) fn close_sftp_panel(&mut self, cx: &mut Context) { + /// Point the browser at `pane_id`, or tear it down when the Files tab has + /// moved to a local pane (`None`). Called from the Files render path, so the + /// browser's lifetime is exactly "the detail panel is showing this remote + /// pane" — no open/close state of its own to fall out of step. + /// + /// Returns `true` when the caller should render the remote browser. + pub(crate) fn sftp_sync_pane( + &mut self, + pane_id: Option, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(pane_id) = pane_id else { + if self.sftp_panel.open_pane_id.is_some() { + self.sftp_close_browser(cx); + } + return false; + }; + if self.sftp_panel.open_pane_id != Some(pane_id) { + self.sftp_open_at(pane_id, window, cx); + } + true + } + + /// Stop browsing: drop the listing and retire the poll loops. Transfers are + /// untouched — they run in the daemon and keep running; only this view of + /// them goes away. + pub(crate) fn sftp_close_browser(&mut self, cx: &mut Context) { self.sftp_panel.open_pane_id = None; + self.sftp_panel.entries.clear(); + self.sftp_panel.error = None; self.sftp_panel.editing = None; self.sftp_panel.editing_path = None; self.sftp_panel.editing_path_sub.clear(); - self.sftp_panel.dismissed_jobs.clear(); - self.sftp_panel.show_history = false; // Invalidate the poll loop. self.sftp_panel.poll_gen = self.sftp_panel.poll_gen.wrapping_add(1); cx.notify(); @@ -253,18 +296,59 @@ impl Tty7App { fn sftp_open_at(&mut self, pane_id: u64, window: &mut Window, cx: &mut Context) { self.sftp_panel.open_pane_id = Some(pane_id); + self.sftp_panel.entries.clear(); + self.sftp_panel.error = None; self.sftp_panel.editing = None; self.sftp_panel.editing_path = None; self.sftp_panel.editing_path_sub.clear(); - self.sftp_panel.dismissed_jobs.clear(); self.sftp_panel.show_history = false; - // Start at the shell's OSC-7 cwd when known, else the filesystem root. - let start = self - .pane_shell_cwd(pane_id, window, cx) - .unwrap_or_else(|| "/".to_string()); - self.sftp_navigate(start, cx); self.sftp_poll_jobs(cx); self.sftp_start_polling(cx); + + // Where this pane was last time you looked, else the shell's cwd. + if let Some(start) = self + .sftp_panel + .cwds + .get(&pane_id) + .cloned() + .or_else(|| self.pane_shell_cwd(pane_id, window, cx)) + { + self.sftp_navigate(start, cx); + return; + } + // Neither: ask the far side where "." is. The shell only reports its cwd + // when tty7's shell integration is installed on the remote — which on a + // host you just connected to it usually isn't — and `/` is a poor place + // to open a file browser. SFTP's own REALPATH resolves to the login + // directory, which is where a fresh session actually is. + self.sftp_navigate_login_dir(pane_id, cx); + } + + /// Resolve the session's login directory (`realpath "."`) and open there. + /// Falls back to `/` when the round-trip fails — a browser at the root still + /// works, and the error would be noise on a path nobody typed. + fn sftp_navigate_login_dir(&mut self, pane_id: u64, cx: &mut Context) { + self.sftp_panel.loading = true; + cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { + RemoteTerminal::sftp_op(pane_id, SftpOp::Realpath { path: ".".into() }) + }) + .await; + let _ = this.update(cx, |this, cx| { + // The browser may have moved on (pane switch, or the user typed a + // path) while the round-trip was out. + if this.sftp_panel.open_pane_id != Some(pane_id) { + return; + } + let home = match result { + SftpOpResult::Link(path) if path.starts_with('/') => path, + _ => "/".to_string(), + }; + this.sftp_navigate(home, cx); + }); + }) + .detach(); } /// The focused pane's OSC-7 cwd as an absolute remote path, if tracked. @@ -315,6 +399,11 @@ impl Tty7App { match result { Ok(mut entries) => { entries.sort_by(|a, b| a.name.cmp(&b.name)); + // Remember where this pane got to, so coming back to it + // resumes rather than restarts. Recorded on arrival, not + // on the way out: only a directory that actually listed is + // worth returning to. + this.sftp_panel.cwds.insert(pane_id, path.clone()); this.sftp_panel.cwd = path; this.sftp_panel.entries = entries; this.sftp_panel.error = None; @@ -571,9 +660,14 @@ impl Tty7App { cx: &mut Context, ) { let octal = format!("{:o}", entry.permissions & 0o777); + let readable = mode_string(entry.permissions); let path = remote_join(&self.sftp_panel.cwd, &entry.name); let input = cx.new(|cx| InputState::new(window, cx).default_value(octal)); - self.sftp_panel.editing = Some(SftpEdit::Chmod { path, input }); + self.sftp_panel.editing = Some(SftpEdit::Chmod { + path, + readable, + input, + }); cx.notify(); } @@ -617,7 +711,7 @@ impl Tty7App { to: remote_join(&self.sftp_panel.cwd, &name), }) } - Some(SftpEdit::Chmod { path, input }) => { + Some(SftpEdit::Chmod { path, input, .. }) => { match u32::from_str_radix(input.read(cx).value().trim(), 8) { Ok(mode) => Some(SftpOp::Chmod { path: path.clone(), @@ -707,6 +801,13 @@ impl Tty7App { cx.notify(); } + /// Expand/collapse the transfers footer's per-job list (clicking its summary + /// line). History mode forces it open, so this only bites outside history. + pub(crate) fn sftp_toggle_tray(&mut self, cx: &mut Context) { + self.sftp_panel.tray_expanded = !self.sftp_panel.tray_expanded; + cx.notify(); + } + /// Close the transfers tray: leave the history view and hide every /// currently-known job. A later transfer (a new job id) reopens the auto-tray. pub(crate) fn sftp_dismiss_tray(&mut self, cx: &mut Context) { @@ -778,159 +879,160 @@ impl Tty7App { // Rendering. // --------------------------------------------------------------------- - /// The bottom-docked SFTP panel (tabby-style), mounted over the lower part of - /// the terminal body when open for `pane_id`. Returns `None` when not open for - /// this pane. - pub(crate) fn render_sftp_overlay( - &self, - pane_id: u64, - remote: &RemoteContext, - _window: &Window, + /// The Files tab's remote mode: the pane's SFTP browser, rendered as the + /// panel's own column rather than the bottom dock it used to be. Same + /// interaction as before — a breadcrumb you can type into, a filter, a + /// dir-first list led by `..`, per-row right-click actions — relaid out for a + /// ~260px column: the toolbar collapses to a refresh tile plus a `⋯`, and the + /// permissions column goes (it's still on the right-click `chmod…`), because + /// name + size + mode can't share this width without all three truncating. + /// + /// `host` names the machine in the header's count slot. It earns that slot: + /// this tab silently swaps between a local tree and a remote filesystem as the + /// detail pane changes, and the list carries rename and delete — so which + /// machine you're deleting on is not something to leave implicit. + pub(crate) fn render_panel_sftp( + &mut self, + host: String, + _window: &mut Window, cx: &mut Context, - ) -> Option { - if self.sftp_panel.open_pane_id != Some(pane_id) { - return None; - } - // Only native-SSH panes open the browser (see `toggle_sftp`); nothing else - // reaches here with the panel open. - if remote.kind != RemoteKind::NativeSsh { - return None; - } - let popover = cx.theme().popover; - let border = cx.theme().border; - - let panel = v_flex() - .id("sftp-panel") - .absolute() - .left_0() - .right_0() - .bottom_0() - .h(gpui::relative(SFTP_PANEL_HEIGHT_FRAC)) - .bg(popover) - .border_t_1() - .border_color(border) - .shadow_lg() - // The panel sits over the terminal body (a sibling), which has its own - // right-click menu. Occlude so clicks — especially the row right-click — - // don't also fall through and pop the terminal's context menu. - .occlude() - .child(self.render_sftp_header(pane_id, cx)) - .when_some(self.render_sftp_edit_form(cx), |this, form| { - this.child(form) - }) - .child(self.render_sftp_list(cx)) - .when_some(self.render_sftp_tray(cx), |this, tray| this.child(tray)) - // FR-T5: a Finder drop uploads onto the current directory. - .on_drop(cx.listener(|this, paths: &ExternalPaths, _window, cx| { - this.sftp_upload_paths(paths.paths().to_vec(), cx); - })); - - Some(panel.into_any_element()) - } - - /// The panel header: a single row with the breadcrumb path (left, growing) - /// and a light, borderless action cluster (right), over an always-visible - /// search box. Kept deliberately compact — the breadcrumb root reads `SFTP`, - /// so there's no separate redundant title. - fn render_sftp_header(&self, pane_id: u64, cx: &mut Context) -> Div { - let border = cx.theme().border; - let muted = cx.theme().muted_foreground; - - // Ghost icon buttons (with tooltips) read as a light toolbar rather than a - // row of heavy labelled pills. - let actions = h_flex() - .flex_none() - .items_center() - .gap_0p5() - .child( - Button::new("sftp-refresh") - .icon(IconName::LoaderCircle) - .ghost() - .small() - .tooltip("Refresh") - .on_click(cx.listener(|this, _, _w, cx| this.sftp_refresh(cx))), - ) - .child( - Button::new("sftp-newfolder") - .icon(IconName::FolderClosed) - .ghost() - .small() - .tooltip("New folder") - .on_click( - cx.listener(|this, _, window, cx| this.sftp_begin_new_folder(window, cx)), - ), - ) - .child( - Button::new("sftp-newfile") - .icon(IconName::File) - .ghost() - .small() - .tooltip("New file") - .on_click( - cx.listener(|this, _, window, cx| this.sftp_begin_new_file(window, cx)), - ), - ) - .child( - Button::new("sftp-upload") - .icon(IconName::ArrowUp) - .ghost() - .small() - .tooltip("Upload") - .on_click(cx.listener(|this, _, _w, cx| this.sftp_pick_upload(cx))), - ) - .child( - Button::new("sftp-history") - .icon(IconName::Inbox) - .ghost() - .small() - .selected(self.sftp_panel.show_history) - .tooltip("Transfers") - .on_click(cx.listener(|this, _, _w, cx| this.sftp_toggle_history(cx))), - ) - .child( - Button::new(("sftp-close", pane_id)) - .icon(IconName::Close) - .ghost() - .small() - .tooltip("Close") - .on_click(cx.listener(|this, _, _w, cx| this.close_sftp_panel(cx))), - ); - - let top = h_flex() - .items_center() - .gap_2() - .px_3() - .py_1p5() - .child( - div() - .flex_1() - .min_w_0() - .child(self.render_sftp_breadcrumb(cx)), - ) - .child(actions); - - // Always-visible search box with a leading magnifier; Esc clears it. - let search = h_flex() - .id("sftp-search") - .px_3() - .pb_2() - .child( - Input::new(&self.sftp_panel.filter_input) - .small() - .cleanable(true) - .prefix(Icon::new(IconName::Search).small().text_color(muted)), - ) + ) -> AnyElement { + let controls = self.sftp_controls(cx); + let title = self.panel_title("Files", Some(host), Some(controls), cx); + let breadcrumb = self.render_sftp_breadcrumb(cx); + // The shared panel search box, plus the one behaviour the old SFTP header + // had that the local tree's doesn't: Esc clears the filter rather than + // falling through to the terminal. + let filter = div() + .id("panel-sftp-filter") + .child(self.panel_search(&self.sftp_panel.filter_input.clone(), cx)) .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| { if ev.keystroke.key == "escape" { this.sftp_clear_filter(window, cx); } })); + let form = self.render_sftp_edit_form(cx); + let list = self.render_sftp_list(cx); v_flex() - .border_b_1() - .border_color(border) - .child(top) - .child(search) + .id("panel-sftp") + .flex_1() + .min_h_0() + .child(title) + .child(breadcrumb) + .child(filter) + .children(form) + .child(list) + // FR-T5: a Finder drop uploads onto the current directory. + .on_drop(cx.listener(|this, paths: &ExternalPaths, _window, cx| { + this.sftp_upload_paths(paths.paths().to_vec(), cx); + })) + .into_any_element() + } + + /// The remote Files header's controls: refresh, and a `⋯` for everything that + /// isn't a per-row action. Two tiles is what the header has room for beside a + /// hostname, and refresh is the one that earns a permanent slot — a remote + /// listing has no watcher behind it, so it's the only way to see a change + /// somebody else made. + fn sftp_controls(&self, cx: &mut Context) -> AnyElement { + let history = self.sftp_panel.show_history; + let tile = |button: Button, selected: bool, cx: &mut Context| { + crate::ui::tab_strip::chrome_tile(button, selected, cx) + .xsmall() + .w(px(24.)) + .h(px(24.)) + .rounded_md() + }; + + h_flex() + .items_center() + .gap(px(2.)) + .child( + tile( + Button::new("panel-sftp-refresh") + .icon(Icon::empty().path("icons/refresh.svg").size(px(13.))), + false, + cx, + ) + .tooltip("Refresh") + .on_click(cx.listener(|this, _, _w, cx| this.sftp_refresh(cx))), + ) + .child( + div().occlude().child( + tile( + Button::new("panel-sftp-menu") + .icon(Icon::empty().path("icons/ellipsis.svg").size(px(13.))), + false, + cx, + ) + .tooltip("More") + .dropdown_menu_with_anchor(gpui::Anchor::TopRight, { + let app = cx.entity().downgrade(); + move |menu, _window, _cx| { + let mut menu = menu.min_w(px(190.)); + for (label, action) in [ + ("New folder", SftpMenuAction::NewFolder), + ("New file", SftpMenuAction::NewFile), + ("Upload…", SftpMenuAction::Upload), + ("Go to shell directory", SftpMenuAction::GotoShellCwd), + ] { + menu = menu.item(PopupMenuItem::new(label).on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| { + this.sftp_menu_action(action, window, cx) + }); + } + })); + } + menu.separator().item( + PopupMenuItem::new(if history { + "Hide transfer history" + } else { + "Transfer history" + }) + .on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| { + this.sftp_menu_action( + SftpMenuAction::ToggleHistory, + window, + cx, + ) + }); + } + }), + ) + } + }), + ), + ) + .into_any_element() + } + + /// One arm per `⋯` entry. A single dispatcher rather than five closures each + /// re-deriving the weak handle, since the menu items all need `&mut Window`. + fn sftp_menu_action( + &mut self, + action: SftpMenuAction, + window: &mut Window, + cx: &mut Context, + ) { + match action { + SftpMenuAction::NewFolder => self.sftp_begin_new_folder(window, cx), + SftpMenuAction::NewFile => self.sftp_begin_new_file(window, cx), + SftpMenuAction::Upload => self.sftp_pick_upload(cx), + SftpMenuAction::GotoShellCwd => { + if let Some(pane_id) = self.sftp_panel.open_pane_id + && let Some(cwd) = self.pane_shell_cwd(pane_id, window, cx) + { + self.sftp_navigate(cwd, cx); + } + } + SftpMenuAction::ToggleHistory => self.sftp_toggle_history(cx), + } } /// The path bar. Normally a clickable breadcrumb (root shown as `SFTP`, like @@ -941,9 +1043,9 @@ impl Tty7App { if let Some(input) = &self.sftp_panel.editing_path { return h_flex() .id("sftp-path-edit") - .px_2() - .py_1() - .child(Input::new(input).small()) + .px(px(CONTENT_INSET)) + .pb(px(2.)) + .child(Input::new(input).xsmall()) // Esc cancels back to the breadcrumb (blur also cancels, via the // input subscription). .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, _window, cx| { @@ -961,13 +1063,15 @@ impl Tty7App { .flex_wrap() .items_center() .gap_0p5() - .px_2() - .py_1() + .px(px(CONTENT_INSET)) + .pb(px(4.)) .on_double_click( cx.listener(|this, _, window, cx| this.sftp_begin_edit_path(window, cx)), ); - // Root: labelled "SFTP", navigates to "/". The current (last) segment reads - // in full ink; ancestors are muted but still clearly legible (the theme + // Root: `/`, the actual path — the header above already says which machine + // this is, so the old "SFTP" label would be naming the protocol in the one + // place the user is reading a path. The current (last) segment reads in + // full ink; ancestors are muted but still clearly legible (the theme // `accent` was near-invisible here). let segments = breadcrumb_segments(&self.sftp_panel.cwd); let last = segments.len().saturating_sub(1); @@ -976,7 +1080,7 @@ impl Tty7App { row = row.child(div().text_xs().text_color(muted).child("›")); } let is_current = i == last; - let label = if i == 0 { "SFTP".to_string() } else { label }; + let label = if i == 0 { "/".to_string() } else { label }; let weight = if i == 0 || is_current { FontWeight::MEDIUM } else { @@ -1007,17 +1111,20 @@ impl Tty7App { let secondary = cx.theme().secondary; let border = cx.theme().border; let foreground = cx.theme().foreground; - let (title, input) = match self.sftp_panel.editing.as_ref()? { - SftpEdit::NewFolder(input) => ("New folder", input), - SftpEdit::NewFile(input) => ("New file", input), - SftpEdit::Rename { input, .. } => ("Rename", input), - SftpEdit::Chmod { input, .. } => ("Permissions (octal)", input), + let (title, input): (String, _) = match self.sftp_panel.editing.as_ref()? { + SftpEdit::NewFolder(input) => ("New folder".to_string(), input), + SftpEdit::NewFile(input) => ("New file".to_string(), input), + SftpEdit::Rename { input, .. } => ("Rename".to_string(), input), + SftpEdit::Chmod { + readable, input, .. + } => (format!("Permissions · {readable}"), input), }; Some( v_flex() - .gap_2() - .m_2() - .p_2() + .gap(px(5.)) + .mx(px(CONTENT_INSET - 4.)) + .mb(px(4.)) + .p(px(6.)) .bg(secondary) .border_1() .border_color(border) @@ -1029,21 +1136,22 @@ impl Tty7App { .text_color(foreground) .child(title), ) - .child(Input::new(input).small()) + .child(Input::new(input).xsmall()) .child( h_flex() - .gap_2() + .gap(px(4.)) .justify_end() .child( Button::new("sftp-edit-cancel") .label("Cancel") - .small() + .ghost() + .xsmall() .on_click(cx.listener(|this, _, _w, cx| this.sftp_cancel_edit(cx))), ) .child( Button::new("sftp-edit-ok") .label("OK") - .small() + .xsmall() .primary() .on_click(cx.listener(|this, _, _w, cx| this.sftp_commit_edit(cx))), ), @@ -1054,15 +1162,27 @@ impl Tty7App { fn render_sftp_list(&self, cx: &mut Context) -> Stateful
{ let danger = cx.theme().danger; let muted = cx.theme().muted_foreground; + // Rows inset themselves so their hover capsule bleeds into the gutter, the + // same way the local tree's and the Changes list's do. let container = div() .id("sftp-list") .flex_1() .min_h_0() .overflow_y_scroll() - .px_1(); + .px(px(CONTENT_INSET - 6.)) + .pb(px(4.)); + + let note = |text: gpui::SharedString, color| { + div() + .px(px(6.)) + .py(px(4.)) + .text_size(px(12.)) + .text_color(color) + .child(text) + }; if let Some(err) = &self.sftp_panel.error { - return container.child(div().p_3().text_xs().text_color(danger).child(err.clone())); + return container.child(note(err.clone().into(), danger)); } let filter = self.sftp_panel.filter_input.read(cx).value().to_string(); @@ -1075,15 +1195,15 @@ impl Tty7App { if entries.is_empty() && !show_go_up { // Distinguish "still loading" from a genuinely empty directory so a // slow listing doesn't read as empty. - let note = if self.sftp_panel.loading { + let text = if self.sftp_panel.loading { "Loading…" } else { "Empty directory." }; - return container.child(div().p_3().text_xs().text_color(muted).child(note)); + return container.child(note(text.into(), muted)); } - let mut list = v_flex().gap_0p5().py_1(); + let mut list = v_flex().gap(px(1.)).py(px(2.)); if show_go_up { list = list.child(self.render_sftp_go_up_row(cx)); } @@ -1098,31 +1218,32 @@ impl Tty7App { /// "the parent folder" and matches the rows below rather than a toolbar action. fn render_sftp_go_up_row(&self, cx: &mut Context) -> AnyElement { let foreground = cx.theme().foreground; - let list_hover = cx.theme().list_hover; h_flex() .id("sftp-go-up") .items_center() - .gap_2() - .px_3() + .gap_1() + .pl(px(6.)) + .pr_1() .py_1() - .rounded_md() + .rounded(cx.theme().radius) .cursor_pointer() - .hover(|s| s.bg(list_hover)) - .child(Icon::new(IconName::Folder).small().text_color(foreground)) + .hover(|s| s.bg(cx.theme().accent.opacity(0.5))) .child( - div() - .flex_1() - .min_w_0() - .text_sm() - .text_color(foreground) - .child(".."), + Icon::new(IconName::FolderOpen) + .xsmall() + .text_color(foreground), ) + .child(div().flex_1().min_w_0().text_sm().child("..")) .on_click(cx.listener(|this, _, _w, cx| this.sftp_up(cx))) .into_any_element() } - /// One entry row: icon + name (+ a `→` marker for symlinks) + a muted - /// size/mode column. Per-row actions (open/download, follow, rename, chmod, + /// One entry row: icon + name (+ a `→` marker for symlinks) + a muted size. + /// The permissions column the bottom dock had is gone — at panel width, name, + /// size and mode all three truncated, and mode is a specialist datum that the + /// row's `chmod…` still reads out on demand. + /// + /// Per-row actions (open/download, follow, rename, chmod, /// delete) live in the right-click context menu built by /// [`sftp_row_context_menu`](Self::sftp_row_context_menu) rather than as a /// row of inline buttons (PRD §6.3: hotkeys + right-click, not a permanent @@ -1165,10 +1286,11 @@ impl Tty7App { h_flex() .id(row_id) .items_center() - .gap_2() - .px_3() + .gap_1() + .pl(px(6.)) + .pr_1() .py_1() - .rounded_md() + .rounded(cx.theme().radius) .cursor_pointer() .hover(|s| s.bg(list_hover)) // Double-click enters a directory; files never download from a click @@ -1178,7 +1300,7 @@ impl Tty7App { ) .child( Icon::new(icon) - .small() + .xsmall() .text_color(if dir_like { dir_color } else { muted }), ) .child( @@ -1190,31 +1312,10 @@ impl Tty7App { .truncate() .child(name_label), ) - // Right-hand metadata: size then mode, each in its own fixed, - // right-aligned column so they line up down the list. - .child( - h_flex() - .flex_none() - .items_center() - .gap_5() - .child( - h_flex() - .w(px(56.)) - .justify_end() - .child(div().text_xs().text_color(muted).child(size)), - ) - .when(entry.permissions != 0, |this| { - this.child( - h_flex().w(px(88.)).justify_end().child( - div() - .text_xs() - .font_family("monospace") - .text_color(muted) - .child(mode_string(entry.permissions)), - ), - ) - }), - ) + // Size trails the name, right-aligned in its own column so the sizes + // line up down the list. Directories contribute an empty string, so + // the column simply doesn't draw for them. + .child(div().flex_none().text_xs().text_color(muted).child(size)) .context_menu(move |menu, _window, cx| { let danger = cx.theme().danger; Self::sftp_row_context_menu(menu, &menu_entry, dir_like, is_symlink, danger, &app) @@ -1294,10 +1395,27 @@ impl Tty7App { ) } - /// The bottom transfer tray. Shows in "auto" mode whenever there are - /// non-dismissed jobs; the header Transfers button pins it open in history - /// mode, where it lists every job (dismissed or not) and stays up even empty. - fn render_sftp_tray(&self, cx: &mut Context) -> Option
{ + /// The transfers footer, pinned to the bottom of the detail panel across all + /// four of its tabs rather than living inside Files. + /// + /// That placement is deliberate: a transfer belongs to the *pane*, not to the + /// tab you happen to be reading, so going to Info to check a port shouldn't + /// make a running upload disappear. It stays pane-scoped for the same reason — + /// aggregating every pane's jobs would quietly turn the panel into a + /// window-level transfer centre, which is not what this column is. + /// + /// Nothing is lost when it goes away: the jobs live in the daemon, keyed by + /// pane (`sftp_transfer_list`), so switching panes and coming back re-queries + /// them intact. + /// + /// Collapsed by default — one line summarising the run, with its own progress + /// underline — because a transfer is something you glance at, not something + /// you watch. Clicking the line expands the per-job list. + pub(crate) fn sftp_transfers_footer(&self, cx: &mut Context) -> Option { + // Only the pane the panel is showing. `open_pane_id` is set by the Files + // tab, so a transfer started there stays visible from any tab — but only + // while that pane is the one on screen. + self.sftp_panel.open_pane_id?; let history = self.sftp_panel.show_history; let jobs: Vec<&SftpJobProgress> = self .sftp_panel @@ -1305,60 +1423,145 @@ impl Tty7App { .iter() .filter(|j| history || !self.sftp_panel.dismissed_jobs.contains(&j.job_id)) .collect(); - // Auto mode with nothing to show → hide. History mode stays open (with an - // empty-state note) so the button always reveals a panel. + // Auto mode with nothing to show → no footer at all. History mode stays up + // (with an empty-state note) so the menu item always reveals something. if jobs.is_empty() && !history { return None; } - let border = cx.theme().border; - let secondary = cx.theme().secondary; - let muted = cx.theme().muted_foreground; - let body = if jobs.is_empty() { - v_flex().child( - div() - .py_1() - .text_xs() - .text_color(muted) - .child("No transfers yet."), - ) + // Colours copied out rather than held as a `theme` binding: the expanded + // body below needs `&mut cx` for its rows, which an outstanding theme + // borrow would block. + let muted = cx.theme().muted_foreground; + let danger = cx.theme().danger; + let accent = cx.theme().accent; + let border = cx.theme().border; + let sidebar = cx.theme().sidebar; + let hover = cx.theme().sidebar_accent.opacity(0.4); + let expanded = self.sftp_panel.tray_expanded || history; + + // The summary line: how many are moving and how far along the run is, as + // one number. Bytes across jobs, not a mean of percentages, so a big file + // beside a small one doesn't read as half done the moment the small one is. + let running = jobs + .iter() + .filter(|j| matches!(j.state, SftpJobState::Running)) + .count(); + let (done, total): (u64, u64) = jobs + .iter() + .filter(|j| matches!(j.state, SftpJobState::Running)) + .fold((0, 0), |(d, t), j| (d + j.bytes_done, t + j.bytes_total)); + let failed = jobs + .iter() + .filter(|j| matches!(j.state, SftpJobState::Error)) + .count(); + let pct = if total > 0 { + ((done as f64 / total as f64) * 100.0).min(100.0) } else { - let mut list = v_flex().gap_1(); - for job in jobs { - list = list.child(self.render_sftp_job(job, cx)); - } - list + 0.0 }; - Some( - v_flex() - .gap_1() - .p_2() - .border_t_1() - .border_color(border) - .bg(secondary) - .child( - h_flex() - .items_center() - .justify_between() - .child( - div() - .text_xs() - .font_weight(FontWeight::MEDIUM) - .text_color(muted) - .child("Transfers"), - ) - .child( + let summary = if running > 0 { + format!("{running} transferring · {pct:.0}%") + } else if failed > 0 { + format!("{failed} failed") + } else { + "Transfers".to_string() + }; + let summary_color = if running == 0 && failed > 0 { + danger + } else { + muted + }; + + let head = h_flex() + .id("sftp-transfers-summary") + .items_center() + .gap(px(6.)) + .px(px(CONTENT_INSET)) + .h(px(28.)) + .cursor_pointer() + .hover(move |s| s.bg(hover)) + .on_click(cx.listener(|this, _, _w, cx| this.sftp_toggle_tray(cx))) + .child( + div() + .text_size(px(11.)) + .text_color(muted) + .child(if expanded { "⌄" } else { "›" }), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(px(11.5)) + .text_color(summary_color) + .child(summary), + ) + .child( + div() + .flex_none() + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + crate::ui::tab_strip::chrome_tile( Button::new("sftp-tray-close") .icon(IconName::Close) - .ghost() - .xsmall() - .tooltip("Close") - .on_click( - cx.listener(|this, _, _w, cx| this.sftp_dismiss_tray(cx)), - ), - ), + .xsmall(), + false, + cx, + ) + .w(px(18.)) + .h(px(18.)) + .rounded(px(4.)) + .tooltip("Dismiss") + .on_click(cx.listener(|this, _, _w, cx| this.sftp_dismiss_tray(cx))), + ), + ); + + // The collapsed bar carries the run's progress as a hairline along its own + // bottom edge, so "how far along" survives the collapse. + let underline = div().h(px(2.)).w_full().bg(border).child( + div() + .h_full() + .w(gpui::relative((pct / 100.0) as f32)) + .bg(if failed > 0 { danger } else { accent }), + ); + + let body = expanded.then(|| { + let inner: Div = if jobs.is_empty() { + v_flex().child( + div() + .px(px(CONTENT_INSET)) + .py(px(3.)) + .text_size(px(11.5)) + .text_color(muted) + .child("No transfers yet."), ) - .child(body), + } else { + let mut list = v_flex().px(px(CONTENT_INSET)).pb(px(6.)).gap(px(6.)); + for job in jobs { + list = list.child(self.render_sftp_job(job, cx)); + } + list + }; + div() + .id("sftp-transfers-list") + // Never more than a third of the column: the footer reports on the + // panel, it doesn't become it. + .max_h(px(200.)) + .overflow_y_scroll() + .child(inner) + }); + + Some( + v_flex() + .flex_none() + .border_t_1() + .border_color(border) + .bg(sidebar) + .child(head) + .when(running > 0 && !expanded, |this| this.child(underline)) + .children(body) + .into_any_element(), ) }