From 005efee05811d2ac91087b79c0f2b218f32d873a Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:52:02 +0800 Subject: [PATCH] fix(ui): explain the io errors that build their own message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `explain_io` exists because "Permission denied (os error 13)" answers a developer's question and not the reader's — its own doc says so. Every failure routed through `HostOps::notify_err` gets it. Four that build their notification string by hand did not, and printed the raw error. The clearest symptom was inside one file: saving a file in the editor went through `notify_err` and explained itself, while opening the same file, denied for the same reason, said `os error 13`. Both file-link openers are changed together — the file tree's opener deliberately shares its wording with the terminal's (#542), so explaining one and not the other would have split a pairing that was on purpose. The `log::warn!` next to each call keeps the exact error. A developer reading a log and a person who just lost a save want different things, so both are written rather than one chosen — noted on `explain_io` along with the rule, since it was the absence of a stated rule that let four callers drift. --- src/terminal/view.rs | 5 ++++- src/ui/code_editor.rs | 10 ++++++++-- src/ui/file_tree.rs | 2 +- src/ui/host_ops.rs | 9 +++++++++ 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/terminal/view.rs b/src/terminal/view.rs index ec72d4d3..6436f535 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -2943,7 +2943,10 @@ impl TerminalView { ) { log::warn!("failed to open file link {}: {reason}", path.display()); let path = path.display().to_string(); - let reason = reason.to_string(); + // The log above keeps the exact error for whoever is debugging; the + // notification gets the sentence, because "os error 13" is not an + // answer to the question the reader actually has. + let reason = crate::ui::host_ops::explain_io(reason); window.push_notification( crate::ui::i18n::t_fmt( crate::ui::i18n::L10nKey::LinkFileOpenFailed, diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index 086f45d8..ca5872de 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -494,7 +494,10 @@ impl Tty7App { Err(e) => { return Err(t_fmt( L10nKey::EditorCantOpen, - &[("path", &path.display().to_string()), ("e", &e.to_string())], + &[ + ("path", &path.display().to_string()), + ("e", &crate::ui::host_ops::explain_io(&e)), + ], )); } }; @@ -512,7 +515,10 @@ impl Tty7App { Err(e) => { return Err(t_fmt( L10nKey::EditorCantRead, - &[("path", &path.display().to_string()), ("e", &e.to_string())], + &[ + ("path", &path.display().to_string()), + ("e", &crate::ui::host_ops::explain_io(&e)), + ], )); } }; diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index 07dcf014..9c48315e 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -1051,7 +1051,7 @@ impl Tty7App { L10nKey::LinkFileOpenFailed, &[ ("path", &path.display().to_string()), - ("error", &e.to_string()), + ("error", &crate::ui::host_ops::explain_io(&e)), ], ), cx, diff --git a/src/ui/host_ops.rs b/src/ui/host_ops.rs index 4e7e9082..f106315d 100644 --- a/src/ui/host_ops.rs +++ b/src/ui/host_ops.rs @@ -190,6 +190,15 @@ impl HostOps { /// rest. `Display` on an `io::Error` answers "what happened" for a developer /// reading a log; it does not answer "what now" for the person who just lost /// a save, and "Permission denied (os error 13)" is the shape of that gap. +/// +/// This is the only way an `io::Error` should reach a person. Everything that +/// goes through [`HostOps::notify_err`] gets it for free; the handful of +/// places that build their own string have to ask. Opening a file used to +/// print `os error 13` while saving the same file explained itself, because +/// the save went through `notify_err` and the open did not. +/// +/// The raw error still belongs in the log next to the call — the two readers +/// want different things, so both are written rather than one chosen. pub fn explain_io(err: &std::io::Error) -> String { use std::io::ErrorKind; let key = match err.kind() {