fix(ui): fold the names toasts did not compose either

Same rule, same reason, the surface next door. A branch name, a machine
label, a settings source path, an agent's display name, and the path out
of a terminal hyperlink — which is whatever the program writing to your
terminal chose to emit — all went into a notification raw.

An error message is deliberately left alone. In a toast the error *is*
the content, not a fragment inside a sentence of ours, and git and ssh
write genuinely multi-line errors whose second line is the useful one.
A dialog embeds one mid-question, so there it is still folded; the guard
now carries a different key list per surface and says why.
This commit is contained in:
l0ng-ai
2026-08-23 16:40:53 +08:00
parent 4d91b25b9b
commit 282b387a06
7 changed files with 99 additions and 36 deletions
+5 -1
View File
@@ -160,7 +160,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
could append text of its own to the question authorising the action. Every
name a dialog did not compose is now folded onto one line, the rule the file
tree, the SFTP list and the sidebar already followed when drawing the same
names.
names. Toasts fold the same names — a branch, a machine, or the path out of a
terminal hyperlink, which is whatever the program writing to your terminal
chose to emit. An error message in a toast is left alone, because there the
message is the content rather than a fragment of a sentence of ours, and git
and ssh write real multi-line errors.
- **A tab created and closed again before the window has reconciled no longer
leaves its shell running.** This was listed here as a known issue: with a
window open, `tab new` immediately followed by `tab close` stranded a live
+5 -2
View File
@@ -3074,7 +3074,7 @@ impl TerminalView {
window.push_notification(
crate::ui::i18n::t_fmt(
crate::ui::i18n::L10nKey::SftpImagePasteUploadFailed,
&[("host", host), ("error", reason)],
&[("host", &one_line(host)), ("error", reason)],
),
cx,
);
@@ -3100,7 +3100,10 @@ impl TerminalView {
window.push_notification(
crate::ui::i18n::t_fmt(
crate::ui::i18n::L10nKey::LinkFileOpenFailed,
&[("path", path.as_str()), ("error", reason.as_str())],
&[
("path", &one_line(path.as_str())),
("error", reason.as_str()),
],
),
cx,
);
+40 -7
View File
@@ -863,7 +863,10 @@ fn close_prompt(ends_the_tab: bool, reason: &CloseReason) -> (String, String) {
),
CloseReason::UnsavedEdits(name) => (
t(L10nKey::CloseUnsavedEditsTitle).to_string(),
t_fmt(L10nKey::CloseUnsavedEditsBody, &[("name", name)]),
t_fmt(
L10nKey::CloseUnsavedEditsBody,
&[("name", &crate::terminal::view::one_line(name))],
),
),
CloseReason::Busy(busy) => {
let title = match ends_the_tab {
@@ -4327,7 +4330,10 @@ impl Tty7App {
move |h| crate::core::worktree::remove(h, &wt, force),
move |_this, result, window, cx| match result {
Ok(()) => window.push_notification(
t_fmt(L10nKey::AppWorktreeRemoved, &[("branch", &branch)]),
t_fmt(
L10nKey::AppWorktreeRemoved,
&[("branch", &crate::terminal::view::one_line(&branch))],
),
cx,
),
Err(e) => window.push_notification(
@@ -4584,27 +4590,54 @@ impl Tty7App {
};
let name = agent.display_name();
if agent.fork_label().is_none() {
window.push_notification(t_fmt(L10nKey::AppForkNoCommand, &[("name", name)]), cx);
window.push_notification(
t_fmt(
L10nKey::AppForkNoCommand,
&[("name", &crate::terminal::view::one_line(name))],
),
cx,
);
return None;
}
if remote.is_some() {
window.push_notification(t_fmt(L10nKey::AppForkLocalOnly, &[("name", name)]), cx);
window.push_notification(
t_fmt(
L10nKey::AppForkLocalOnly,
&[("name", &crate::terminal::view::one_line(name))],
),
cx,
);
return None;
}
let session = session.unwrap_or_default();
let Some(id) = session.session_id.as_deref() else {
window.push_notification(t_fmt(L10nKey::AppForkNoSessionId, &[("name", name)]), cx);
window.push_notification(
t_fmt(
L10nKey::AppForkNoSessionId,
&[("name", &crate::terminal::view::one_line(name))],
),
cx,
);
return None;
};
let Some(cmd) = agent.fork_command(id, session.launch_argv.as_deref()) else {
window.push_notification(
t_fmt(L10nKey::AppForkSessionIdNotToken, &[("name", name)]),
t_fmt(
L10nKey::AppForkSessionIdNotToken,
&[("name", &crate::terminal::view::one_line(name))],
),
cx,
);
return None;
};
if session.status == AgentStatus::Working {
window.push_notification(t_fmt(L10nKey::AppForkMidTurn, &[("name", name)]), cx);
window.push_notification(
t_fmt(
L10nKey::AppForkMidTurn,
&[("name", &crate::terminal::view::one_line(name))],
),
cx,
);
}
Some(cmd)
}
+2 -2
View File
@@ -1031,7 +1031,7 @@ impl Tty7App {
t_fmt(
L10nKey::LinkFileOpenFailed,
&[
("path", &path.display().to_string()),
("path", &one_line(&path.display().to_string())),
("error", &crate::ui::host_ops::explain_io(&e)),
],
),
@@ -1042,7 +1042,7 @@ impl Tty7App {
window.push_notification(
t_fmt(
L10nKey::LinkDirOutsideTree,
&[("path", &path.display().to_string())],
&[("path", &one_line(&path.display().to_string()))],
),
cx,
);
+37 -20
View File
@@ -106,7 +106,7 @@ mod tests {
assert!(keep.is_cancel(), "the cancel is marked, not guessed");
}
/// Every name a confirmation dialog did not compose is folded onto one
/// Every name a dialog or a toast did not compose is folded onto one
/// line before it goes in.
///
/// `terminal::view::one_line` says the rule out loud — "anything that
@@ -126,13 +126,28 @@ mod tests {
/// `{name}`, `{path}`, `{branch}` and the rest are always someone else's
/// bytes, while `{verb}` and `{n}` are ours.
#[test]
fn a_dialog_folds_every_name_it_did_not_compose() {
fn a_dialog_or_toast_folds_every_name_it_did_not_compose() {
/// Substitution keys whose value is never text this app wrote.
const THEIRS: [&str; 9] = [
"name", "path", "branch", "machine", "host", "file", "subject", "author", "error",
///
/// A dialog's `{error}` is checked too. A toast's is not: there the
/// message *is* the content rather than a fragment inside a sentence
/// of ours, and git and ssh write genuinely multi-line errors whose
/// second line is the useful one. A dialog embeds it mid-question.
const NAMES: [&str; 8] = [
"name", "path", "branch", "machine", "host", "file", "subject", "author",
];
let surfaces: [(&str, &[&str]); 2] = [
(
".prompt(",
&[
"name", "path", "branch", "machine", "host", "file", "subject", "author",
"error",
],
),
("push_notification(", &NAMES),
];
fn walk(dir: &std::path::Path, found: &mut Vec<String>) {
fn walk(dir: &std::path::Path, surfaces: &[(&str, &[&str]); 2], found: &mut Vec<String>) {
let mut entries: Vec<_> = std::fs::read_dir(dir)
.expect("the ui sources are readable")
.filter_map(Result::ok)
@@ -141,22 +156,24 @@ mod tests {
entries.sort();
for path in entries {
if path.is_dir() {
walk(&path, found);
walk(&path, surfaces, found);
continue;
}
if path.extension().is_none_or(|e| e != "rs") {
continue;
}
let src = std::fs::read_to_string(&path).expect("a source file reads");
for (n, region) in prompt_regions(&src) {
for key in THEIRS {
for value in substitutions(&region, key) {
if !value.contains("one_line(") {
found.push(format!(
"{}:{}{{{key}}} is {value}",
path.display(),
n
));
for (call, keys) in *surfaces {
for (n, region) in call_regions(&src, call) {
for key in keys.iter().copied() {
for value in substitutions(&region, key) {
if !value.contains("one_line(") {
found.push(format!(
"{}:{}{{{key}}} is {value}",
path.display(),
n
));
}
}
}
}
@@ -164,13 +181,13 @@ mod tests {
}
}
/// Each `prompt(` call in `src`, as (1-based line, argument text).
fn prompt_regions(src: &str) -> Vec<(usize, String)> {
/// Each `call` site in `src`, as (1-based line, argument text).
fn call_regions(src: &str, call: &str) -> Vec<(usize, String)> {
let mut out = Vec::new();
let bytes = src.as_bytes();
let mut at = 0;
while let Some(hit) = src[at..].find(".prompt(") {
let open = at + hit + ".prompt(".len();
while let Some(hit) = src[at..].find(call) {
let open = at + hit + call.len();
let mut depth = 1usize;
let mut end = open;
while end < bytes.len() && depth > 0 {
@@ -216,7 +233,7 @@ mod tests {
let ui = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
assert!(ui.is_dir(), "the sources moved: {ui:?}");
let mut found = Vec::new();
walk(&ui, &mut found);
walk(&ui, &surfaces, &mut found);
assert!(
found.is_empty(),
"these dialogs interpolate a name nobody folded, so the name gets \
+4 -1
View File
@@ -353,7 +353,10 @@ impl Tty7App {
_ => {
let machine = self.remote_machine_label(cx);
window.push_notification(
t_fmt(L10nKey::RemoteNoConnectionDetails, &[("machine", &machine)]),
t_fmt(
L10nKey::RemoteNoConnectionDetails,
&[("machine", &one_line(&machine))],
),
cx,
);
false
+6 -3
View File
@@ -4077,7 +4077,7 @@ impl Tty7App {
window.push_notification(
Notification::error(t_fmt(
L10nKey::SettingsImportUnreadable,
&[("path", &source)],
&[("path", &crate::terminal::view::one_line(&source))],
))
.id1::<Self>(NOTIFICATION),
cx,
@@ -4086,8 +4086,11 @@ impl Tty7App {
}
if report.profiles.is_empty() {
window.push_notification(
Notification::warning(t_fmt(L10nKey::SettingsImportNoHosts, &[("path", &source)]))
.id1::<Self>(NOTIFICATION),
Notification::warning(t_fmt(
L10nKey::SettingsImportNoHosts,
&[("path", &crate::terminal::view::one_line(&source))],
))
.id1::<Self>(NOTIFICATION),
cx,
);
return;