T13: the right column's Git and Files switch, kept in settings; Files shows the shown session's folder as a tree, folders first, Git's marks on names from git status without optional locks, unreadable folders noted; a double press opens a folder, or a file in the system's app; it reloads when the session reports a folder, so at each prompt; a program without shell integration gets a line saying it reports no folder; header controls keep their presses from the header's double-press zoom
CI / platforms (windows-latest) (push) Failing after 8s
CI / macos (push) Failing after 9m12s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-14 19:09:21 -04:00
1 parent 2ddc40d939
commit 8369d96b36
11 files changed
+700 -27

No files matched your search

+218
View File
@@ -0,0 +1,218 @@
use std::{
path::{Path, PathBuf},
process::{Command, Output},
};
use anyhow::{Context as _, bail};
use ely_gpui_component::lists::GitStatus;
/// Runs git in `folder` with English messages, taking no lock the user's own git could meet.
fn git(folder: &Path, args: &[&str]) -> anyhow::Result<Output> {
Command::new("git")
.arg("-C")
.arg(folder)
.arg("--no-optional-locks")
.args(args)
.env("LC_ALL", "C")
.output()
.with_context(|| format!("git: `git {}` did not start", args.join(" ")))
}
/// Each change at or under `folder`, by its full path; none outside a work tree.
pub fn changes(folder: &Path) -> anyhow::Result<Option<Vec<(PathBuf, GitStatus)>>> {
let place = git(
folder,
&["rev-parse", "--is-inside-work-tree", "--show-prefix"],
)?;
if !place.status.success() {
let error = String::from_utf8_lossy(&place.stderr);
if error.contains("not a git repository") {
return Ok(None);
}
bail!(
"git: rev-parse in {} failed: {}",
folder.display(),
error.trim()
);
}
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")?;
let status = git(folder, &["status", "--porcelain=v1", "-z", "--", "."])?;
if !status.status.success() {
let error = String::from_utf8_lossy(&status.stderr);
bail!(
"git: status in {} failed: {}",
folder.display(),
error.trim()
);
}
parse(&status.stdout)?
.into_iter()
.map(|(path, status)| {
let Some(rest) = path.strip_prefix(prefix) else {
bail!("git: {path} lies outside {prefix}");
};
let parts = rest.split('/').filter(|part| !part.is_empty());
Ok((
parts.fold(folder.to_path_buf(), |path, part| path.join(part)),
status,
))
})
.collect::<anyhow::Result<_>>()
.map(Some)
}
/// Porcelain v1 records, each ended by NUL: `XY path`, a rename's or copy's old path after it.
fn parse(out: &[u8]) -> anyhow::Result<Vec<(String, GitStatus)>> {
let mut fields = out
.split(|&byte| byte == 0)
.filter(|field| !field.is_empty());
let mut changes = Vec::new();
while let Some(field) = fields.next() {
let record = || String::from_utf8_lossy(field);
let (x, y, path) = match field {
[x, y, b' ', path @ ..] if !path.is_empty() => (*x, *y, path),
_ => bail!("git: status record {:?} is malformed", record()),
};
let Some(status) = kind(x, y) else {
bail!("git: status record {:?} has an unknown code", record());
};
if [x, y].iter().any(|code| matches!(code, b'R' | b'C')) {
fields
.next()
.with_context(|| format!("git: {:?} lacks its old path", record()))?;
}
changes.push((String::from_utf8_lossy(path).into_owned(), status));
}
Ok(changes)
}
/// One mark for a record's index and work tree codes; a copy is a new file.
fn kind(x: u8, y: u8) -> Option<GitStatus> {
Some(match (x, y) {
(b'?', b'?') => GitStatus::Untracked,
(b'U', _) | (_, b'U') | (b'A', b'A') | (b'D', b'D') => GitStatus::Conflicted,
(b'D', _) | (_, b'D') => GitStatus::Deleted,
(b'R', _) | (_, b'R') => GitStatus::Renamed,
(b'A' | b'C', _) | (_, b'A' | b'C') => GitStatus::Added,
(b'M' | b'T', _) | (_, b'M' | b'T') => GitStatus::Modified,
_ => return None,
})
}
#[cfg(test)]
mod tests {
use std::{env, fs};
use super::*;
/// A fresh folder under the temp dir.
fn folder(name: &str) -> PathBuf {
let folder = env::temp_dir().join(format!("elyterminal-{name}-{}", std::process::id()));
if folder.exists() {
fs::remove_dir_all(&folder).expect("a stale test folder goes");
}
fs::create_dir_all(&folder).expect("a test folder");
folder
}
/// Runs git in `folder` as a fixed author, unsigned.
fn run(folder: &Path, args: &[&str]) {
let done = Command::new("git")
.arg("-C")
.arg(folder)
.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}");
}
#[test]
fn records_read_as_marks_skipping_old_paths() {
let out = b"RM new.rs\0old.rs\0 M src/a.rs\0?? notes/\0AA both.txt\0UU clash.rs\0\
A added.rs\0 D gone.rs\0C copy.rs\0from.rs\0 T link\0MM twice.rs\0DU kept.rs\0";
let marks = parse(out).expect("records");
let expected = [
("new.rs", GitStatus::Renamed),
("src/a.rs", GitStatus::Modified),
("notes/", GitStatus::Untracked),
("both.txt", GitStatus::Conflicted),
("clash.rs", GitStatus::Conflicted),
("added.rs", GitStatus::Added),
("gone.rs", GitStatus::Deleted),
("copy.rs", GitStatus::Added),
("link", GitStatus::Modified),
("twice.rs", GitStatus::Modified),
("kept.rs", GitStatus::Conflicted),
]
.map(|(path, status)| (path.to_string(), status));
assert_eq!(marks, expected);
}
#[test]
fn records_git_never_writes_are_errors() {
for out in [
&b"M\0"[..],
b"MM \0",
b"!! ignored\0",
b"R new.rs\0",
b" x\0",
] {
assert!(parse(out).is_err(), "{:?}", String::from_utf8_lossy(out));
}
}
#[test]
fn a_work_tree_reports_changes_under_the_folder() {
let root = folder("git");
run(&root, &["init", "-q"]);
run(&root, &["config", "status.showUntrackedFiles", "normal"]);
fs::create_dir_all(root.join("src/inner")).expect("folders");
for (path, text) in [
("kept.txt", "kept"),
("gone.txt", "gone"),
("src/main.rs", "fn main() {}"),
("src/inner/deep.rs", "deep"),
] {
fs::write(root.join(path), text).expect("a file");
}
run(&root, &["add", "."]);
run(&root, &["commit", "-qm", "first"]);
fs::write(root.join("src/main.rs"), "fn main() { }").expect("an edit");
fs::remove_file(root.join("gone.txt")).expect("a removal");
fs::write(root.join("added.txt"), "new").expect("a new file");
run(&root, &["add", "added.txt"]);
fs::create_dir_all(root.join("notes/sub")).expect("an untracked folder");
fs::write(root.join("notes/sub/todo.txt"), "todo").expect("an untracked file");
let mut all = changes(&root).expect("status").expect("a work tree");
all.sort_by(|a, b| a.0.cmp(&b.0));
let expected = [
("added.txt", GitStatus::Added),
("gone.txt", GitStatus::Deleted),
("notes", GitStatus::Untracked),
("src/main.rs", GitStatus::Modified),
]
.map(|(path, status)| (root.join(path), status));
assert_eq!(all, expected);
let src = root.join("src");
let main = (src.join("main.rs"), GitStatus::Modified);
assert_eq!(changes(&src).expect("status"), Some(vec![main]));
let sub = root.join("notes").join("sub");
let whole = (sub.clone(), GitStatus::Untracked);
assert_eq!(
changes(&sub).expect("status"),
Some(vec![whole]),
"a folder inside an untracked one is untracked whole"
);
assert_eq!(changes(&root.join(".git")).expect("rev-parse"), None);
assert_eq!(changes(&folder("git-outside")).expect("rev-parse"), None);
}
}