From 029aa3e988eac36fa15e35ced436bfca30f1cc1d Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 23 Aug 2026 07:11:48 +0800 Subject: [PATCH] fix(scm): fold a status path where the panel and the detail draw it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `git::status` asks for `--porcelain=v2 -z`, and its own comment says why: without `-z` "any path with a space, a quote or a newline comes back C-quoted". Raw is the right thing to read — it is what opens the file — and the wrong thing to draw. A filename is bytes to the kernel, `touch $'a\nb'` makes one, and the row it lands in has a fixed height that a mandatory break grows past and over its neighbour. The file tree met this and folds its own names. The source-control panel and the commit detail read their paths from a different place and arrived at the shared `split_display_path` unfolded, so the eighth site in this tree is the one place two views share. Folded there, which is why it now hands back owned strings instead of borrows — and on both halves, because a directory carries a newline exactly as easily as a file does. Checked against an injected regression, and the existing assertions about how the split itself behaves are unchanged apart from their types. --- CHANGELOG.md | 9 ++++++ src/ui/scm/path.rs | 71 ++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a14b4ab..6f0cef7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -188,6 +188,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 expected f32 at line 1 column 20`. The window's notice appends it too, on its own line after the translated sentence. +- **A changed file's name draws on one row of the source-control panel.** + tty7 asks git for `--porcelain=v2 -z` precisely so paths arrive raw rather + than C-quoted, which is right for opening a file and wrong for drawing one: + a filename is bytes to the kernel, `touch $'a\nb'` makes one, and the row it + lands in has a fixed height. The file tree already folded its own names; the + source-control panel and the commit detail read theirs from a different place + and reached the shared label helper unfolded. Both halves fold now — a + directory can carry one just as easily. + - **A commit subject draws on one row of the graph.** git keeps a `\r`, `\t` or `\v` in a subject verbatim and `git log --format=%s` returns it that way, so any repository you clone can carry one — and the graph row is a fixed diff --git a/src/ui/scm/path.rs b/src/ui/scm/path.rs index 542a1cbb..9cc73ee5 100644 --- a/src/ui/scm/path.rs +++ b/src/ui/scm/path.rs @@ -2,6 +2,7 @@ //! 260px column. All pure, all cheap, all unit-tested — the panel calls these //! once per visible row per frame. +use crate::terminal::view::one_line; use std::borrow::Cow; use unicode_segmentation::UnicodeSegmentation as _; @@ -14,14 +15,25 @@ const ELLIPSIS: char = '…'; /// The panel renders these as two runs with different sizes and colours, so /// they have to come back as separate slices rather than one pre-joined /// string. A path with no directory gets an empty second half. -pub(crate) fn split_display_path(rel: &str) -> (&str, &str) { +pub(crate) fn split_display_path(rel: &str) -> (String, String) { // A trailing slash means the caller handed us a directory; the last // component is still the name, so drop the slash before splitting. let trimmed = rel.strip_suffix('/').unwrap_or(rel); - match trimmed.rsplit_once('/') { - Some((dir, name)) => (name, dir), - None => (trimmed, ""), - } + let (dir, name) = match trimmed.rsplit_once('/') { + Some((dir, name)) => (dir, name), + None => ("", trimmed), + }; + // Folded, and that is why this hands back owned strings rather than + // borrows. A path here comes out of `git status --porcelain=v2 -z`, which + // this tree asks for precisely so paths arrive *raw* — the `-z` comment in + // `git::status` says as much: without it "any path with a space, a quote or + // a newline comes back C-quoted". Raw is right for opening the file and + // wrong for drawing it: a filename is bytes to the kernel, `touch $'a\nb'` + // makes one, and a row that breaks grows past its height and paints over + // the row below. + // + // Both halves, because a *directory* can carry one just as easily. + (one_line(name), one_line(dir)) } /// Keep the head and the tail, drop the middle. Paths and branch names both @@ -97,21 +109,56 @@ pub(crate) fn relative_time(now_unix: i64, then_unix: i64) -> String { mod tests { use super::*; + /// A path from git status draws on one row, both halves of it. + /// + /// This tree asks git for `--porcelain=v2 -z` precisely so paths arrive + /// *raw* — the comment on that flag says without it "any path with a + /// space, a quote or a newline comes back C-quoted". Raw is right for + /// opening the file and wrong for drawing it: a filename is bytes to the + /// kernel, `touch $'a\nb'` makes one, and the row it lands in has a fixed + /// height that a mandatory break grows past. + /// + /// The file tree already folds its own names for exactly this reason; the + /// source-control panel and the commit detail read their paths from a + /// different place and reached this helper unfolded. + #[test] + fn a_status_path_with_control_characters_folds_on_both_halves() { + assert_eq!( + split_display_path("src/weird\nname.txt"), + ("weird↵name.txt".to_string(), "src".to_string()) + ); + assert_eq!( + split_display_path("odd\ndir/plain.txt"), + ("plain.txt".to_string(), "odd↵dir".to_string()), + "a directory can carry one just as easily" + ); + assert_eq!( + split_display_path("a\tb/c\rd"), + ("c d".to_string(), "a b".to_string()), + "every control character, not only the newline" + ); + assert_eq!( + split_display_path("src/ui/项目.rs"), + ("项目.rs".to_string(), "src/ui".to_string()), + "nothing else is touched" + ); + } + #[test] fn split_display_path_separates_the_name_from_its_directory() { - assert_eq!(split_display_path("src/ui/app.rs"), ("app.rs", "src/ui")); - assert_eq!(split_display_path("README.md"), ("README.md", "")); - assert_eq!(split_display_path("a/b"), ("b", "a")); - assert_eq!(split_display_path(""), ("", "")); + assert_eq!(split_display_path("src/ui/app.rs"), ("app.rs".to_string(), "src/ui".to_string())); + assert_eq!(split_display_path("README.md"), ("README.md".to_string(), "".to_string())); + assert_eq!(split_display_path("a/b"), ("b".to_string(), "a".to_string())); + assert_eq!(split_display_path(""), ("".to_string(), "".to_string())); } #[test] fn split_display_path_ignores_a_trailing_slash() { - assert_eq!(split_display_path("src/ui/"), ("ui", "src")); - assert_eq!(split_display_path("src/"), ("src", "")); + assert_eq!(split_display_path("src/ui/"), ("ui".to_string(), "src".to_string())); + assert_eq!(split_display_path("src/"), ("src".to_string(), "".to_string())); // A leading slash leaves an empty directory half rather than dropping // the root — the caller decides how to render that. - assert_eq!(split_display_path("/etc"), ("etc", "")); + assert_eq!(split_display_path("/etc"), ("etc".to_string(), "".to_string())); } #[test]