mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
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:
@@ -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'
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user