From 2d2cdb851d514aea16ece9a216adca8b67ae6d8a Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:38:21 +0800 Subject: [PATCH] feat(forwards): switch a port forward off instead of deleting it (#437, #439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A forwarding rule could be added and removed, and nothing in between. A service that is only up part of the day meant deleting the rule and typing it in again, twice a day, forever. - A saved rule carries an `enabled` flag. Off keeps the rule exactly as it was written and simply does not offer it to the far side. The settings row leads with the switch — it is the one control that decides whether the rest of the row means anything — and fades when it is off. The section header counts what the connection will open and what it will not. - A live forward can be switched off from the Forwards panel. The far side has no such thing as a paused listener, so this is a remove that keeps the rule: the row stays, faded, with the switch that puts it back always visible. A rule whose port has since been taken stays off and says why instead of vanishing. - Opening Add no longer greets you with a red line. The panel seeds the bind host with 127.0.0.1, and the blank-form check read that as typing, so every Add opened under a complaint about a form nobody had touched. - The address field on the SSH page's empty state was a 24px pill: `flex_1` under a parent chain with no width of its own resolves against nothing. It declares its width now, and the placeholder that explains the page is readable again. --- crates/tty7-core/src/core/ssh_profile.rs | 12 ++ src/core/ssh_config.rs | 3 + src/ui/app.rs | 114 ++++++++++- src/ui/forwards.rs | 231 ++++++++++++++++++++--- src/ui/i18n/en.rs | 9 + src/ui/i18n/ja.rs | 9 + src/ui/i18n/mod.rs | 7 + src/ui/i18n/zh.rs | 9 + src/ui/settings.rs | 80 +++++++- src/ui/ssh_connect.rs | 39 +++- 10 files changed, 470 insertions(+), 43 deletions(-) diff --git a/crates/tty7-core/src/core/ssh_profile.rs b/crates/tty7-core/src/core/ssh_profile.rs index 8fcbfdad..8bd15092 100644 --- a/crates/tty7-core/src/core/ssh_profile.rs +++ b/crates/tty7-core/src/core/ssh_profile.rs @@ -148,6 +148,16 @@ pub struct ForwardRule { pub bind: HostPort, pub target: HostPort, pub description: String, + /// Whether the connection opens this rule. A rule that is off is kept + /// exactly as it was written and simply not offered to the far side, so a + /// forward for a service that is only up some of the time can be switched + /// off instead of deleted and typed again (#437). + /// + /// `default_true` rather than `#[serde(default)]`'s `false`: every rule + /// written before this field existed was on, and reading them back off is + /// how a config full of working forwards would go quiet after an update. + #[serde(default = "default_true")] + pub enabled: bool, } impl Default for ForwardRule { @@ -157,6 +167,7 @@ impl Default for ForwardRule { bind: HostPort::default(), target: HostPort::default(), description: String::new(), + enabled: true, } } } @@ -563,6 +574,7 @@ mod tests { bind: HostPort::new("127.0.0.1", 8080), target: HostPort::new("10.0.0.1", 80), description: "web".to_string(), + enabled: false, }]; original.socks_proxy = Some(HostPort::new("proxy", 1080)); original.algorithms.kex = vec!["curve25519-sha256".to_string()]; diff --git a/src/core/ssh_config.rs b/src/core/ssh_config.rs index c98b452f..dfe0dde0 100644 --- a/src/core/ssh_config.rs +++ b/src/core/ssh_config.rs @@ -883,6 +883,9 @@ fn parse_forward_rule(kind: ForwardKind, value: &str) -> Option { bind, target, description: String::new(), + // A LocalForward in `~/.ssh/config` is one ssh would open, so the + // profile imported from it opens it too. + enabled: true, }) } diff --git a/src/ui/app.rs b/src/ui/app.rs index 7cc611c6..b6b19ffb 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -526,6 +526,22 @@ pub(crate) struct LoopbackForwardPanelState { /// Why the last Add or Save did not take, in the far side's own words. /// Cleared the moment the form is closed or the edit is abandoned. pub(crate) mf_error: Option, + /// Rules that were switched off rather than removed (#437). + /// + /// Switching one off really does take the forward down — there is no such + /// thing as a paused listener on the far side — so the rule has nowhere to + /// live but here, and the panel draws these beside the running ones. A + /// service that is only up some of the time can then be switched off and + /// back on instead of deleted and typed in again. + pub(crate) paused: Vec, +} + +/// A forward the user switched off, kept whole so switching it back on needs +/// nothing typed again. +#[derive(Clone)] +pub(crate) struct PausedForward { + pub(crate) pane_id: u64, + pub(crate) rule: crate::daemon::protocol::SshForwardRule, } pub struct Tty7App { @@ -988,7 +1004,9 @@ impl Tty7App { let sftp_panel = crate::ui::sftp::SftpPanelState::new(window, cx); let file_tree = crate::ui::file_tree::FileTreeState::new(window, cx); let editor = crate::ui::code_editor::EditorPanelState::new(window, cx); - let mf_bind_host = cx.new(|cx| InputState::new(window, cx).default_value("127.0.0.1")); + let mf_bind_host = cx.new(|cx| { + InputState::new(window, cx).default_value(crate::ui::forwards::DEFAULT_BIND_HOST) + }); let mf_bind_port = cx.new(|cx| InputState::new(window, cx).placeholder("8080")); let mf_target_host = cx.new(|cx| InputState::new(window, cx).placeholder("127.0.0.1")); let mf_target_port = cx.new(|cx| InputState::new(window, cx).placeholder("80")); @@ -1146,6 +1164,7 @@ impl Tty7App { mf_description, mf_editing: None, mf_error: None, + paused: Vec::new(), }, sftp_panel, right_panel: Default::default(), @@ -2594,12 +2613,99 @@ impl Tty7App { ] { input.update(cx, |input, cx| input.set_value("", window, cx)); } - self.loopback_panel - .mf_bind_host - .update(cx, |input, cx| input.set_value("127.0.0.1", window, cx)); + self.loopback_panel.mf_bind_host.update(cx, |input, cx| { + input.set_value(crate::ui::forwards::DEFAULT_BIND_HOST, window, cx) + }); cx.notify(); } + /// Take a running forward down but keep the rule, so it can be put back + /// without being typed again (#437). + /// + /// The far side has no idea a rule can be "off" — a listener is either + /// bound or it is not — so this is a remove that files the rule here + /// instead of dropping it. If the remove does not answer, nothing moves: + /// the row stays where it is, still running, rather than turning into a + /// paused entry for a forward that is in fact still bound. + pub(crate) fn pause_managed_forward( + &mut self, + pane_id: u64, + forward_id: u64, + cx: &mut Context, + ) { + let Some(forward) = self + .loopback_panel + .managed + .iter() + .find(|m| m.id == forward_id) + .cloned() + else { + return; + }; + let Some(list) = self.forward_route(pane_id, cx).remove(forward_id) else { + return; + }; + self.loopback_panel.managed = list; + self.loopback_panel.paused.push(PausedForward { + pane_id, + rule: crate::ui::forwards::rule_of(&forward), + }); + cx.notify(); + } + + /// Put a switched-off rule back on the connection. + /// + /// It only leaves the paused list once the far side has bound it — a rule + /// whose port is now taken has to stay switched off and say so, rather than + /// vanishing from the panel because a button was pressed. + pub(crate) fn resume_paused_forward( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { + use crate::daemon::protocol::ForwardStatus; + use gpui_component::WindowExt as _; + + let Some(paused) = self.loopback_panel.paused.get(index).cloned() else { + return; + }; + let route = self.forward_route(paused.pane_id, cx); + let before: Vec = self.loopback_panel.managed.iter().map(|m| m.id).collect(); + let Some(list) = route.add(paused.rule.clone()) else { + window.push_notification(t(L10nKey::ForwardRequestFailed), cx); + return; + }; + // A rule that could not be bound is registered all the same, with the + // reason in its status — the same shape `add_managed_forward` reads. + let broken = crate::ui::forwards::added_forward(&before, &list).and_then(|added| { + match &added.status { + ForwardStatus::Error(msg) => Some((added.id, msg.clone())), + ForwardStatus::Listening => None, + } + }); + self.loopback_panel.managed = list; + if let Some((id, msg)) = broken { + if let Some(list) = route.remove(id) { + self.loopback_panel.managed = list; + } + window.push_notification(msg, cx); + cx.notify(); + return; + } + self.loopback_panel.paused.remove(index); + cx.notify(); + } + + /// Forget a rule that was switched off. The far side has nothing to undo — + /// pausing already took the forward down. + pub(crate) fn remove_paused_forward(&mut self, index: usize, cx: &mut Context) { + if index < self.loopback_panel.paused.len() { + self.loopback_panel.paused.remove(index); + cx.notify(); + } + } + pub(crate) fn remove_managed_forward( &mut self, pane_id: u64, diff --git a/src/ui/forwards.rs b/src/ui/forwards.rs index a9784dfa..45a1d5e0 100644 --- a/src/ui/forwards.rs +++ b/src/ui/forwards.rs @@ -16,6 +16,12 @@ use crate::ui::right_panel::{META, TEXT, TEXT_MONO}; /// draw the same row, so they fade it by the same amount. pub(crate) const NO_TARGET_FADE: f32 = 0.4; +/// What a forward listens on when nobody says otherwise, and what the panel +/// puts in the bind-host field when it opens the form. Loopback, not every +/// interface: a rule typed in a hurry should not expose the far side's service +/// to the network this laptop is on. +pub(crate) const DEFAULT_BIND_HOST: &str = "127.0.0.1"; + /// The managed-forward form's five text fields, read out of their inputs. /// /// Split out so the question "do these make a rule?" can be asked without a @@ -55,7 +61,7 @@ impl ForwardFields { let bind_host = match self.bind_host.trim() { // The panel's own default, and the one the strip's tooltip // promises: an empty bind host is loopback, not every interface. - "" => "127.0.0.1".to_string(), + "" => DEFAULT_BIND_HOST.to_string(), host => host.to_string(), }; let description = self.description.trim(); @@ -72,16 +78,23 @@ impl ForwardFields { /// Whether the form is still empty enough that saying what is missing /// would be nagging rather than helping — the same restraint the settings /// sheet shows through `ForwardRuleForm::is_blank`. + /// + /// The bind host is compared against the default rather than against empty: + /// the panel opens the form with `127.0.0.1` already in that field, so a + /// field-by-field emptiness check called a form nobody had touched + /// "started", and every Add opened under a red line telling the user what + /// was missing from a form they had not begun to fill in. pub(crate) fn is_blank(&self) -> bool { - [ - &self.bind_host, - &self.bind_port, - &self.target_host, - &self.target_port, - &self.description, - ] - .iter() - .all(|v| v.trim().is_empty()) + let untouched_bind_host = matches!(self.bind_host.trim(), "" | DEFAULT_BIND_HOST); + untouched_bind_host + && [ + &self.bind_port, + &self.target_host, + &self.target_port, + &self.description, + ] + .iter() + .all(|v| v.trim().is_empty()) } } @@ -113,6 +126,34 @@ pub(crate) fn rule_of(forward: &ManagedForward) -> SshForwardRule { } } +/// The one-letter badge a rule wears, the same letter `ssh -L/-R/-D` uses. +fn kind_letter(kind: SshForwardKind) -> &'static str { + match kind { + SshForwardKind::Local => "L", + SshForwardKind::Remote => "R", + SshForwardKind::Dynamic => "D", + } +} + +/// What a rule listens on, with the loopback host left off — `8080` rather than +/// `127.0.0.1:8080`, which is the same address spelled three times a row. +fn bind_label(host: &str, port: u16) -> String { + match host { + DEFAULT_BIND_HOST | "localhost" | "" => port.to_string(), + host => format!("{host}:{port}"), + } +} + +/// The square action tile a forward row's buttons are cut from — the panel's +/// existing hover-strip size, in one place so the switch and the delete are the +/// same target. +fn row_tile(id: impl Into, icon: IconName, cx: &mut Context) -> Button { + crate::ui::tab_strip::chrome_tile(Button::new(id).icon(icon).xsmall(), false, cx) + .w(px(crate::ui::tab_strip::MIN_TARGET)) + .h(px(crate::ui::tab_strip::MIN_TARGET)) + .rounded(px(4.)) +} + impl Tty7App { pub(crate) fn render_ssh_status_strip( &self, @@ -272,17 +313,32 @@ impl Tty7App { .filter(|m| m.pane_id == pane_id) .cloned() .collect(); + // Switched-off rules sit under the running ones, in the order they + // were switched off. Their index into the panel's own list is what the + // buttons act on, so it is carried alongside rather than derived twice. + let paused: Vec<(usize, SshForwardRule)> = self + .loopback_panel + .paused + .iter() + .enumerate() + .filter(|(_, p)| p.pane_id == pane_id) + .map(|(ix, p)| (ix, p.rule.clone())) + .collect(); + let empty = managed.is_empty() && paused.is_empty(); let mono = cx.theme().mono_font_family.clone(); let mut list = v_flex().px(px(CONTENT_INSET - 4.)).py(px(2.)).gap(px(1.)); for forward in &managed { list = list.child(self.forward_row(forward, &mono, cx)); } + for (index, rule) in &paused { + list = list.child(self.paused_forward_row(*index, rule, &mono, cx)); + } Some( v_flex() .child(self.panel_subtitle(t(L10nKey::ForwardPanelTitle), true, Some(add), cx)) - .when(managed.is_empty() && !open, |this| { + .when(empty && !open, |this| { this.child( div() .px(px(CONTENT_INSET)) @@ -292,12 +348,105 @@ impl Tty7App { .child(crate::ui::i18n::t(crate::ui::i18n::L10nKey::None)), ) }) - .when(!managed.is_empty(), |this| this.child(list)) + .when(!empty, |this| this.child(list)) .when(open, |this| this.child(self.forward_form(pane_id, cx))) .into_any_element(), ) } + /// A rule that is switched off: the same row, faded, with the switch that + /// puts it back always visible — a row nobody can see a way out of reads as + /// broken rather than as off. + fn paused_forward_row( + &self, + index: usize, + rule: &SshForwardRule, + mono: &gpui::SharedString, + cx: &mut Context, + ) -> Stateful
{ + let theme = cx.theme(); + let muted = theme.muted_foreground; + let letter = kind_letter(rule.kind); + let bind = bind_label(&rule.bind_host, rule.bind_port); + let tail = match rule.kind { + SshForwardKind::Dynamic => "SOCKS".to_string(), + _ => format!("→ {}:{}", rule.target_host, rule.target_port), + }; + let group = gpui::SharedString::from(format!("panel-forward-off-{index}")); + + h_flex() + .id(("panel-forward-off", index)) + .group(group.clone()) + .items_center() + .gap(px(8.)) + .px(px(4.)) + .py(px(3.)) + .rounded(px(5.)) + .opacity(NO_TARGET_FADE) + .child(crate::ui::right_panel::git_badge(letter, muted, mono)) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap(px(1.)) + .child( + h_flex() + .items_center() + .gap(px(6.)) + .child(crate::ui::right_panel::info_chip( + &bind, + theme.accent, + theme.foreground, + mono, + )) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(rems(TEXT_MONO)) + .font_family(mono.clone()) + .text_color(muted) + .child(tail), + ), + ) + .when_some(rule.description.clone(), |this, desc| { + this.child( + div() + .truncate() + .text_size(rems(META)) + .text_color(muted) + .child(desc), + ) + }), + ) + .child( + h_flex() + .flex_shrink_0() + .gap(px(1.)) + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + row_tile(("panel-forward-on", index), IconName::Play, cx) + .tooltip(t(L10nKey::ForwardTooltipTurnOn)) + .on_click(cx.listener(move |this, _, window, cx| { + this.resume_paused_forward(index, window, cx) + })), + ) + .child( + div() + .opacity(0.) + .group_hover(group, |s| s.opacity(1.)) + .child( + row_tile(("panel-forward-off-del", index), IconName::Close, cx) + .tooltip(t(L10nKey::ForwardTooltipForget)) + .on_click(cx.listener(move |this, _, _window, cx| { + this.remove_paused_forward(index, cx) + })), + ), + ), + ) + } + fn forward_row( &self, forward: &ManagedForward, @@ -307,17 +456,9 @@ impl Tty7App { let theme = cx.theme(); let muted = theme.muted_foreground; let sf = cx.global::().sidebar; - let letter = match forward.kind { - SshForwardKind::Local => "L", - SshForwardKind::Remote => "R", - SshForwardKind::Dynamic => "D", - }; + let letter = kind_letter(forward.kind); let errored = matches!(forward.status, ForwardStatus::Error(_)); - let bind = if matches!(forward.bind_host.as_str(), "127.0.0.1" | "localhost" | "") { - forward.bind_port.to_string() - } else { - format!("{}:{}", forward.bind_host, forward.bind_port) - }; + let bind = bind_label(&forward.bind_host, forward.bind_port); let tail = match &forward.status { ForwardStatus::Error(msg) => msg.clone(), ForwardStatus::Listening => match forward.kind { @@ -385,22 +526,33 @@ impl Tty7App { }), ) .child( - div() + h_flex() .flex_shrink_0() + .gap(px(1.)) .opacity(0.) .group_hover(group, |s| s.opacity(1.)) .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()) + // Off, not gone: the rule is kept and can be switched back + // on without being typed again (#437). .child( - crate::ui::tab_strip::chrome_tile( - Button::new(("panel-forward-del", forward_id as usize)) - .icon(IconName::Close) - .xsmall(), - false, + row_tile( + ("panel-forward-pause", forward_id as usize), + IconName::Pause, + cx, + ) + .tooltip(t(L10nKey::ForwardTooltipTurnOff)) + .on_click(cx.listener( + move |this, _, _window, cx| { + this.pause_managed_forward(pane_id, forward_id, cx) + }, + )), + ) + .child( + row_tile( + ("panel-forward-del", forward_id as usize), + IconName::Close, cx, ) - .w(px(crate::ui::tab_strip::MIN_TARGET)) - .h(px(crate::ui::tab_strip::MIN_TARGET)) - .rounded(px(4.)) .tooltip(t(L10nKey::ForwardTooltipRemove)) .on_click(cx.listener( move |this, _, _window, cx| { @@ -638,6 +790,23 @@ mod tests { assert!(!form.is_blank()); } + /// The panel opens the form with the default bind host already in the + /// field. Reading that as "the user has started filling this in" is what + /// put a red line under every freshly opened Add, telling the user what was + /// missing from a form they had not touched. + #[test] + fn the_bind_host_the_form_opens_with_is_not_typing() { + let mut form = fields(SshForwardKind::Local, "", "", ""); + form.bind_host = DEFAULT_BIND_HOST.to_string(); + assert!(form.is_blank(), "the form opens on this; nobody typed it"); + + form.bind_host = "0.0.0.0".to_string(); + assert!( + !form.is_blank(), + "a bind host that is not the default was typed, and the form may say what is missing" + ); + } + #[test] fn a_rule_survives_the_round_trip_through_a_live_forward() { let rule = rule_of(&managed(3, 8080)); diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 998446c0..f6b6ed6b 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -276,8 +276,11 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsJumpHostSelf => "A host can't be its own jump host — won't be saved.", L10nKey::SettingsNoneSummary => "(none)", L10nKey::SettingsPortForwarding => "Port forwarding", + L10nKey::SettingsRulesOff => "1 switched off", L10nKey::SettingsRulesOpenedWithConnection => "1 rule, opened with the connection", L10nKey::SettingsAddRule => "+ Add rule", + L10nKey::SettingsFwdRuleOn => "Opened with the connection", + L10nKey::SettingsFwdRuleOff => "Kept, but not opened", L10nKey::SettingsRemoveRule => "Remove rule", L10nKey::SettingsFwdLegendLocal => "L — a local port reaches the remote side", L10nKey::SettingsFwdLegendRemote => "R — a remote port reaches this machine", @@ -916,6 +919,9 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::ForwardDisconnectedFrom => "Disconnected from {host}", L10nKey::SshEditProfile => "Edit connection…", L10nKey::ForwardTooltipAdd => "Add forward", + L10nKey::ForwardTooltipTurnOff => "Switch off — keeps the rule", + L10nKey::ForwardTooltipTurnOn => "Switch on", + L10nKey::ForwardTooltipForget => "Forget this rule", L10nKey::ForwardTooltipRemove => "Remove", L10nKey::ForwardLocal => "Local", L10nKey::ForwardRemote => "Remote", @@ -1797,6 +1803,9 @@ pub fn translate_variant_en(key: L10nKey, branch: &'static str) -> Option<&'stat (L10nKey::SettingsRulesOpenedWithConnection, "other") => { "{count} rules, opened with the connection" } + (L10nKey::SettingsRulesOff, "zero") => "0 switched off", + (L10nKey::SettingsRulesOff, "one") => "1 switched off", + (L10nKey::SettingsRulesOff, "other") => "{count} switched off", (L10nKey::SettingsOfflineMachines, "zero") => { "0 more saved machines are not connected — open a workspace on one to install its hooks there." } diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 7c921ad4..9da5535b 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -279,8 +279,11 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { } L10nKey::SettingsNoneSummary => "(なし)", L10nKey::SettingsPortForwarding => "ポートフォワーディング", + L10nKey::SettingsRulesOff => "1 件は無効", L10nKey::SettingsRulesOpenedWithConnection => "接続と同時に開くルール 1 件", L10nKey::SettingsAddRule => "+ ルールを追加", + L10nKey::SettingsFwdRuleOn => "接続時に開く", + L10nKey::SettingsFwdRuleOff => "保持するが開かない", L10nKey::SettingsRemoveRule => "ルールを削除", L10nKey::SettingsFwdLegendLocal => "L — ローカルポートからリモート側へアクセスできる", L10nKey::SettingsFwdLegendRemote => "R — リモートポートからこのマシンへアクセスできる", @@ -966,6 +969,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::ForwardDisconnectedFrom => "{host} から切断されました", L10nKey::SshEditProfile => "接続を編集…", L10nKey::ForwardTooltipAdd => "フォワードを追加", + L10nKey::ForwardTooltipTurnOff => "無効にする(ルールは残す)", + L10nKey::ForwardTooltipTurnOn => "有効にする", + L10nKey::ForwardTooltipForget => "このルールを削除", L10nKey::ForwardTooltipRemove => "削除", L10nKey::ForwardLocal => "ローカル", L10nKey::ForwardRemote => "リモート", @@ -1855,6 +1861,9 @@ pub fn translate_variant_ja(key: L10nKey, branch: &'static str) -> Option<&'stat (L10nKey::SettingsRulesOpenedWithConnection, "other") => { "接続と同時に開くルール {count} 件" } + (L10nKey::SettingsRulesOff, "zero") => "0 件は無効", + (L10nKey::SettingsRulesOff, "one") => "1 件は無効", + (L10nKey::SettingsRulesOff, "other") => "{count} 件は無効", (L10nKey::SettingsOfflineMachines, "zero") => { "未接続の保存済みマシンはもうありません — いずれかでワークスペースを開くと、そこにフックをインストールできます" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index ee6b7b39..62c3c91f 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -275,8 +275,11 @@ l10n_keys! { SettingsNoneLower, SettingsPortForwarding, SettingsRulesOpenedWithConnection, + SettingsRulesOff, SettingsAddRule, SettingsRemoveRule, + SettingsFwdRuleOn, + SettingsFwdRuleOff, SettingsFwdLegendLocal, SettingsFwdLegendRemote, SettingsFwdLegendDynamic, @@ -677,6 +680,9 @@ l10n_keys! { SshEditProfile, ForwardTooltipAdd, ForwardTooltipRemove, + ForwardTooltipTurnOff, + ForwardTooltipTurnOn, + ForwardTooltipForget, ForwardLocal, ForwardRemote, ForwardDynamic, @@ -1580,6 +1586,7 @@ mod tests { L10nKey::SettingsImportSummary, L10nKey::SettingsImportIgnored, L10nKey::SettingsRulesOpenedWithConnection, + L10nKey::SettingsRulesOff, L10nKey::SettingsOfflineMachines, L10nKey::SettingsForgetPasswordSharedBody, L10nKey::PanelMoreChangedFiles, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 0cf0ff59..d0ae40b5 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -244,8 +244,11 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsJumpHostSelf => "主机不能把自己当作跳板——不会被保存。", L10nKey::SettingsNoneSummary => "(无)", L10nKey::SettingsPortForwarding => "端口转发", + L10nKey::SettingsRulesOff => "1 条已停用", L10nKey::SettingsRulesOpenedWithConnection => "1 条规则,随连接打开", L10nKey::SettingsAddRule => "+ 添加规则", + L10nKey::SettingsFwdRuleOn => "连接时开启", + L10nKey::SettingsFwdRuleOff => "保留但不开启", L10nKey::SettingsRemoveRule => "删除规则", L10nKey::SettingsFwdLegendLocal => "L — 本地端口可达远程侧", L10nKey::SettingsFwdLegendRemote => "R — 远程端口可达本机", @@ -870,6 +873,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::ForwardDisconnectedFrom => "与 {host} 的连接已断开", L10nKey::SshEditProfile => "编辑连接…", L10nKey::ForwardTooltipAdd => "添加转发", + L10nKey::ForwardTooltipTurnOff => "停用(保留规则)", + L10nKey::ForwardTooltipTurnOn => "启用", + L10nKey::ForwardTooltipForget => "删除这条规则", L10nKey::ForwardTooltipRemove => "移除", L10nKey::ForwardLocal => "本地", L10nKey::ForwardRemote => "远程", @@ -1696,6 +1702,9 @@ pub fn translate_variant_zh(key: L10nKey, branch: &'static str) -> Option<&'stat (L10nKey::SettingsRulesOpenedWithConnection, "zero") => "0 条规则,随连接打开", (L10nKey::SettingsRulesOpenedWithConnection, "one") => "1 条规则,随连接打开", (L10nKey::SettingsRulesOpenedWithConnection, "other") => "{count} 条规则,随连接打开", + (L10nKey::SettingsRulesOff, "zero") => "0 条已停用", + (L10nKey::SettingsRulesOff, "one") => "1 条已停用", + (L10nKey::SettingsRulesOff, "other") => "{count} 条已停用", (L10nKey::SettingsOfflineMachines, "zero") => { "还有 0 台已保存的机器未连接——在其中一台上打开工作区,即可在那台机器上安装 hook。" } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index a7b462e6..e7850462 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -226,6 +226,13 @@ const STACK_ROW_BELOW: f32 = 500.; /// lines instead of running off the page. const SPLIT_FORWARD_ROW_BELOW: f32 = 620.; +/// How far a switched-off forwarding rule fades. Deep enough that a column of +/// rules shows at a glance which ones this connection will open, shallow enough +/// that the one that is off is still readable — it is a rule being kept, not a +/// rule being deleted. The same weight the live Forwards panel fades a Dynamic +/// rule's absent target by. +const DISABLED_RULE_FADE: f32 = crate::ui::forwards::NO_TARGET_FADE; + /// And the width below which even `bind → target` is more than one line holds: /// two host fields at their narrow floor, two ports and the arrow come to about /// 310, which is more than `CONTENT_MIN_W`. The SSH page reaches this on the @@ -984,6 +991,10 @@ impl SshProfileForm { pub(crate) struct ForwardRuleForm { pub(crate) kind: ForwardKind, + /// Whether the connection opens this rule. Off keeps the rule and skips + /// it, which is what a forward for a service that is only up some of the + /// time needs (#437). + pub(crate) enabled: bool, pub(crate) bind_host: Entity, pub(crate) bind_port: Entity, pub(crate) target_host: Entity, @@ -1011,6 +1022,7 @@ impl ForwardRuleForm { bind, target, description: val(&self.description), + enabled: self.enabled, }) } @@ -1433,6 +1445,7 @@ fn seed_forward_row( let port = |p: u16| if p == 0 { String::new() } else { p.to_string() }; ForwardRuleForm { kind: rule.kind, + enabled: rule.enabled, bind_host: seed_hinted(window, cx, &rule.bind.host, "localhost"), bind_port: seed_hinted(window, cx, &port(rule.bind.port), "8080"), target_host: seed_hinted(window, cx, &rule.target.host, "127.0.0.1"), @@ -3252,9 +3265,16 @@ impl Tty7App { .mt_3() .gap_2() .child( + // Declared, not shared out: `flex_1` is a share of a + // parent's width, and nothing above this row has one — + // on a content-sizing pass the chain resolved against + // nothing and the field collapsed to a 24px pill beside + // its own Connect button, with the placeholder that + // explains the whole page clipped out of it. The same + // failure the rail's workspace head had. div() - .flex_1() - .max_w(px(320.)) + .w(px(320.)) + .max_w_full() .child(Input::new(&input).small()), ) .child( @@ -4462,14 +4482,24 @@ impl Tty7App { cx: &mut Context, ) -> AnyElement { let muted = cx.theme().muted_foreground; + // The header says how many rules the connection *opens*, so a rule + // switched off is not one of them — the collapsed section would + // otherwise promise three forwards and open one. let count = form .forwards .iter() - .filter(|r| r.collect(cx).is_some()) + .filter(|r| r.enabled && r.collect(cx).is_some()) .count(); - let summary = match count { - 0 => t(L10nKey::SettingsNoneSummary).to_string(), - _ => t_plural(L10nKey::SettingsRulesOpenedWithConnection, count, &[]), + let off = form.forwards.iter().filter(|r| !r.enabled).count(); + let summary = match (count, off) { + (0, 0) => t(L10nKey::SettingsNoneSummary).to_string(), + (0, off) => t_plural(L10nKey::SettingsRulesOff, off, &[]), + (count, 0) => t_plural(L10nKey::SettingsRulesOpenedWithConnection, count, &[]), + (count, off) => format!( + "{} · {}", + t_plural(L10nKey::SettingsRulesOpenedWithConnection, count, &[]), + t_plural(L10nKey::SettingsRulesOff, off, &[]) + ), }; let mut section = v_flex().child(self.disclosure_header( "ssh-sec-fwd", @@ -4618,8 +4648,29 @@ impl Tty7App { ) .tooltip(t(L10nKey::SettingsRemoveRule)) .on_click(cx.listener(move |this, _, _w, cx| this.remove_forward_rule(idx, cx))); + // Leads the row rather than trailing it: this is the one control that + // decides whether the rest of the row means anything, and a reader + // scanning a column of rules for the one that is off should not have + // to read across four fields to find out. + let enable = div().flex_shrink_0().child( + crate::ui::theme::switch(("ssh-fwd-enabled", idx), cx) + .small() + .checked(row.enabled) + .tooltip(match row.enabled { + true => t(L10nKey::SettingsFwdRuleOn), + false => t(L10nKey::SettingsFwdRuleOff), + }) + .on_click(cx.listener(move |this, on: &bool, _w, cx| { + if let Some(f) = this.ssh_form_mut() + && let Some(r) = f.forwards.get_mut(idx) + { + r.enabled = *on; + cx.notify(); + } + })), + ); - let rule = match split { + let body = match split { true => v_flex() .gap_1() .child( @@ -4638,6 +4689,21 @@ impl Tty7App { .child(description) .child(remove), }; + // A rule that is off keeps every field readable and editable — it is + // still the rule, just not one this connection opens — but it stops + // competing with the ones that are on. + let rule = h_flex() + .gap_2() + .when(split, |row| row.items_start()) + .when(!split, |row| row.items_center()) + .child(enable) + .child( + div() + .flex_1() + .min_w_0() + .when(!row.enabled, |body| body.opacity(DISABLED_RULE_FADE)) + .child(body), + ); v_flex() .gap_0p5() diff --git a/src/ui/ssh_connect.rs b/src/ui/ssh_connect.rs index f7d288c6..6e7c5dcf 100644 --- a/src/ui/ssh_connect.rs +++ b/src/ui/ssh_connect.rs @@ -376,7 +376,14 @@ fn build_spec_inner( key_passphrases: (!key_passphrases.is_empty()).then_some(key_passphrases), proxy: map_proxy(profile), jump, - forwards: profile.forwards.iter().map(map_forward).collect(), + // A rule that is switched off stays in the profile and is simply not + // offered to the far side (#437). + forwards: profile + .forwards + .iter() + .filter(|rule| rule.enabled) + .map(map_forward) + .collect(), keepalive_interval_s: profile.keepalive_interval_s, keepalive_count_max: profile.keepalive_count_max, connect_timeout_s: profile.connect_timeout_s, @@ -468,6 +475,9 @@ fn unmap_forward(rule: &SshForwardRule) -> ForwardRule { bind: HostPort::new(rule.bind_host.clone(), rule.bind_port), target: HostPort::new(rule.target_host.clone(), rule.target_port), description: rule.description.clone().unwrap_or_default(), + // A live connection only carries the rules that were switched on, so + // everything read back off one is on. + enabled: true, } } @@ -693,6 +703,7 @@ mod tests { bind: HostPort::new("localhost", 8080), target: HostPort::new("127.0.0.1", 80), description: "web".into(), + enabled: true, }]; let spec = build_native_ssh_spec(&p, &[], &store, true); @@ -784,6 +795,32 @@ mod tests { ); } + /// A rule that is switched off stays in the profile — that is the whole + /// point of switching it off rather than deleting it — and simply is not + /// among the ones the connection opens (#437). + #[test] + fn a_switched_off_rule_is_kept_and_not_opened() { + let store = InMemoryCredentialStore::new(); + let mut p = profile("web", "10.0.0.5", "deploy"); + let rule = |port: u16, enabled: bool| ForwardRule { + kind: ForwardKind::Local, + bind: HostPort::new("127.0.0.1", port), + target: HostPort::new("127.0.0.1", 80), + description: String::new(), + enabled, + }; + p.forwards = vec![rule(8080, true), rule(8081, false), rule(8082, true)]; + + let spec = build_native_ssh_spec(&p, &[], &store, true); + let ports: Vec = spec.forwards.iter().map(|f| f.bind_port).collect(); + assert_eq!(ports, vec![8080, 8082]); + assert_eq!( + p.forwards.len(), + 3, + "the profile still has every rule someone wrote" + ); + } + #[test] fn resolves_jump_chain_into_nested_specs() { let bastion = profile("bastion", "bastion.example.com", "jump");