mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
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.
This commit is contained in:
@@ -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()];
|
||||
|
||||
@@ -883,6 +883,9 @@ fn parse_forward_rule(kind: ForwardKind, value: &str) -> Option<ForwardRule> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+110
-4
@@ -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<String>,
|
||||
/// 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<PausedForward>,
|
||||
}
|
||||
|
||||
/// 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<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<u64> = 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<Self>) {
|
||||
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,
|
||||
|
||||
+200
-31
@@ -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<gpui::ElementId>, icon: IconName, cx: &mut Context<Tty7App>) -> 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<Self>,
|
||||
) -> Stateful<Div> {
|
||||
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::<crate::ui::presets::Surfaces>().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));
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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") => {
|
||||
"未接続の保存済みマシンはもうありません — いずれかでワークスペースを開くと、そこにフックをインストールできます"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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。"
|
||||
}
|
||||
|
||||
+73
-7
@@ -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<InputState>,
|
||||
pub(crate) bind_port: Entity<InputState>,
|
||||
pub(crate) target_host: Entity<InputState>,
|
||||
@@ -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<Self>,
|
||||
) -> 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()
|
||||
|
||||
+38
-1
@@ -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<u16> = 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");
|
||||
|
||||
Reference in New Issue
Block a user