fix(scm): fold a status path where the panel and the detail draw it

`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.
This commit is contained in:
l0ng-ai
2026-08-23 07:11:48 +08:00
parent 95b31cd64b
commit 029aa3e988
2 changed files with 68 additions and 12 deletions
+9
View File
@@ -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
+59 -12
View File
@@ -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]