From 7606c19a5112cf69b73c213814802f2b29cca010 Mon Sep 17 00:00:00 2001 From: ayamir <61657399+ayamir@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:55:44 +0800 Subject: [PATCH] fix(ssh): move forwards to pane context (#71) * feat(ssh): add palette SSH connection entry * fix(ssh): clarify add connection placeholder * fix(ssh): move forwards to pane context * fix(ssh): show host in forward panel * fix(ssh): open forwarded local links * fix(ssh): simplify forward form label --- src/daemon/protocol.rs | 2 +- src/terminal/view.rs | 6 +- src/ui/app.rs | 397 +++++++++++++++++++++++------------------ src/ui/forwards.rs | 326 +++++++++++++++++++++++++++++++++ src/ui/mod.rs | 1 + src/ui/palette.rs | 89 ++++++++- src/ui/settings.rs | 348 +----------------------------------- 7 files changed, 640 insertions(+), 529 deletions(-) create mode 100644 src/ui/forwards.rs diff --git a/src/daemon/protocol.rs b/src/daemon/protocol.rs index 1e9e7727..cfb7b353 100644 --- a/src/daemon/protocol.rs +++ b/src/daemon/protocol.rs @@ -203,7 +203,7 @@ fn managed_ssh_option_is_blocked(name: &str) -> bool { ) } -fn ssh_option_takes_value(flag: char) -> bool { +pub(crate) fn ssh_option_takes_value(flag: char) -> bool { matches!( flag, 'B' | 'b' diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 10ca568d..6ab795f5 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -29,7 +29,7 @@ use crate::core::actions::{ CloseActiveTab, NewTab, SendBackTab, SendTab, SplitDown, SplitRight, ToggleMaximizePane, }; use crate::core::config::{Config, NotifyMode}; -use crate::daemon::protocol::ShellSpec; +use crate::daemon::protocol::{RemoteContext, ShellSpec}; /// Inset (px) between the terminal-surface edge and the cell grid. The prompt /// editor and the floating completion / history menus are absolutely positioned @@ -824,6 +824,10 @@ impl TerminalView { self.terminal.foreground_cwd() } + pub fn remote_context(&self) -> Option { + self.terminal.remote_context() + } + /// The shell this pane was explicitly spawned with (new-tab dropdown pick), /// so splits can inherit it. `None` → the default shell. pub fn shell_spec(&self) -> Option { diff --git a/src/ui/app.rs b/src/ui/app.rs index 5e4a690a..60a362e8 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1,10 +1,7 @@ //! The window shell: a transparent unified title bar carrying the tab strip, //! with the active terminal filling the rest. Owns all tabs (each its own PTY). -use gpui::{ - App, Axis, ClipboardItem, Context, Entity, PromptLevel, Subscription, Window, div, prelude::*, - px, -}; +use gpui::{App, Axis, Context, Entity, PromptLevel, Subscription, Window, div, prelude::*, px}; use gpui_component::color_picker::{ColorPickerEvent, ColorPickerState}; use gpui_component::input::{InputEvent, InputState}; use gpui_component::select::{SearchableVec, SelectEvent, SelectState}; @@ -17,7 +14,10 @@ use crate::core::config::{Config, NewTabPosition, ShellConfig}; use crate::core::session::{Session, SessionAxis, SessionPane, SessionTab}; use crate::core::shells::DetectedShell; use crate::core::ssh_config; -use crate::daemon::protocol::{ShellSpec, SshSpec}; +use crate::daemon::protocol::{ + LoopbackForwardId, LoopbackForwardInfo, RemoteContext, ShellSpec, SshSpec, + ssh_option_takes_value, +}; use crate::terminal::view::{ChildExited, TerminalView}; use crate::ui::palette::{Command, CommandKind, PaletteEvent, PaletteView}; use crate::ui::pane::{CloseOutcome, Dir, Pane}; @@ -119,6 +119,14 @@ pub(crate) struct Renaming { _subs: Vec, } +pub(crate) struct LoopbackForwardPanelState { + pub(crate) open_pane_id: Option, + pub(crate) forwards: Vec, + pub(crate) host_input: Entity, + pub(crate) port_input: Entity, + pub(crate) editing: Option, +} + pub struct Tty7App { /// The open tabs; each owns a split-pane tree and an optional name. pub(crate) tabs: Vec, @@ -188,6 +196,10 @@ pub struct Tty7App { /// the "+" dropdown. Probed once at startup off the UI thread — empty until /// that lands, when the dropdown offers just the default entry. pub(crate) detected_shells: Vec, + /// Pane-contextual SSH loopback forward UI state. The controls render only + /// over the active SSH pane, but the input/editing state is app-owned so it + /// is not tied to the Settings tab. + pub(crate) loopback_panel: LoopbackForwardPanelState, } impl Tty7App { @@ -213,6 +225,13 @@ impl Tty7App { let font_family_bold = cx.global::().font_family_bold.clone(); let font_family_italic = cx.global::().font_family_italic.clone(); let font_features = cx.global::().font_features.clone(); + let loopback_host_input = + cx.new(|cx| InputState::new(window, cx).default_value("localhost")); + let loopback_port_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("3000") + .default_value("") + }); // Live-apply hot-reloaded config: the watcher in `main.rs` swaps the // `Config` global on every `config.json` change, which fires this. Theme // and colors are handled separately by `apply_theme`; here we cover the @@ -279,6 +298,13 @@ impl Tty7App { record_gen: 0, home_focus: cx.focus_handle(), detected_shells: Vec::new(), + loopback_panel: LoopbackForwardPanelState { + open_pane_id: None, + forwards: crate::terminal::RemoteTerminal::list_loopback_forwards(), + host_input: loopback_host_input, + port_input: loopback_port_input, + editing: None, + }, }; // Discover this machine's shells for the "+" dropdown off the UI thread // (the WSL probe on Windows spawns a process, and /etc/shells hits the @@ -788,77 +814,70 @@ impl Tty7App { } pub(crate) fn refresh_loopback_forwards(&mut self, cx: &mut Context) { - let forwards = crate::terminal::RemoteTerminal::list_loopback_forwards(); - if let Some(settings) = self - .tabs - .get_mut(self.active) - .and_then(|tab| tab.settings.as_mut()) - { - settings.loopback_forwards = forwards; + self.loopback_panel.forwards = crate::terminal::RemoteTerminal::list_loopback_forwards(); + cx.notify(); + } + + pub(crate) fn close_loopback_forward(&mut self, id: LoopbackForwardId, cx: &mut Context) { + self.loopback_panel.forwards = crate::terminal::RemoteTerminal::close_loopback_forward(id); + cx.notify(); + } + + pub(crate) fn toggle_loopback_forward_panel(&mut self, pane_id: u64, cx: &mut Context) { + let should_open = self.loopback_panel.open_pane_id != Some(pane_id); + if should_open { + self.loopback_panel.open_pane_id = Some(pane_id); + if self + .loopback_panel + .editing + .as_ref() + .is_some_and(|id| id.pane_id != pane_id) + { + self.loopback_panel.editing = None; + } + self.refresh_loopback_forwards(cx); + } else { + self.loopback_panel.open_pane_id = None; + self.loopback_panel.editing = None; } cx.notify(); } - pub(crate) fn close_loopback_forward( - &mut self, - id: crate::daemon::protocol::LoopbackForwardId, - cx: &mut Context, - ) { - let forwards = crate::terminal::RemoteTerminal::close_loopback_forward(id); - if let Some(settings) = self - .tabs - .get_mut(self.active) - .and_then(|tab| tab.settings.as_mut()) - { - settings.loopback_forwards = forwards; - } + pub(crate) fn close_loopback_forward_panel(&mut self, cx: &mut Context) { + self.loopback_panel.open_pane_id = None; + self.loopback_panel.editing = None; cx.notify(); } - pub(crate) fn copy_loopback_forward_address( - &mut self, - address: String, - cx: &mut Context, - ) { - cx.write_to_clipboard(ClipboardItem::new_string(address)); - } - pub(crate) fn save_loopback_forward_form( &mut self, + pane_id: u64, window: &mut Window, cx: &mut Context, ) { - let Some((pane_id, host, port, editing)) = self - .tabs - .get(self.active) - .and_then(|tab| tab.settings.as_ref()) - .and_then(|settings| { - let host = settings - .loopback_host_input - .read(cx) - .value() - .trim() - .to_string(); - let port = settings - .loopback_port_input - .read(cx) - .value() - .trim() - .parse::() - .ok()?; - let pane_id = settings - .loopback_editing - .as_ref() - .map(|id| id.pane_id) - .or(settings.loopback_default_pane_id)?; - Some((pane_id, host, port, settings.loopback_editing.clone())) - }) + let host = self + .loopback_panel + .host_input + .read(cx) + .value() + .trim() + .to_string(); + let Some(port) = self + .loopback_panel + .port_input + .read(cx) + .value() + .trim() + .parse::() + .ok() else { return; }; if host.is_empty() { return; } + let editing = self.loopback_panel.editing.clone(); + let pane_id = editing.as_ref().map(|id| id.pane_id).unwrap_or(pane_id); if crate::terminal::RemoteTerminal::ensure_loopback_forward(pane_id, &host, port).is_ok() { if let Some(old) = editing { @@ -866,44 +885,33 @@ impl Tty7App { let _ = crate::terminal::RemoteTerminal::close_loopback_forward(old); } } - let forwards = crate::terminal::RemoteTerminal::list_loopback_forwards(); - if let Some(settings) = self - .tabs - .get_mut(self.active) - .and_then(|tab| tab.settings.as_mut()) - { - settings.loopback_forwards = forwards; - settings.loopback_editing = None; - settings.loopback_host_input.update(cx, |input, cx| { - input.set_value("localhost", window, cx); - }); - settings.loopback_port_input.update(cx, |input, cx| { - input.set_value("", window, cx); - }); - } + self.loopback_panel.forwards = + crate::terminal::RemoteTerminal::list_loopback_forwards(); + self.loopback_panel.editing = None; + self.loopback_panel.host_input.update(cx, |input, cx| { + input.set_value("localhost", window, cx); + }); + self.loopback_panel.port_input.update(cx, |input, cx| { + input.set_value("", window, cx); + }); cx.notify(); } } pub(crate) fn edit_loopback_forward( &mut self, - id: crate::daemon::protocol::LoopbackForwardId, + id: LoopbackForwardId, window: &mut Window, cx: &mut Context, ) { - if let Some(settings) = self - .tabs - .get_mut(self.active) - .and_then(|tab| tab.settings.as_mut()) - { - settings.loopback_editing = Some(id.clone()); - settings.loopback_host_input.update(cx, |input, cx| { - input.set_value(id.remote_host, window, cx); - }); - settings.loopback_port_input.update(cx, |input, cx| { - input.set_value(id.remote_port.to_string(), window, cx); - }); - } + self.loopback_panel.open_pane_id = Some(id.pane_id); + self.loopback_panel.editing = Some(id.clone()); + self.loopback_panel.host_input.update(cx, |input, cx| { + input.set_value(id.remote_host, window, cx); + }); + self.loopback_panel.port_input.update(cx, |input, cx| { + input.set_value(id.remote_port.to_string(), window, cx); + }); cx.notify(); } @@ -912,51 +920,16 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) { - if let Some(settings) = self - .tabs - .get_mut(self.active) - .and_then(|tab| tab.settings.as_mut()) - { - settings.loopback_editing = None; - settings.loopback_host_input.update(cx, |input, cx| { - input.set_value("localhost", window, cx); - }); - settings.loopback_port_input.update(cx, |input, cx| { - input.set_value("", window, cx); - }); - } + self.loopback_panel.editing = None; + self.loopback_panel.host_input.update(cx, |input, cx| { + input.set_value("localhost", window, cx); + }); + self.loopback_panel.port_input.update(cx, |input, cx| { + input.set_value("", window, cx); + }); cx.notify(); } - pub(crate) fn open_managed_ssh_tab(&mut self, window: &mut Window, cx: &mut Context) { - let Some((target, options)) = self - .tabs - .get(self.active) - .and_then(|tab| tab.settings.as_ref()) - .map(|settings| { - ( - settings - .ssh_target_input - .read(cx) - .value() - .trim() - .to_string(), - settings.ssh_options_input.read(cx).value().to_string(), - ) - }) - else { - return; - }; - if target.is_empty() { - return; - } - let Ok(args) = parse_ssh_option_words(&options) else { - return; - }; - let ssh = SshSpec { target, args }; - self.open_managed_ssh_spec(ssh, window, cx); - } - fn open_managed_ssh_spec(&mut self, ssh: SshSpec, window: &mut Window, cx: &mut Context) { if ssh.validate().is_err() { return; @@ -1621,9 +1594,14 @@ impl Tty7App { cx, ); } + OpenSshConnect(input) => { + if let Ok(ssh) = parse_ssh_connect_input(&input) { + self.open_managed_ssh_spec(ssh, window, cx); + } + } // Handled inside `PaletteView` (opens a sub-list); these never emit a // `Confirm` for this variant, so they never reach here. - OpenThemePicker | OpenSshProfilePicker(_) => {} + OpenThemePicker | OpenSshConnectInput | OpenSshProfilePicker(_) => {} ActivateTab(i) => self.activate(i, window, cx), } } @@ -1641,14 +1619,6 @@ impl Tty7App { /// focus it. fn toggle_settings(&mut self, window: &mut Window, cx: &mut Context) { if let Some(index) = self.settings_tab_index() { - let loopback_default_pane_id = self - .tabs - .get(self.active) - .and_then(|tab| tab.pane.focused_or_first(window, cx)) - .map(|pane| pane.read(cx).pane_id); - if let Some(settings) = self.tabs[index].settings.as_mut() { - settings.loopback_default_pane_id = loopback_default_pane_id; - } self.activate(index, window, cx); return; } @@ -1669,33 +1639,6 @@ impl Tty7App { } }), ); - let loopback_default_pane_id = self - .tabs - .get(self.active) - .and_then(|tab| tab.pane.focused_or_first(window, cx)) - .map(|pane| pane.read(cx).pane_id); - let loopback_host_input = - cx.new(|cx| InputState::new(window, cx).default_value("localhost")); - let loopback_port_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("3000") - .default_value("") - }); - let ssh_target_input = cx.new(|cx| InputState::new(window, cx).placeholder("user@host")); - let ssh_options_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("-p 2222 -J jump") - .default_value("") - }); - for input in [&ssh_target_input, &ssh_options_input] { - subs.push(cx.subscribe_in(input, window, |_this, _i, ev, _w, cx| { - if matches!(ev, InputEvent::Change) { - cx.notify(); - } - })); - } - let loopback_forwards = crate::terminal::RemoteTerminal::list_loopback_forwards(); - self.maximized = None; self.tabs.push(Tab { pane: Pane::Empty, @@ -1713,13 +1656,6 @@ impl Tty7App { theme_editor: None, theme_panel_open: false, theme_search, - loopback_forwards, - ssh_target_input, - ssh_options_input, - loopback_default_pane_id, - loopback_host_input, - loopback_port_input, - loopback_editing: None, recording: None, rebinding_note: None, _subs: subs, @@ -2145,6 +2081,16 @@ impl Tty7App { .and_then(|t| t.settings.as_mut()) } + fn active_ssh_pane(&self, window: &Window, cx: &App) -> Option<(u64, RemoteContext)> { + let pane = self + .tabs + .get(self.active)? + .pane + .focused_or_first(window, cx)?; + let pane = pane.read(cx); + Some((pane.pane_id, pane.remote_context()?)) + } + /// Select a sidebar section in the active settings tab (no-op elsewhere). pub(crate) fn select_settings_section( &mut self, @@ -2416,6 +2362,7 @@ impl Tty7App { impl Render for Tty7App { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let strip = self.tab_strip(window, cx); + let active_ssh_pane = self.active_ssh_pane(window, cx); // Render the active tab's pane tree; show focus rings only when split. let body = match self.tabs.get(self.active) { // Zero tabs: the window's own face — the home page (see `ui::home`). @@ -2582,7 +2529,16 @@ impl Render for Tty7App { // edge clear (before the traffic lights' mirror gap on macOS). .child(strip), ) - .child(div().flex_1().relative().overflow_hidden().child(body)) + .child( + div() + .flex_1() + .relative() + .overflow_hidden() + .child(body) + .when_some(active_ssh_pane, |this, (pane_id, remote)| { + this.child(self.render_loopback_forward_overlay(pane_id, &remote, cx)) + }), + ) // Command palette overlay, layered above everything when open. .when_some(self.palette.clone(), |this, palette| this.child(palette)) } @@ -2761,9 +2717,64 @@ pub(crate) fn parse_ssh_option_words(input: &str) -> Result, ()> { Ok(words) } +pub(crate) fn parse_ssh_connect_input(input: &str) -> Result { + let mut words = parse_ssh_option_words(input)?; + if words.first().is_some_and(|word| word == "ssh") { + words.remove(0); + } + let Some(target_ix) = ssh_connect_target_ix(&words) else { + return Err(()); + }; + let target = words.remove(target_ix); + if target.trim().is_empty() { + return Err(()); + } + let ssh = SshSpec { + target, + args: words, + }; + ssh.validate().map_err(|_| ())?; + Ok(ssh) +} + +fn ssh_connect_target_ix(words: &[String]) -> Option { + let mut i = 0; + while i < words.len() { + let word = &words[i]; + if word == "--" { + return None; + } + if !word.starts_with('-') { + return Some(i); + } + if ssh_short_option_value_flag(word).is_some() { + i += 1; + if i >= words.len() { + return None; + } + } + i += 1; + } + None +} + +fn ssh_short_option_value_flag(word: &str) -> Option { + let short = word.strip_prefix('-')?; + if short.is_empty() || short.starts_with('-') { + return None; + } + let mut chars = short.chars(); + let flag = chars.next()?; + if ssh_option_takes_value(flag) && chars.as_str().is_empty() { + Some(flag) + } else { + None + } +} + #[cfg(test)] mod tests { - use super::parse_ssh_option_words; + use super::{parse_ssh_connect_input, parse_ssh_option_words}; #[test] fn parses_ssh_option_words_with_quotes() { @@ -2777,6 +2788,44 @@ mod tests { fn rejects_unclosed_ssh_option_quote() { assert!(parse_ssh_option_words("-J 'jump").is_err()); } + + #[test] + fn parses_ssh_connect_input_with_optional_ssh_prefix() { + assert_eq!( + parse_ssh_connect_input("ssh dev -p 2222 -J 'jump host'") + .unwrap() + .target, + "dev" + ); + let parsed = parse_ssh_connect_input("dev -p 2222").unwrap(); + assert_eq!(parsed.target, "dev"); + assert_eq!(parsed.args, vec!["-p", "2222"]); + } + + #[test] + fn parses_ssh_connect_input_with_options_before_target() { + let parsed = parse_ssh_connect_input("ssh -p 2222 -J 'jump host' dev").unwrap(); + assert_eq!(parsed.target, "dev"); + assert_eq!(parsed.args, vec!["-p", "2222", "-J", "jump host"]); + } + + #[test] + fn parses_ssh_connect_input_with_quoted_target() { + let parsed = parse_ssh_connect_input("ssh -l dev 'host name'").unwrap(); + assert_eq!(parsed.target, "host name"); + assert_eq!(parsed.args, vec!["-l", "dev"]); + } + + #[test] + fn rejects_ssh_connect_input_without_target() { + assert!(parse_ssh_connect_input("ssh -p 2222").is_err()); + } + + #[test] + fn rejects_ssh_connect_input_with_remote_command() { + assert!(parse_ssh_connect_input("ssh dev uptime").is_err()); + assert!(parse_ssh_connect_input("ssh -- dev").is_err()); + } } #[cfg(test)] diff --git a/src/ui/forwards.rs b/src/ui/forwards.rs new file mode 100644 index 00000000..806d24e1 --- /dev/null +++ b/src/ui/forwards.rs @@ -0,0 +1,326 @@ +//! Pane-contextual SSH loopback forward controls. +//! +//! Settings owns persistent preferences; this module owns the live forwarding +//! dashboard that only makes sense beside a concrete SSH pane. + +use gpui::{AnyElement, Context, Div, FontWeight, SharedString, div, prelude::*, px}; +use gpui_component::Selectable as _; +use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::input::Input; +use gpui_component::{ActiveTheme as _, Sizable as _, h_flex, v_flex}; + +use crate::daemon::protocol::{LoopbackForwardInfo, RemoteContext}; +use crate::ui::app::Tty7App; + +impl Tty7App { + pub(crate) fn render_loopback_forward_overlay( + &self, + pane_id: u64, + remote: &RemoteContext, + cx: &mut Context, + ) -> AnyElement { + let foreground = cx.theme().foreground; + let pane_forwards = self.loopback_forwards_for_pane(pane_id); + let active_count = pane_forwards.len(); + let panel_open = self.loopback_panel.open_pane_id == Some(pane_id); + let label = if active_count == 0 { + "Ports".to_string() + } else { + format!("Ports {active_count}") + }; + + div() + .absolute() + .top_2() + .right_4() + .flex() + .flex_col() + .items_end() + .gap_2() + .child( + Button::new(("ssh-forward-chip", pane_id)) + .label(label) + .small() + .selected(panel_open) + .on_click(cx.listener(move |this, _, _window, cx| { + this.toggle_loopback_forward_panel(pane_id, cx) + })), + ) + .when(panel_open, |this| { + this.child(self.render_loopback_forward_panel(pane_id, remote, &pane_forwards, cx)) + }) + .text_color(foreground) + .into_any_element() + } + + fn loopback_forwards_for_pane(&self, pane_id: u64) -> Vec { + self.loopback_panel + .forwards + .iter() + .filter(|forward| forward.id.pane_id == pane_id) + .cloned() + .collect() + } + + fn render_loopback_forward_panel( + &self, + pane_id: u64, + remote: &RemoteContext, + forwards: &[LoopbackForwardInfo], + cx: &mut Context, + ) -> Div { + let popover = cx.theme().popover; + let border = cx.theme().border; + let foreground = cx.theme().foreground; + let muted_foreground = cx.theme().muted_foreground; + let refresh = Button::new(("ssh-forward-refresh", pane_id)) + .label("Refresh") + .small() + .on_click(cx.listener(|this, _, _w, cx| this.refresh_loopback_forwards(cx))); + let close = Button::new(("ssh-forward-panel-close", pane_id)) + .label("Close") + .small() + .on_click(cx.listener(|this, _, _w, cx| this.close_loopback_forward_panel(cx))); + + let body = if forwards.is_empty() { + v_flex().child( + div() + .text_sm() + .text_color(muted_foreground) + .child("No active forwards for this host."), + ) + } else { + let mut list = v_flex().gap_2(); + for forward in forwards { + list = list.child(self.render_loopback_forward_row(forward, cx)); + } + list + }; + + v_flex() + .w(px(460.)) + .max_h(px(420.)) + .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(h_flex().gap_2().child(refresh).child(close)), + ) + .child(self.render_loopback_forward_form(pane_id, cx)) + .child(body) + } + + fn render_loopback_forward_form(&self, pane_id: u64, cx: &mut Context) -> Div { + let theme = cx.theme(); + let host_input = self.loopback_panel.host_input.clone(); + let port_input = self.loopback_panel.port_input.clone(); + let editing = self.loopback_panel.editing.clone(); + let title = if editing.is_some() { + "Edit forward" + } else { + "Add forward" + }; + let save_label = if editing.is_some() { "Save" } else { "Add" }; + let cancel = + editing.is_some().then(|| { + Button::new(("ssh-forward-cancel", pane_id)) + .label("Cancel") + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.cancel_loopback_forward_edit(window, cx) + })) + }); + + let host = div() + .w(px(180.)) + .child(Input::new(&host_input).small()) + .into_any_element(); + let port = div() + .w(px(92.)) + .child(Input::new(&port_input).small()) + .into_any_element(); + + v_flex() + .gap_2() + .py_1() + .child( + h_flex() + .items_center() + .justify_between() + .gap_3() + .child( + v_flex().gap_0p5().child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(theme.foreground) + .child(title), + ), + ) + .child( + h_flex() + .gap_2() + .child( + Button::new(("ssh-forward-save", pane_id)) + .label(save_label) + .small() + .primary() + .on_click(cx.listener(move |this, _, window, cx| { + this.save_loopback_forward_form(pane_id, window, cx) + })), + ) + .when_some(cancel, |row, button| row.child(button)), + ), + ) + .child( + h_flex() + .items_center() + .gap_2() + .child(host) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(":"), + ) + .child(port), + ) + } + + fn render_loopback_forward_row( + &self, + forward: &LoopbackForwardInfo, + cx: &mut Context, + ) -> Div { + let theme = cx.theme(); + let id = forward.id.clone(); + let remote = format!("{}:{}", forward.id.remote_host, forward.id.remote_port); + let local = format!("http://127.0.0.1:{}", forward.local_port); + let local_url = local.clone(); + let details = format!( + "idle {} · age {}", + human_duration(forward.idle_secs), + human_duration(forward.age_secs) + ); + let close_id = SharedString::from(format!( + "ssh-forward-close-{}-{}-{}-{}", + forward.id.pane_id, forward.id.target, forward.id.remote_host, forward.id.remote_port + )); + let edit_id = SharedString::from(format!( + "ssh-forward-edit-{}-{}-{}-{}", + forward.id.pane_id, forward.id.target, forward.id.remote_host, forward.id.remote_port + )); + let open_id = SharedString::from(format!( + "ssh-forward-open-{}-{}-{}-{}-{}", + forward.id.pane_id, + forward.id.target, + forward.id.remote_host, + forward.id.remote_port, + forward.local_port + )); + + h_flex() + .items_center() + .gap_3() + .px_3() + .py_2() + .border_1() + .border_color(theme.border) + .rounded_md() + .child( + v_flex() + .gap_0p5() + .flex_1() + .min_w_0() + .child( + h_flex() + .gap_2() + .items_center() + .child(div().text_sm().text_color(theme.foreground).child(remote)) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child("->"), + ) + .child( + div() + .id(open_id) + .text_sm() + .text_color(theme.accent) + .cursor_pointer() + .hover(|style| style.bg(theme.accent.opacity(0.08)).underline()) + .child(local) + .on_click(cx.listener(move |_this, _, _window, cx| { + cx.open_url(&local_url); + })), + ), + ) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(details), + ), + ) + .child( + h_flex() + .gap_2() + .child( + Button::new(edit_id) + .label("Edit") + .small() + .on_click(cx.listener({ + let id = id.clone(); + move |this, _, window, cx| { + this.edit_loopback_forward(id.clone(), window, cx) + } + })), + ) + .child( + Button::new(close_id) + .label("Close") + .small() + .on_click(cx.listener(move |this, _, _w, cx| { + this.close_loopback_forward(id.clone(), cx) + })), + ), + ) + } +} + +fn human_duration(secs: u64) -> String { + if secs < 60 { + format!("{secs}s") + } else if secs < 60 * 60 { + format!("{}m", secs / 60) + } else { + format!("{}h", secs / 3600) + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index c0e41718..42c84963 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -6,6 +6,7 @@ //! depends back on `ui`. pub mod app; +pub mod forwards; pub mod hints; pub mod home; pub mod keymap; diff --git a/src/ui/palette.rs b/src/ui/palette.rs index 476a0291..bb7c3608 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -55,8 +55,12 @@ pub enum CommandKind { RestartDaemon, /// Opens the theme sub-list (a nested palette). Handled in `PaletteView`. OpenThemePicker, + /// Opens a typed SSH connection sub-list. Handled in `PaletteView`. + OpenSshConnectInput, /// Opens the SSH profile sub-list. Handled in `PaletteView`. OpenSshProfilePicker(Vec), + /// Open a tty7-managed SSH tab from a typed target/options line. + OpenSshConnect(String), /// Apply the preset at this index in `presets::all()`. Emitted from the /// theme sub-list. SetTheme(usize), @@ -100,7 +104,9 @@ impl CommandKind { RestartDaemon => "RestartDaemon", FindInTerminal | OpenThemePicker + | OpenSshConnectInput | OpenSshProfilePicker(_) + | OpenSshConnect(_) | SetTheme(_) | OpenSshProfile(_) | ActivateTab(_) => return None, @@ -157,6 +163,7 @@ impl Command { Command::new("Clear", ClearTerminal), Command::new("Find in Terminal…", FindInTerminal), Command::new("Reopen Closed Tab", ReopenClosedTab), + Command::new("SSH: Add Connection…", OpenSshConnectInput), Command::new("Change Theme…", OpenThemePicker), Command::new("Open Settings", OpenSettings), Command::new("Reset Font Size", ResetFontSize), @@ -195,6 +202,15 @@ impl Command { }) .collect() } + + fn ssh_connect_command(input: &str) -> Command { + let title = if input.trim().is_empty() { + "SSH: Add Connection…".to_string() + } else { + format!("SSH: Connect {}", input.trim()) + }; + Command::new(title, CommandKind::OpenSshConnect(input.to_string())) + } } /// Case-insensitive subsequence match: every character of `query` must appear @@ -219,16 +235,36 @@ pub struct PaletteDelegate { commands: Vec, /// The subset matching the current query — exactly what the list renders. matched: Vec, + input: Option, + query: String, /// Index of the highlighted row, mirrored from the list's own selection so /// `render_item` can mark it. `None` when nothing matches. selected: Option, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum PaletteInput { + SshConnect, +} + impl PaletteDelegate { pub fn new(commands: Vec) -> Self { Self { matched: commands.clone(), commands, + input: None, + query: String::new(), + selected: Some(IndexPath::default()), + } + } + + fn ssh_connect() -> Self { + let matched = vec![Command::ssh_connect_command("")]; + Self { + commands: Vec::new(), + matched, + input: Some(PaletteInput::SshConnect), + query: String::new(), selected: Some(IndexPath::default()), } } @@ -255,12 +291,17 @@ impl ListDelegate for PaletteDelegate { _window: &mut Window, _cx: &mut Context>, ) -> Task<()> { - self.matched = self - .commands - .iter() - .filter(|c| fuzzy_match(query, &c.title)) - .cloned() - .collect(); + if let Some(PaletteInput::SshConnect) = self.input { + self.query = query.to_string(); + self.matched = vec![Command::ssh_connect_command(query)]; + } else { + self.matched = self + .commands + .iter() + .filter(|c| fuzzy_match(query, &c.title)) + .cloned() + .collect(); + } self.selected = (!self.matched.is_empty()).then(IndexPath::default); Task::ready(()) } @@ -347,6 +388,7 @@ pub enum PaletteEvent { enum PaletteMenu { Root, Theme, + SshConnect, SshProfiles, } @@ -391,7 +433,14 @@ impl PaletteView { window: &mut Window, cx: &mut Context, ) -> Entity> { - let delegate = PaletteDelegate::new(commands); + Self::build_list_with_delegate(PaletteDelegate::new(commands), window, cx) + } + + fn build_list_with_delegate( + delegate: PaletteDelegate, + window: &mut Window, + cx: &mut Context, + ) -> Entity> { let list = cx.new(|cx| ListState::new(delegate, window, cx).searchable(true)); list.update(cx, |state, cx| state.focus(window, cx)); list @@ -408,6 +457,20 @@ impl PaletteView { cx.notify(); } + fn show_ssh_connect(&mut self, window: &mut Window, cx: &mut Context) { + let list = Self::build_list_with_delegate(PaletteDelegate::ssh_connect(), window, cx); + self._sub = cx.subscribe_in(&list, window, Self::on_list_event); + self.list = list; + cx.notify(); + } + + fn search_placeholder(&self) -> &'static str { + match self.menu { + PaletteMenu::SshConnect => "user@host [-p 2222 -J jump]", + PaletteMenu::Root | PaletteMenu::Theme | PaletteMenu::SshProfiles => "Search…", + } + } + /// Translate the current list's confirm/cancel into either a host-facing /// event or an in-place transition into/out of a sub-list. fn on_list_event( @@ -428,11 +491,16 @@ impl PaletteView { let themes = Command::theme_commands(cx); self.show(themes, window, cx); } + Some(CommandKind::OpenSshConnectInput) => { + self.menu = PaletteMenu::SshConnect; + self.show_ssh_connect(window, cx); + } Some(CommandKind::OpenSshProfilePicker(profiles)) => { self.menu = PaletteMenu::SshProfiles; let profiles = Command::ssh_profile_commands(profiles); self.show(profiles, window, cx); } + Some(CommandKind::OpenSshConnect(input)) if input.trim().is_empty() => {} Some(kind) => cx.emit(PaletteEvent::Confirm(kind)), None => cx.emit(PaletteEvent::Dismiss), } @@ -477,7 +545,12 @@ impl Render for PaletteView { // highlight the context menu, new-tab dropdown and completion popup // use. The 4px top/bottom inset keeps the first/last row clear of the // card's rounded corners. - .child(List::new(&self.list).py_1().max_h(px(440.))); + .child( + List::new(&self.list) + .search_placeholder(self.search_placeholder()) + .py_1() + .max_h(px(440.)), + ); // Full-window scrim; clicking the empty area dismisses the palette (the // card itself is occluded so its clicks don't bubble here). diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 01c3fe6e..d348e9fb 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -17,13 +17,10 @@ use gpui_component::select::{SearchableVec, Select, SelectState}; use gpui_component::sidebar::{Sidebar, SidebarCollapsible, SidebarMenu, SidebarMenuItem}; use gpui_component::slider::{Slider, SliderState}; use gpui_component::switch::Switch; -use gpui_component::{ - ActiveTheme as _, Disableable as _, Icon, IconName, Sizable as _, h_flex, v_flex, -}; +use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; use std::sync::Arc; use crate::core::config::{Config, CursorStyle, NewTabPosition, NotifyMode}; -use crate::daemon::protocol::{LoopbackForwardId, LoopbackForwardInfo, SshSpec}; use crate::ui::app::{FONT_SIZE_STEP, LINE_HEIGHT_STEP, ThemeEdit, Tty7App}; use crate::ui::presets; @@ -99,16 +96,6 @@ pub(crate) struct SettingsState { pub(crate) theme_panel_open: bool, /// Live filter for the theme picker panel's list. pub(crate) theme_search: Entity, - /// Last daemon-reported SSH loopback forwards shown in Terminal → Links. - pub(crate) loopback_forwards: Vec, - pub(crate) ssh_target_input: Entity, - pub(crate) ssh_options_input: Entity, - /// Pane selected when Settings opened; manual forwards use this pane's - /// proven SSH context. - pub(crate) loopback_default_pane_id: Option, - pub(crate) loopback_host_input: Entity, - pub(crate) loopback_port_input: Entity, - pub(crate) loopback_editing: Option, /// `Some` while a Keybindings row is capturing a new shortcut: the action /// being rebound plus the live keystroke interceptor that swallows and /// records the next keypress (see `Tty7App::start_recording_key`). @@ -153,16 +140,6 @@ pub(crate) fn humanize_action(action: &str) -> String { out } -fn human_duration(secs: u64) -> String { - if secs < 60 { - format!("{secs}s") - } else if secs < 60 * 60 { - format!("{}m", secs / 60) - } else { - format!("{}h", secs / 3600) - } -} - impl Tty7App { /// Build the settings tab body: a fixed left sidebar (section nav) beside a /// scrollable content area for the selected section. Esc closes the tab. @@ -376,249 +353,6 @@ impl Tty7App { .child(control) } - fn render_loopback_forwards( - &self, - forwards: &[LoopbackForwardInfo], - cx: &mut Context, - ) -> Div { - let theme = cx.theme(); - let refresh = Button::new("ssh-forward-refresh") - .label("Refresh") - .small() - .on_click(cx.listener(|this, _, _w, cx| this.refresh_loopback_forwards(cx))); - - let header = h_flex() - .items_center() - .justify_between() - .child( - div() - .text_sm() - .font_weight(FontWeight::MEDIUM) - .text_color(theme.foreground) - .child("Active SSH forwards"), - ) - .child(refresh); - - let body = if forwards.is_empty() { - v_flex().child( - div() - .text_sm() - .text_color(theme.muted_foreground) - .child("No active SSH loopback forwards."), - ) - } else { - forwards.iter().fold(v_flex().gap_2(), |list, forward| { - list.child(self.render_loopback_forward_row(forward, cx)) - }) - }; - - v_flex().gap_2().py_2().child(header).child(body) - } - - fn render_loopback_forward_form(&self, cx: &mut Context) -> Div { - let theme = cx.theme(); - let Some(settings) = self.active_settings() else { - return div(); - }; - let host_input = settings.loopback_host_input.clone(); - let port_input = settings.loopback_port_input.clone(); - let editing = settings.loopback_editing.clone(); - let pane_label = editing - .as_ref() - .map(|id| format!("Pane {} on {}", id.pane_id, id.target)) - .or_else(|| { - settings - .loopback_default_pane_id - .map(|pane_id| format!("Current pane {pane_id}")) - }) - .unwrap_or_else(|| "Open Settings from an SSH pane".to_string()); - let title = if editing.is_some() { - "Edit SSH forward" - } else { - "Add SSH forward" - }; - let save_label = if editing.is_some() { "Save" } else { "Add" }; - let cancel = - editing.is_some().then(|| { - Button::new("ssh-forward-cancel") - .label("Cancel") - .small() - .on_click(cx.listener(|this, _, window, cx| { - this.cancel_loopback_forward_edit(window, cx) - })) - }); - - let host = div() - .w(px(180.)) - .child(Input::new(&host_input).small()) - .into_any_element(); - let port = div() - .w(px(92.)) - .child(Input::new(&port_input).small()) - .into_any_element(); - - v_flex() - .gap_2() - .py_2() - .child( - h_flex() - .items_center() - .justify_between() - .child( - v_flex() - .gap_0p5() - .child( - div() - .text_sm() - .font_weight(FontWeight::MEDIUM) - .text_color(theme.foreground) - .child(title), - ) - .child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(pane_label), - ), - ) - .child( - h_flex() - .gap_2() - .child( - Button::new("ssh-forward-save") - .label(save_label) - .small() - .primary() - .on_click(cx.listener(|this, _, window, cx| { - this.save_loopback_forward_form(window, cx) - })), - ) - .when_some(cancel, |row, button| row.child(button)), - ), - ) - .child( - h_flex() - .items_center() - .gap_2() - .child(host) - .child( - div() - .text_sm() - .text_color(theme.muted_foreground) - .child(":"), - ) - .child(port), - ) - } - - fn render_loopback_forward_row( - &self, - forward: &LoopbackForwardInfo, - cx: &mut Context, - ) -> Div { - let theme = cx.theme(); - let id = forward.id.clone(); - let remote = format!( - "{}:{} on {}", - forward.id.remote_host, forward.id.remote_port, forward.id.target - ); - let local = format!("127.0.0.1:{}", forward.local_port); - let local_for_copy = local.clone(); - let details = format!( - "Pane {} · idle {} · age {}", - forward.id.pane_id, - human_duration(forward.idle_secs), - human_duration(forward.age_secs) - ); - let close_id = SharedString::from(format!( - "ssh-forward-close-{}-{}-{}-{}", - forward.id.pane_id, forward.id.target, forward.id.remote_host, forward.id.remote_port - )); - let edit_id = SharedString::from(format!( - "ssh-forward-edit-{}-{}-{}-{}", - forward.id.pane_id, forward.id.target, forward.id.remote_host, forward.id.remote_port - )); - let copy_id = SharedString::from(format!( - "ssh-forward-copy-{}-{}-{}-{}-{}", - forward.id.pane_id, - forward.id.target, - forward.id.remote_host, - forward.id.remote_port, - forward.local_port - )); - - h_flex() - .items_center() - .gap_4() - .px_3() - .py_2() - .border_1() - .border_color(theme.border) - .rounded_md() - .child( - v_flex() - .gap_0p5() - .flex_1() - .min_w_0() - .child( - h_flex() - .gap_2() - .items_center() - .child(div().text_sm().text_color(theme.foreground).child(remote)) - .child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child("->"), - ) - .child( - div() - .id(copy_id) - .text_sm() - .text_color(theme.accent) - .cursor_pointer() - .hover(|style| style.bg(theme.accent.opacity(0.08))) - .child(local) - .on_click(cx.listener(move |this, _, _window, cx| { - this.copy_loopback_forward_address( - local_for_copy.clone(), - cx, - ) - })), - ), - ) - .child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(details), - ), - ) - .child( - h_flex() - .gap_2() - .child( - Button::new(edit_id) - .label("Edit") - .small() - .on_click(cx.listener({ - let id = id.clone(); - move |this, _, window, cx| { - this.edit_loopback_forward(id.clone(), window, cx) - } - })), - ) - .child( - Button::new(close_id) - .label("Close") - .small() - .on_click(cx.listener(move |this, _, _w, cx| { - this.close_loopback_forward(id.clone(), cx) - })), - ), - ) - } - /// A segmented control (gpui-component's `ButtonGroup`, outline) for a small /// set of mutually-exclusive options — the refined stand-in for a raw row of /// radio circles, which read as an unstyled form beside the sheet's tuned @@ -1110,8 +844,8 @@ impl Tty7App { NotifyMode::Unfocused => 1, NotifyMode::Always => 2, }; - let (scroll_slider, loopback_forwards) = match self.active_settings() { - Some(s) => (s.scroll_slider.clone(), s.loopback_forwards.clone()), + let scroll_slider = match self.active_settings() { + Some(s) => s.scroll_slider.clone(), None => return div().into_any_element(), }; @@ -1249,9 +983,6 @@ impl Tty7App { ssh_loopback_switch, cx, )) - .child(self.render_managed_ssh_tab_form(cx)) - .child(self.render_loopback_forward_form(cx)) - .child(self.render_loopback_forwards(&loopback_forwards, cx)) .child(self.section_rule(cx)) .child(self.section_header("Clipboard", cx)) .child(self.settings_row( @@ -1277,79 +1008,6 @@ impl Tty7App { .into_any_element() } - fn render_managed_ssh_tab_form(&self, cx: &mut Context) -> Div { - let theme = cx.theme(); - let Some(settings) = self.active_settings() else { - return div(); - }; - let target_input = settings.ssh_target_input.clone(); - let options_input = settings.ssh_options_input.clone(); - let target = target_input.read(cx).value().trim().to_string(); - let options = options_input.read(cx).value().to_string(); - let options_words = crate::ui::app::parse_ssh_option_words(&options); - let validation = options_words - .as_ref() - .map_err(|_| "Unclosed quote in SSH options.".to_string()) - .and_then(|args| { - SshSpec { - target: target.clone(), - args: args.clone(), - } - .validate() - }); - let can_open = validation.is_ok(); - let options_label = validation - .as_ref() - .map(|_| "Connection options only; put the host in Target.".to_string()) - .unwrap_or_else(|err| err.clone()); - - let target = div() - .w(px(180.)) - .child(Input::new(&target_input).small()) - .into_any_element(); - let options = div() - .w(px(220.)) - .child(Input::new(&options_input).small()) - .into_any_element(); - - v_flex() - .gap_2() - .py_2() - .child( - h_flex() - .items_center() - .justify_between() - .child( - v_flex() - .gap_0p5() - .child( - div() - .text_sm() - .font_weight(FontWeight::MEDIUM) - .text_color(theme.foreground) - .child("Open managed SSH tab"), - ) - .child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(options_label), - ), - ) - .child( - Button::new("managed-ssh-open") - .label("Open") - .small() - .primary() - .disabled(!can_open) - .on_click(cx.listener(|this, _, window, cx| { - this.open_managed_ssh_tab(window, cx) - })), - ), - ) - .child(h_flex().items_center().gap_2().child(target).child(options)) - } - /// Window & Tabs section: the app window's lifecycle and tab placement. fn render_settings_window_tabs(&self, cx: &mut Context) -> AnyElement { let cfg = cx.global::();