feat(ssh): fold port forwarding and SFTP into the detail panel

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.
This commit is contained in:
l0ng-ai
2026-07-25 11:25:06 +08:00
parent fd3de8a3ed
commit 0ebe68a453
11 changed files with 1033 additions and 687 deletions
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M21.3 12a9.3 9.3 0 1 1-2.72-6.58"/><path d="M21.3 2.7v5.2h-5.2"/></svg>

After

Width:  |  Height:  |  Size: 264 B

+6 -1
View File
@@ -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,
+15 -1
View File
@@ -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,
+7
View File
@@ -632,6 +632,13 @@ async fn run_op(sftp: &SftpSession, op: &SftpOp) -> Result<SftpOpResult, String>
.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)
}
})
}
+65 -45
View File
@@ -323,9 +323,13 @@ pub(crate) struct Renaming {
}
pub(crate) struct LoopbackForwardPanelState {
pub(crate) open_pane_id: Option<u64>,
/// 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<u64>,
/// 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<crate::daemon::protocol::ManagedForward>,
/// 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<Self>) {
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<Self>) {
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>) {
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<Self>,
) {
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>,
) {
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::<Config>().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)
}))
+6
View File
@@ -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"),
+242 -302
View File
@@ -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<u64>,
cx: &mut Context<Self>,
) -> 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<AnyElement> {
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<Self>,
) -> 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<Self>) -> Div {
let foreground = cx.theme().foreground;
let muted_foreground = cx.theme().muted_foreground;
let managed: Vec<ManagedForward> = 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<Self>) -> 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<Self>,
) -> Stateful<Div> {
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<Self>) -> 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<gpui_component::input::InputState>,
port: &Entity<gpui_component::input::InputState>| {
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<Self>) -> 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)
})),
)
}
}
+6 -2
View File
@@ -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<KeyBinding> {
"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),
+8 -2
View File
@@ -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),
+170 -30
View File
@@ -119,6 +119,19 @@ impl Tty7App {
let width = self.right_panel_px(window, cx);
let tab = cx.global::<Config>().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<String>,
@@ -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<Self>) -> 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<gpui_component::input::InputState>,
cx: &mut Context<Self>,
) -> 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<PathBuf> = None;
let mut pane_id: Option<u64> = 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<u64> = 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<Self>) -> 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<AnyElement>,
cx: &mut Context<Self>,
) -> 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<u64>, cx: &mut Context<Self>) {
///
/// `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<u64>, forwards: bool, cx: &mut Context<Self>) {
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<Self>) {
fn spawn_procs_query(
&mut self,
pane_id: u64,
generation: u64,
forwards: bool,
cx: &mut Context<Self>,
) {
// `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::<Config>();
let wanted =
@@ -759,7 +861,7 @@ impl Tty7App {
let cfg = cx.global::<Config>();
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<Self>) -> 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<Self>,
) -> 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.))
+507 -304
View File
File diff suppressed because it is too large Load Diff