{
+ 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(),
)
}