From dc19123fab9f3fce0eb1d889d65141b3d9d19b5f Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:15:12 +0800 Subject: [PATCH] refactor(forwards): ask the form itself whether it is incomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ForwardFields` exists, by its own doc, so "do these make a rule?" can be asked without a `Window` and answered the same way in both places that need it. "Should the form be saying what is still missing?" is the same kind of question and was being answered in the render instead, as a pair of halves neither of which was held. Dropping the blank half puts an error message under a panel nobody has typed into yet — the exact greeting the check was added to prevent. Dropping the other half leaves it up after the form is valid. Nothing failed either way. Now a method beside `collect` and `is_blank`, with a truth table over it: untouched, whitespace-only, half typed, finished, and dynamic — where the same fields that are half typed for a local forward are already a rule. --- src/ui/forwards.rs | 69 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/src/ui/forwards.rs b/src/ui/forwards.rs index ad8012be..b10aef4c 100644 --- a/src/ui/forwards.rs +++ b/src/ui/forwards.rs @@ -80,6 +80,18 @@ impl ForwardFields { .iter() .all(|v| v.trim().is_empty()) } + + /// Whether the form should be saying what is still missing. + /// + /// Both halves, and for opposite reasons. Not yet a rule is the whole + /// point of the message — but a form nobody has typed into is not a + /// mistake, and greeting someone with what they have got wrong before they + /// have touched anything is the behaviour the blank check exists to + /// prevent. Asked here rather than in the render so the pair cannot be + /// half-remembered at the call site, which is where they were. + pub(crate) fn incomplete(&self) -> bool { + self.collect().is_none() && !self.is_blank() + } } /// The entry a forward request just appended: the one the panel did not have @@ -421,7 +433,7 @@ impl Tty7App { // is something to add, and the line below the form says what is still // missing — but not while the form has barely been touched. let complete = fields.collect().is_some(); - let incomplete = !complete && !fields.is_blank(); + let incomplete = fields.incomplete(); let selected = match kind { SshForwardKind::Local => 0, SshForwardKind::Remote => 1, @@ -549,6 +561,61 @@ mod tests { } } + /// The message under the form is for someone who has started and not + /// finished. Each half of that is a separate promise, and dropping either + /// changes what an untouched panel looks like on open. + #[test] + fn the_form_says_what_is_missing_only_once_it_has_been_typed_into() { + let blank = ForwardFields { + kind: SshForwardKind::Local, + bind_host: String::new(), + bind_port: String::new(), + target_host: String::new(), + target_port: String::new(), + description: String::new(), + }; + assert!(blank.is_blank()); + assert!( + !blank.incomplete(), + "an untouched form has nothing to complain about" + ); + + // Whitespace is still untouched: a stray space must not turn the panel + // into an error message. + let spaces = ForwardFields { + bind_port: " ".to_string(), + ..blank_like() + }; + assert!(!spaces.incomplete(), "whitespace is not an attempt"); + + // Half typed: a bind port and nothing to send it to. + let started = fields(SshForwardKind::Local, "8080", "", ""); + assert!(started.collect().is_none()); + assert!(started.incomplete(), "started and not finished"); + + // Finished: nothing left to say. + let done = fields(SshForwardKind::Local, "8080", "10.0.0.5", "80"); + assert!(done.collect().is_some()); + assert!(!done.incomplete(), "a rule that is ready is not incomplete"); + + // Dynamic needs no target, so the same fields that are half typed for a + // local forward are a finished rule here. + let dynamic = fields(SshForwardKind::Dynamic, "1080", "", ""); + assert!(dynamic.collect().is_some()); + assert!(!dynamic.incomplete()); + } + + fn blank_like() -> ForwardFields { + ForwardFields { + kind: SshForwardKind::Local, + bind_host: String::new(), + bind_port: String::new(), + target_host: String::new(), + target_port: String::new(), + description: String::new(), + } + } + fn managed(id: u64, bind_port: u16) -> ManagedForward { ManagedForward { id,