mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
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:
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}))
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user