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

+2 -2
View File
@@ -7,7 +7,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
- Rust 1.95, edition 2024. One binary crate, `elyterminal`.
- Ely (`ely-gpui-component`) from git.zacharyzhang.com, pinned by rev. gpui and gpui_platform from crates.io's `gpui-pre`, the exact version Ely pins, under their crate names through `package`.
- `alacritty_terminal` 0.26: grid, parser, pseudo-terminal, event loop.
- `serde`, `serde_json`: settings. `dirs`: the config folder. `log`, `env_logger`: logs to stderr.
- `serde`, `serde_json`: settings. `dirs`: the config folder. `log`, `env_logger`: logs to stderr. `unicode-normalization`, on macOS: Git's marks in composed form.
## Commands
@@ -61,5 +61,5 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
- 2026-10-14: the program hears focus in and out under mode 1004 when the terminal's focus or the window's activation changes. The window wears the program's title, the app's name while it names none. A bell is logged and makes no sound. A full reset, `ESC c`, empties alacritty's title and sends only `CursorBlinkingChange`, in the parser's order, synchronized updates too; on that event the view replays the title through `set_options`, which sends it.
- 2026-10-14: the title bar is transparent, as Ghostty's: the content runs under it, Ely's column headers drag the window, and the terminal's header shows the program's title. macOS draws its traffic lights at Ely's origin; elsewhere Ely draws the controls, so windows ask for client decorations, which only Linux reads, and Ely's `ResizeBorder` gives the edges. Cmd-B and Cmd-Alt-B (Ctrl-Shift-B and Ctrl-Shift-Alt-B off macOS) toggle the sessions and right columns, kept in the settings.
- 2026-10-14: session keys are Ghostty's: Cmd-T, Cmd-W, Cmd-1 to Cmd-8, Cmd-9 for the last, Ctrl-Tab and Ctrl-Shift-Tab; off macOS Ctrl-Shift-T, Ctrl-Shift-W and Alt with the digits. A digit past the last session goes on to the program. A new session runs the Shell setting in home or in the shown session's folder, as Start in says. A session whose program ends closes its row; the last row closes the window, and with no window left the app quits. A bell in a hidden session marks its row until it is shown. A hidden terminal hears no mouse release, so hiding it ends its mouse gesture as releases would: the selection drag ends, and the program hears each held button come up. A row's title is the program's, or the app's name while it names none.
- 2026-10-14: Files shows the shown session's folder in Ely's `Tree`, not `FileTree`, which takes every path up front and drops empty folders. A folder is read off the main thread when it first opens; the root and the open folders are read again whenever the session reports a folder, so at each prompt. Git's marks come from `git status --porcelain=v1 -z -- .`, run with `--no-optional-locks`, so it never holds `index.lock` against the user's own git, and under the user's `status.showUntrackedFiles`, so a home folder kept in git stays quick. An untracked folder marks what it holds. A program without shell integration gets a line in the right column until it reports a folder. A control in a column header keeps its presses: Ely's header zooms the window on any double press.
- 2026-10-14: Files shows the shown session's folder in Ely's `Tree`, not `FileTree`, which takes every path up front and drops empty folders. A folder is read off the main thread when it first opens; the root and the open folders are read again whenever the session reports a folder, so at each prompt. Git's marks come from `git status --porcelain=v1 -z -- .`, run with `--no-optional-locks`, so it never holds `index.lock` against the user's own git, and under the user's `status.showUntrackedFiles`, so a home folder kept in git stays quick. An untracked folder marks what it holds. On macOS, whose file systems ignore Unicode forms, a name keeps its form on disk and meets Git's mark in composed form, as git's `core.precomposeunicode` writes it. A label built from a path shows each control character as its picture, `␊` for a newline: gpui shapes no newline, and a file may be named with one. A program without shell integration gets a line in the right column until it reports a folder. A control in a column header keeps its presses: Ely's header zooms the window on any double press.
- 2026-10-14: settings refuse unknown fields; a bad settings file stops the start and is never overwritten.
Generated
+1
View File
@@ -1771,6 +1771,7 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"signal-hook",
"unicode-normalization",
]
[[package]]
+1
View File
@@ -32,6 +32,7 @@ signal-hook = "0.4"
[target.'cfg(target_os = "macos")'.dependencies]
block = "0.1"
objc = "0.2"
unicode-normalization = "0.1"
[target.'cfg(target_os = "macos")'.dev-dependencies]
core-graphics = { version = "0.24", features = ["highsierra"] }
+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]