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
This commit is contained in:
7 files changed
+167
-22
No files matched your search
+30
-12
@@ -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()),
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user