T13: a label built from a path shows control characters as their pictures, so a name with a newline no longer aborts gpui's line shaping in Files or the session row; on macOS a name meets Git's mark in composed form, as git writes it; the rev-parse prefix keeps its own newlines
CI / platforms (windows-latest) (push) Failing after 8s
CI / macos (push) Failing after 9m56s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-14 19:25:58 -04:00
1 parent 8369d96b36
commit 937dab8552
7 files changed
+167 -22

No files matched your search

+30 -12
View File
@@ -1,4 +1,5 @@
use std::{
borrow::Cow,
collections::{HashMap, HashSet},
fs, io,
path::{Path, PathBuf},
@@ -43,20 +44,37 @@ fn list(folder: &Path) -> Listing {
Ok(entries)
}
/// Changes by path, and the folders from `root` down that hold one.
/// `path` as marks are keyed: on macOS, whose file systems ignore Unicode forms, composed as Git writes it.
fn mark_key(path: &Path) -> Cow<'_, Path> {
#[cfg(target_os = "macos")]
if let Some(text) = path.to_str()
&& !unicode_normalization::is_nfc(text)
{
use unicode_normalization::UnicodeNormalization;
return Cow::Owned(text.nfc().collect::<String>().into());
}
Cow::Borrowed(path)
}
/// Changes by their keys, and the folders from `root` down that hold one.
fn marks(
root: &Path,
changes: Vec<(PathBuf, GitStatus)>,
) -> (HashMap<PathBuf, GitStatus>, HashSet<PathBuf>) {
let dirty = changes
.iter()
.flat_map(|(path, _)| {
let root = mark_key(root);
let marks: HashMap<PathBuf, GitStatus> = changes
.into_iter()
.map(|(path, status)| (mark_key(&path).into_owned(), status))
.collect();
let dirty = marks
.keys()
.flat_map(|path| {
let up = path.ancestors().skip(1);
up.take_while(|folder| folder.starts_with(root))
up.take_while(|folder| folder.starts_with(&root))
})
.map(Path::to_path_buf)
.collect();
(changes.into_iter().collect(), dirty)
(marks, dirty)
}
/// Git's mark on a node: its letter and tint; a dot on a folder that holds changes.
@@ -191,7 +209,7 @@ impl Files {
/// Folder `path`'s node, its entries once read; `untracked` when an untracked folder holds it.
fn folder(&self, path: &Path, label: String, untracked: bool) -> TreeNode {
let own = |path: &Path| self.marks.get(path).copied();
let own = |path: &Path| self.marks.get(mark_key(path).as_ref()).copied();
let mark = own(path).or(untracked.then_some(GitStatus::Untracked));
let inside = mark == Some(GitStatus::Untracked);
let node = TreeNode::new(key(path), label).icon(IconName::Folder);
@@ -199,11 +217,11 @@ impl Files {
None => node.pending(),
Some(Err(_)) => return node.children([]).note("unreadable").tone(Tone::Danger),
Some(Ok(entries)) => node.children(entries.iter().map(|entry| {
let path = path.join(&entry.name);
let (path, label) = (path.join(&entry.name), sessions::one_line(&entry.name));
if entry.folder {
return self.folder(&path, entry.name.clone(), inside);
return self.folder(&path, label, inside);
}
let file = TreeNode::new(key(&path), entry.name.clone()).icon(IconName::File);
let file = TreeNode::new(key(&path), label).icon(IconName::File);
badge(
file,
own(&path).or(inside.then_some(GitStatus::Untracked)),
@@ -211,7 +229,7 @@ impl Files {
)
})),
};
badge(node, mark, self.dirty.contains(path))
badge(node, mark, self.dirty.contains(mark_key(path).as_ref()))
}
}
@@ -220,7 +238,7 @@ impl Render for Files {
let home = dirs::home_dir();
let label = match self.root.file_name() {
Some(name) if home.as_deref() != Some(self.root.as_path()) => {
name.to_string_lossy().into()
sessions::one_line(&name.to_string_lossy())
}
_ => sessions::shown(&self.root, home.as_deref()),
};
+84
View File
@@ -133,3 +133,87 @@ fn the_tree_wears_marks_and_an_untracked_folder_marks_what_it_holds() {
);
assert_eq!(files.folder(&root, "r".into(), false), expected);
}
#[test]
fn names_with_newlines_label_on_one_line_and_keep_their_paths() {
let root = PathBuf::from("/r");
let (folder, file) = (root.join("line\nbreak"), root.join("report\n2026.txt"));
let mut files = Files::new(root.clone());
let entries = vec![
Entry {
name: "line\nbreak".into(),
folder: true,
},
Entry {
name: "report\n2026.txt".into(),
folder: false,
},
];
files.listings.insert(root.clone(), Ok(entries));
let expected = TreeNode::new(key(&root), "r")
.icon(IconName::Folder)
.children([
TreeNode::new(key(&folder), "line␊break")
.icon(IconName::Folder)
.pending(),
TreeNode::new(key(&file), "report␊2026.txt").icon(IconName::File),
]);
assert_eq!(files.folder(&root, "r".into(), false), expected);
}
/// Runs git in `root` as a fixed author, unsigned.
#[cfg(target_os = "macos")]
fn run(root: &Path, args: &[&str]) {
let done = std::process::Command::new("git")
.arg("-C")
.arg(root)
.args(["-c", "user.name=Ely", "-c", "user.email=ely@example.com"])
.args(["-c", "commit.gpgsign=false"])
.args(args)
.output()
.expect("git runs");
let error = String::from_utf8_lossy(&done.stderr);
assert!(done.status.success(), "git {args:?}: {error}");
}
#[cfg(target_os = "macos")]
#[test]
fn decomposed_names_on_disk_wear_the_composed_marks_git_writes() {
let root = folder("files-nfd");
run(&root, &["init", "-q"]);
run(&root, &["config", "core.precomposeunicode", "true"]);
run(&root, &["config", "status.showUntrackedFiles", "normal"]);
let (dossier, tracked, untracked) = (
"dossier-e\u{301}",
"tracked-e\u{301}.txt",
"new-e\u{301}.txt",
);
fs::create_dir(root.join(dossier)).expect("a folder");
fs::write(root.join(dossier).join("inner.txt"), "first").expect("a file");
fs::write(root.join(tracked), "first").expect("a file");
run(&root, &["add", "."]);
run(&root, &["commit", "-qm", "first"]);
fs::write(root.join(dossier).join("inner.txt"), "second").expect("an edit");
fs::write(root.join(tracked), "second").expect("an edit");
fs::write(root.join(untracked), "new").expect("an untracked file");
let mut files = Files::new(root.clone());
files.listings.insert(root.clone(), list(&root));
let changes = git::changes(&root).expect("status").expect("a work tree");
(files.marks, files.dirty) = marks(&root, changes);
let node = |name: &str, icon| TreeNode::new(key(&root.join(name)), name).icon(icon);
let dotted = |node: TreeNode| node.note("•").tone(Tone::Warning);
let expected = dotted(
TreeNode::new(key(&root), "r")
.icon(IconName::Folder)
.children([
node(".git", IconName::Folder).pending(),
dotted(node(dossier, IconName::Folder).pending()),
node(untracked, IconName::File)
.note("U")
.tone(Tone::Success),
node(tracked, IconName::File).note("M").tone(Tone::Warning),
]),
);
assert_eq!(files.folder(&root, "r".into(), false), expected);
}
+24 -5
View File
@@ -36,11 +36,15 @@ pub fn changes(folder: &Path) -> anyhow::Result<Option<Vec<(PathBuf, GitStatus)>
);
}
let place = String::from_utf8_lossy(&place.stdout);
let mut lines = place.lines();
if lines.next() != Some("true") {
return Ok(None);
}
let prefix = lines.next().context("git: rev-parse printed no prefix")?;
// The prefix may hold newlines of its own.
let answer = place
.strip_suffix('\n')
.and_then(|place| place.split_once('\n'));
let prefix = match answer {
Some(("true", prefix)) => prefix,
Some(("false", _)) => return Ok(None),
_ => bail!("git: rev-parse in {} printed {place:?}", folder.display()),
};
let status = git(folder, &["status", "--porcelain=v1", "-z", "--", "."])?;
if !status.status.success() {
let error = String::from_utf8_lossy(&status.stderr);
@@ -215,4 +219,19 @@ mod tests {
assert_eq!(changes(&root.join(".git")).expect("rev-parse"), None);
assert_eq!(changes(&folder("git-outside")).expect("rev-parse"), None);
}
#[cfg(unix)]
#[test]
fn a_folder_named_with_a_newline_keeps_its_whole_prefix() {
let root = folder("git-newline");
run(&root, &["init", "-q"]);
let inner = root.join("line\nbreak");
fs::create_dir_all(&inner).expect("a folder");
fs::write(inner.join("tracked.txt"), "first").expect("a file");
run(&root, &["add", "."]);
run(&root, &["commit", "-qm", "first"]);
fs::write(inner.join("tracked.txt"), "second").expect("an edit");
let edit = (inner.join("tracked.txt"), GitStatus::Modified);
assert_eq!(changes(&inner).expect("status"), Some(vec![edit]));
}
}
+25 -3
View File
@@ -27,13 +27,25 @@ pub struct Session {
pub _events: Subscription,
}
/// `folder` as a row shows it: the home folder as `~`.
/// `folder` as a row shows it: the home folder as `~`, on one line.
pub fn shown(folder: &Path, home: Option<&Path>) -> String {
match home.and_then(|home| folder.strip_prefix(home).ok()) {
let shown = match home.and_then(|home| folder.strip_prefix(home).ok()) {
Some(rest) if rest.as_os_str().is_empty() => "~".into(),
Some(rest) => format!("~{MAIN_SEPARATOR}{}", rest.display()),
None => folder.display().to_string(),
}
};
one_line(&shown)
}
/// `text` on one line, as gpui shapes labels: each control character as its picture, `␊` for a newline.
pub fn one_line(text: &str) -> String {
text.chars()
.map(|c| match c {
'\0'..='\x1f' => char::from_u32(0x2400 + c as u32).expect("a control picture"),
'\x7f' => '\u{2421}',
c => c,
})
.collect()
}
/// The session shown once session `closed` of `left + 1` is gone, `active` having been shown.
@@ -102,6 +114,16 @@ mod tests {
"a sibling is no child"
);
assert_eq!(shown("/tmp"), "/tmp");
assert_eq!(
shown("/Users/ely/line\nbreak"),
format!("~{MAIN_SEPARATOR}line␊break")
);
}
#[test]
fn control_characters_show_as_their_pictures() {
assert_eq!(one_line("a\nb\tc\rd\0e\u{7f}"), "a␊b␉c␍d␀e␡");
assert_eq!(one_line("café ✓"), "café ✓", "printable text stays");
}
#[test]