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
This commit is contained in:
ayamir
2026-07-13 19:55:44 +08:00
committed by GitHub
parent 5302fc0f7e
commit 7606c19a51
7 changed files with 640 additions and 529 deletions
+1 -1
View File
@@ -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'
+5 -1
View File
@@ -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<RemoteContext> {
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<ShellSpec> {
+223 -174
View File
@@ -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<Subscription>,
}
pub(crate) struct LoopbackForwardPanelState {
pub(crate) open_pane_id: Option<u64>,
pub(crate) forwards: Vec<LoopbackForwardInfo>,
pub(crate) host_input: Entity<InputState>,
pub(crate) port_input: Entity<InputState>,
pub(crate) editing: Option<LoopbackForwardId>,
}
pub struct Tty7App {
/// The open tabs; each owns a split-pane tree and an optional name.
pub(crate) tabs: Vec<Tab>,
@@ -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<DetectedShell>,
/// 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::<Config>().font_family_bold.clone();
let font_family_italic = cx.global::<Config>().font_family_italic.clone();
let font_features = cx.global::<Config>().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<Self>) {
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>) {
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<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);
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<Self>,
) {
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>) {
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<Self>,
) {
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<Self>,
) {
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::<u16>()
.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::<u16>()
.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<Self>,
) {
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<Self>,
) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) -> 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<Vec<String>, ()> {
Ok(words)
}
pub(crate) fn parse_ssh_connect_input(input: &str) -> Result<SshSpec, ()> {
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<usize> {
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<char> {
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)]
+326
View File
@@ -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<Self>,
) -> 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<LoopbackForwardInfo> {
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<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 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<Self>) -> 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<Self>,
) -> 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)
}
}
+1
View File
@@ -6,6 +6,7 @@
//! depends back on `ui`.
pub mod app;
pub mod forwards;
pub mod hints;
pub mod home;
pub mod keymap;
+81 -8
View File
@@ -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<SshProfile>),
/// 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<Command>,
/// The subset matching the current query — exactly what the list renders.
matched: Vec<Command>,
input: Option<PaletteInput>,
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<IndexPath>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum PaletteInput {
SshConnect,
}
impl PaletteDelegate {
pub fn new(commands: Vec<Command>) -> 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<ListState<Self>>,
) -> 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<Self>,
) -> Entity<ListState<PaletteDelegate>> {
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<Self>,
) -> Entity<ListState<PaletteDelegate>> {
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<Self>) {
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).
+3 -345
View File
@@ -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<InputState>,
/// Last daemon-reported SSH loopback forwards shown in Terminal → Links.
pub(crate) loopback_forwards: Vec<LoopbackForwardInfo>,
pub(crate) ssh_target_input: Entity<InputState>,
pub(crate) ssh_options_input: Entity<InputState>,
/// Pane selected when Settings opened; manual forwards use this pane's
/// proven SSH context.
pub(crate) loopback_default_pane_id: Option<u64>,
pub(crate) loopback_host_input: Entity<InputState>,
pub(crate) loopback_port_input: Entity<InputState>,
pub(crate) loopback_editing: Option<LoopbackForwardId>,
/// `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<Self>,
) -> 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<Self>) -> 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<Self>,
) -> 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<Self>) -> 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<Self>) -> AnyElement {
let cfg = cx.global::<Config>();