mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
fix(scm): close out the review's minor findings across the data and UI layers
The second pass over the branch review: every remaining finding verified against the code, the real ones fixed. Data layer: - A truncated log parse is never called complete: RecordSplitter drops an overlong record whole and reports the count (delivered cut short, a commit body cut mid-way reads as the real message), parse_log carries a truncated flag past MAX_LOG_BYTES, and load_page only says "end of history" when the parse read everything git returned. - Every scope pins symbolic revs to shas before walking, so a commit landing between two pages can no longer shift where page two starts under Head and Refs scopes; unresolvable names read as "no history" rather than as a load failure. --parents was doing nothing and is gone; edge sort is stable so a merge's Outs keep first-parent order. - The lane model's central invariant now names the join case — a merge whose second parent already has a lane reserved sends its Out onto that lane, one line below the cut, not two — with a golden test for the commonest merge topology of all, which no golden covered. - DiffSource revs get the same could-be-an-option guard log already had; C-quoted paths decode the full escape set (a tab decoded to a literal t broke the :(literal) re-probe); rename from/to lines override the ambiguous diff --git header; combined-diff line numbers follow the sides rather than the colour, so a " +" line no longer drifts every number below it. - A rename's old path stays out of the per-file decoration map, where it outranked a file re-created at that path; ignored records decorate as Ignored, not Modified; checkout <branch> gains the trailing -- that keeps a stale name from falling back to a worktree-clobbering path checkout; unstage before the first commit takes -f (worktree- safe with --cached); batches split by bytes as well as count for Windows' 32K command line; a deadline expiry reports Timeout, not "git could not be run"; error details keep both streams. - probe_status distinguishes "not a repository" from "could not ask": a dropped link keeps the cached status (stale beats blank) and rests 10s instead of erasing the panel, while a definitive not-a-repo also drops the cwd→root mappings so the panel stops drawing Loading for a repository that is gone. Probe and watch work are wrapped against panics that would wedge their in-flight bookkeeping forever, watch landings check the wipe counter, superseded probes relaunch through the debounce, and a refused network slot says so instead of eating the click. UI: - Reset --hard confirms with its own words (commits fall off the branch), not the discard dialog's; a merge commit whose prefilled message the user cleared is committable again; the disabled commit button distinguishes "nothing to commit" from "write a message". - Selection highlight matches on the diff source too, so a file staged and edited again no longer lights both of its rows for one overlay. - The graph materializes only the rows in the viewport window (5000 flex children per frame was most of a frame), row clicks carry the page Arc and an index instead of a deep Commit clone per row per frame, filter results are cached per (page, query), and a selected merge ring's hole matches the selection band under it. - A failed commit_files read says the list could not be read instead of "0 files changed"; the STAGED chip and the graph's relative times go through the i18n table; the keys-awaiting-a-caller list is pruned to the seven that still are; the orphaned PanelUntracked key is gone; the zh commit placeholder reads naturally. 2398 tests, 0 failures. Known flake: daemon::singleton's second-claim test, untouched by this branch, fails ~1 in 3 full parallel runs and passes alone.
This commit is contained in:
@@ -163,6 +163,23 @@ impl DiffSource {
|
||||
pub fn lists_untracked(&self) -> bool {
|
||||
matches!(self, DiffSource::Worktree | DiffSource::Head)
|
||||
}
|
||||
|
||||
/// Whether every rev this source carries can be handed to git as an
|
||||
/// argument. The same rule log's `is_rev` applies: a rev the caller made
|
||||
/// up is still a rev git will be handed, and anything that could be read
|
||||
/// as an option — `--output=…` most damningly — is refused before it
|
||||
/// reaches an argv. Checked by [`probe_diff`], so no in-tree caller can
|
||||
/// forget it.
|
||||
fn revs_are_arguments(&self) -> bool {
|
||||
let ok = |rev: &str| {
|
||||
!rev.is_empty() && !rev.starts_with('-') && !rev.contains(|c: char| c.is_control())
|
||||
};
|
||||
match self {
|
||||
DiffSource::Worktree | DiffSource::Staged | DiffSource::Head => true,
|
||||
DiffSource::Commit { rev, .. } => ok(rev),
|
||||
DiffSource::Range { base, head } => ok(base) && ok(head),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn strings(args: &[&str]) -> Vec<String> {
|
||||
@@ -358,6 +375,9 @@ pub fn probe(host: &dyn Host, cwd: &Path) -> Option<DiffSnapshot> {
|
||||
}
|
||||
|
||||
pub fn probe_diff(host: &dyn Host, root: &Path, req: &DiffRequest<'_>) -> Option<DiffSnapshot> {
|
||||
if !req.source.revs_are_arguments() {
|
||||
return None;
|
||||
}
|
||||
let toplevel = git::git(host, root, &["rev-parse", "--show-toplevel"])?;
|
||||
let toplevel = PathBuf::from(toplevel.trim_end_matches(['\n', '\r']));
|
||||
let branch = git::branch_name(host, root)?;
|
||||
@@ -483,12 +503,25 @@ impl DiffParser {
|
||||
file.status = FileStatus::Deleted;
|
||||
return;
|
||||
}
|
||||
if line.starts_with("rename from ") {
|
||||
// These four carry one unambiguous path each — unlike the `diff --git`
|
||||
// header, where two unquoted paths containing ` b/` cannot be split
|
||||
// reliably (git does not quote a path for a mere space). They land
|
||||
// after the header, so what they say overrides what it guessed.
|
||||
if let Some(old) = line.strip_prefix("rename from ") {
|
||||
file.status = FileStatus::Renamed;
|
||||
file.old_path = Some(unquote_path(old));
|
||||
return;
|
||||
}
|
||||
if line.starts_with("copy from ") {
|
||||
if let Some(old) = line.strip_prefix("copy from ") {
|
||||
file.status = FileStatus::Copied;
|
||||
file.old_path = Some(unquote_path(old));
|
||||
return;
|
||||
}
|
||||
if let Some(new) = line
|
||||
.strip_prefix("rename to ")
|
||||
.or_else(|| line.strip_prefix("copy to "))
|
||||
{
|
||||
file.path = unquote_path(new);
|
||||
return;
|
||||
}
|
||||
if let Some(mode) = line.strip_prefix("old mode ") {
|
||||
@@ -542,7 +575,7 @@ impl DiffParser {
|
||||
if !self.in_hunk {
|
||||
return;
|
||||
}
|
||||
let Some((kind, text)) = split_body_line(line, self.markers) else {
|
||||
let Some((kind, sides, text)) = split_body_line(line, self.markers) else {
|
||||
return;
|
||||
};
|
||||
match kind {
|
||||
@@ -565,24 +598,19 @@ impl DiffParser {
|
||||
let Some(hunk) = file.hunks.last_mut() else {
|
||||
return;
|
||||
};
|
||||
let (o, n) = match kind {
|
||||
LineKind::Added => {
|
||||
let n = self.new_no;
|
||||
self.new_no += 1;
|
||||
(None, Some(n))
|
||||
}
|
||||
LineKind::Removed => {
|
||||
let o = self.old_no;
|
||||
self.old_no += 1;
|
||||
(Some(o), None)
|
||||
}
|
||||
LineKind::Context => {
|
||||
let (o, n) = (self.old_no, self.new_no);
|
||||
self.old_no += 1;
|
||||
self.new_no += 1;
|
||||
(Some(o), Some(n))
|
||||
}
|
||||
};
|
||||
// Numbered by side, not by colour: in a combined diff a ` +` line is
|
||||
// painted as an addition but *exists* in the first parent, and the
|
||||
// old-side counter has to walk past it or every number below drifts.
|
||||
let o = sides.in_old.then(|| {
|
||||
let o = self.old_no;
|
||||
self.old_no += 1;
|
||||
o
|
||||
});
|
||||
let n = sides.in_new.then(|| {
|
||||
let n = self.new_no;
|
||||
self.new_no += 1;
|
||||
n
|
||||
});
|
||||
hunk.lines.push(DiffLine {
|
||||
kind,
|
||||
old_no: o,
|
||||
@@ -648,10 +676,19 @@ fn object_type(mode: &str) -> &str {
|
||||
&mode[..mode.len().min(3)]
|
||||
}
|
||||
|
||||
/// Splits a hunk body line into its kind and its text, given how many marker
|
||||
/// columns the hunk carries. A combined diff marks a line per parent; one `+`
|
||||
/// or `-` anywhere in those columns settles what happened to the line.
|
||||
fn split_body_line(line: &str, markers: usize) -> Option<(LineKind, &str)> {
|
||||
/// Splits a hunk body line into its kind, which sides it exists on, and its
|
||||
/// text, given how many marker columns the hunk carries. A combined diff marks
|
||||
/// a line per parent; one `+` or `-` anywhere in those columns settles the
|
||||
/// *colour*, but the line numbers come from the sides:
|
||||
///
|
||||
/// - the line is in the result iff no column says `-`;
|
||||
/// - the line is in the first parent — the side tty7 numbers — iff its own
|
||||
/// column says `-`, or says ` ` on a line that is in the result. (` ` on a
|
||||
/// line outside the result is the other parent's removal; the first parent
|
||||
/// never had it.)
|
||||
///
|
||||
/// For an ordinary one-column diff this reduces to exactly `+`/`-`/context.
|
||||
fn split_body_line(line: &str, markers: usize) -> Option<(LineKind, LineSides, &str)> {
|
||||
let head = line.get(..markers)?;
|
||||
let kind = if head.contains('+') {
|
||||
LineKind::Added
|
||||
@@ -662,7 +699,17 @@ fn split_body_line(line: &str, markers: usize) -> Option<(LineKind, &str)> {
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some((kind, &line[markers..]))
|
||||
let in_new = !head.contains('-');
|
||||
let first = head.as_bytes().first().copied();
|
||||
let in_old = first == Some(b'-') || (first == Some(b' ') && in_new);
|
||||
Some((kind, LineSides { in_old, in_new }, &line[markers..]))
|
||||
}
|
||||
|
||||
/// Which sides of the diff a body line exists on. See [`split_body_line`].
|
||||
#[derive(Clone, Copy)]
|
||||
struct LineSides {
|
||||
in_old: bool,
|
||||
in_new: bool,
|
||||
}
|
||||
|
||||
fn is_hunk_line(line: &str) -> bool {
|
||||
@@ -696,10 +743,15 @@ fn parse_quoted_pair(s: &str) -> Vec<String> {
|
||||
continue;
|
||||
}
|
||||
match ch {
|
||||
'\\' if in_quote => escaped = true,
|
||||
// The backslash stays in `cur`: the scanner only needs to know the
|
||||
// next `"` does not close the quote; `c_unescape` does the decode.
|
||||
'\\' if in_quote => {
|
||||
cur.push('\\');
|
||||
escaped = true;
|
||||
}
|
||||
'"' => {
|
||||
if in_quote {
|
||||
parts.push(std::mem::take(&mut cur));
|
||||
parts.push(c_unescape(&std::mem::take(&mut cur)));
|
||||
}
|
||||
in_quote = !in_quote;
|
||||
}
|
||||
@@ -710,6 +762,67 @@ fn parse_quoted_pair(s: &str) -> Vec<String> {
|
||||
parts
|
||||
}
|
||||
|
||||
/// A single path as written after `rename from ` and friends: C-quoted when it
|
||||
/// carries a character git always quotes (a control character or a `"` —
|
||||
/// `core.quotePath=false` stops the quoting of non-ASCII only), bare
|
||||
/// otherwise.
|
||||
fn unquote_path(s: &str) -> String {
|
||||
let s = s.trim_end_matches(['\n', '\r']);
|
||||
match s
|
||||
.strip_prefix('"')
|
||||
.and_then(|inner| inner.strip_suffix('"'))
|
||||
{
|
||||
Some(inner) => c_unescape(inner),
|
||||
None => s.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes the C escapes git writes inside a quoted path — the full set, not
|
||||
/// just `\"` and `\\`: a path with a real tab arrives as `\t`, and decoding it
|
||||
/// to a literal `t` breaks the `:(literal)` re-probe for that row. Octal
|
||||
/// escapes are *bytes* — a multi-byte character arrives as several — so the
|
||||
/// value is assembled as bytes and read back as UTF-8 at the end.
|
||||
fn c_unescape(s: &str) -> String {
|
||||
let mut bytes: Vec<u8> = Vec::with_capacity(s.len());
|
||||
let mut push_char = |bytes: &mut Vec<u8>, ch: char| {
|
||||
let mut buf = [0u8; 4];
|
||||
bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
|
||||
};
|
||||
let mut it = s.chars().peekable();
|
||||
while let Some(ch) = it.next() {
|
||||
if ch != '\\' {
|
||||
push_char(&mut bytes, ch);
|
||||
continue;
|
||||
}
|
||||
match it.next() {
|
||||
Some(digit @ '0'..='7') => {
|
||||
let mut value = digit as u32 - '0' as u32;
|
||||
for _ in 0..2 {
|
||||
match it.peek() {
|
||||
Some(&next @ '0'..='7') => {
|
||||
value = value * 8 + (next as u32 - '0' as u32);
|
||||
it.next();
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
bytes.push(value as u8);
|
||||
}
|
||||
Some('n') => bytes.push(b'\n'),
|
||||
Some('t') => bytes.push(b'\t'),
|
||||
Some('r') => bytes.push(b'\r'),
|
||||
Some('a') => bytes.push(0x07),
|
||||
Some('b') => bytes.push(0x08),
|
||||
Some('f') => bytes.push(0x0c),
|
||||
Some('v') => bytes.push(0x0b),
|
||||
// `\"`, `\\`, and anything git never writes: the character itself.
|
||||
Some(other) => push_char(&mut bytes, other),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
|
||||
fn strip_prefix_ab(p: &str) -> String {
|
||||
p.strip_prefix("a/")
|
||||
.or_else(|| p.strip_prefix("b/"))
|
||||
@@ -922,9 +1035,9 @@ Binary files a/img.png and b/img.png differ
|
||||
|
||||
#[test]
|
||||
fn quote_path_is_off_on_every_source() {
|
||||
// Left on, a non-ASCII path arrives as C octal escapes that nothing
|
||||
// downstream decodes — `parse_quoted_pair` would hand back the literal
|
||||
// digits. Verified against git 2.50.1: `diff --git
|
||||
// Left on, every non-ASCII path arrives as C octal escapes — decodable
|
||||
// (see below), but the raw spelling needs no decode at all. Verified
|
||||
// against git 2.50.1: `diff --git
|
||||
// "a/\344\270\255\346\226\207\345\220\215.txt" …` becomes
|
||||
// `diff --git a/中文名.txt b/中文名.txt` once this is off.
|
||||
for source in [
|
||||
@@ -941,21 +1054,74 @@ Binary files a/img.png and b/img.png differ
|
||||
}
|
||||
}
|
||||
|
||||
/// `core.quotePath=false` stops the quoting of non-ASCII only. A path
|
||||
/// with a control character or a `"` is *always* C-quoted, so the decoder
|
||||
/// has to speak the whole escape set — a tab decoded to a literal `t`
|
||||
/// names a path that does not exist, and the `:(literal)` re-probe for
|
||||
/// that row comes back empty.
|
||||
#[test]
|
||||
fn octal_escaped_paths_are_what_the_flag_prevents() {
|
||||
fn c_quoted_paths_decode_the_full_escape_set() {
|
||||
let escaped = parse_unified(
|
||||
"diff --git \"a/\\344\\270\\255\\346\\226\\207\\345\\220\\215.txt\" \
|
||||
\"b/\\344\\270\\255\\346\\226\\207\\345\\220\\215.txt\"\n",
|
||||
);
|
||||
assert_ne!(
|
||||
escaped[0].path, "中文名.txt",
|
||||
"the escapes are not decoded here, which is why they must not be produced"
|
||||
);
|
||||
assert_eq!(escaped[0].path, "中文名.txt");
|
||||
|
||||
let control = parse_unified("diff --git \"a/x\\ty.rs\" \"b/x\\ty.rs\"\n");
|
||||
assert_eq!(control[0].path, "x\ty.rs");
|
||||
|
||||
let quote =
|
||||
parse_unified("diff --git \"a/he said \\\"hi\\\".md\" \"b/he said \\\"hi\\\".md\"\n");
|
||||
assert_eq!(quote[0].path, "he said \"hi\".md");
|
||||
|
||||
let raw = parse_unified("diff --git a/中文名.txt b/中文名.txt\n");
|
||||
assert_eq!(raw[0].path, "中文名.txt");
|
||||
}
|
||||
|
||||
/// git never quotes a path for a mere space, so a `diff --git` header
|
||||
/// whose paths contain ` b/` cannot be split reliably — but the `rename
|
||||
/// from`/`rename to` lines that follow name one path each, and they win.
|
||||
#[test]
|
||||
fn rename_lines_override_an_ambiguous_header() {
|
||||
let files = parse_unified(
|
||||
"diff --git a/my b/old.rs b/my b/new.rs\n\
|
||||
similarity index 90%\n\
|
||||
rename from my b/old.rs\n\
|
||||
rename to my b/new.rs\n",
|
||||
);
|
||||
assert_eq!(files[0].path, "my b/new.rs");
|
||||
assert_eq!(files[0].old_path.as_deref(), Some("my b/old.rs"));
|
||||
assert_eq!(files[0].status, FileStatus::Renamed);
|
||||
}
|
||||
|
||||
/// A rev that could be read as an option never reaches git — same guard
|
||||
/// log's `is_rev` applies, on the module that calls itself the one place
|
||||
/// to read what git is asked.
|
||||
#[test]
|
||||
fn a_rev_shaped_like_an_option_never_reaches_git() {
|
||||
let host = crate::host::local::LocalHost::new();
|
||||
for source in [
|
||||
DiffSource::commit("--output=/tmp/pwned"),
|
||||
DiffSource::Range {
|
||||
base: "--output=/tmp/pwned".into(),
|
||||
head: "main".into(),
|
||||
},
|
||||
DiffSource::Range {
|
||||
base: "main".into(),
|
||||
head: "".into(),
|
||||
},
|
||||
] {
|
||||
let req = DiffRequest {
|
||||
source,
|
||||
..DiffRequest::default()
|
||||
};
|
||||
assert!(
|
||||
probe_diff(&*host, Path::new("/"), &req).is_none(),
|
||||
"refused before any git runs"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_appends_its_pathspecs_after_a_separator() {
|
||||
let paths = [":(literal)src/a[b].rs".to_string()];
|
||||
@@ -1168,6 +1334,20 @@ index af70335,f794161..0000000
|
||||
assert_eq!(lines[4].text, "SIDE", "added on one side is still added");
|
||||
assert_eq!(lines[6].text, "c");
|
||||
assert_eq!((files[0].added, files[0].removed), (5, 0));
|
||||
|
||||
// Numbers follow the *sides*, not the colour: ` +MAIN` is painted as
|
||||
// an addition but exists in the first parent (it is HEAD's own line),
|
||||
// so the old counter walks past it — and `c` lands on old line 3,
|
||||
// exactly the `-1,3` the hunk header promises. `+ SIDE` is the other
|
||||
// parent's line: no old number.
|
||||
assert_eq!(
|
||||
(lines[2].old_no, lines[2].new_no),
|
||||
(Some(2), Some(3)),
|
||||
"MAIN: {:?}",
|
||||
lines[2]
|
||||
);
|
||||
assert_eq!((lines[4].old_no, lines[4].new_no), (None, Some(5)));
|
||||
assert_eq!((lines[6].old_no, lines[6].new_no), (Some(3), Some(7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -47,6 +47,14 @@ pub const MAX_LOG_BYTES: usize = 16 * 1024 * 1024;
|
||||
/// told apart by counting fields — and one NUL inside a commit message (git
|
||||
/// objects allow it) would desynchronise the whole stream. RS and US cannot
|
||||
/// occur in a sha, a refname, an ISO date or an address.
|
||||
///
|
||||
/// A commit *message* can still carry RS or US — nothing git accepts is out of
|
||||
/// bounds there. The failure is contained, not eliminated: the body truncates
|
||||
/// at the stray separator, `is_hex_oid` throws the tail away unless it is
|
||||
/// deliberately shaped like a full record, and a deliberately shaped one can
|
||||
/// fabricate at worst a bogus row in the graph — whose `git show` then fails.
|
||||
/// Sealing that needs length-prefixed reads (`cat-file --batch`), a different
|
||||
/// data path entirely.
|
||||
pub const REC_SEP: u8 = 0x1e;
|
||||
pub const FIELD_SEP: u8 = 0x1f;
|
||||
|
||||
@@ -326,7 +334,9 @@ impl LaneAlloc {
|
||||
// No parents: a root. `slots[node]` was released above and nothing
|
||||
// claimed it, so the lane simply ends here.
|
||||
|
||||
edges.sort_unstable_by_key(Edge::paint_rank);
|
||||
// Stable: two edges of one rank (a merge's several `Out`s) keep their
|
||||
// insertion order — first parent first — which the golden tests pin.
|
||||
edges.sort_by_key(Edge::paint_rank);
|
||||
GraphRow {
|
||||
node,
|
||||
// Colour is the lane number, fixed when the lane is created and
|
||||
@@ -394,17 +404,28 @@ const LOG_FIELDS: usize = 11;
|
||||
|
||||
pub const REF_FORMAT: &str = "--format=%(objectname)%x1f%(refname)%x1f%(refname:short)%x1f%(upstream)%x1f%(HEAD)%x1f%(objecttype)%x1f%(*objectname)";
|
||||
|
||||
/// What [`parse_log`] read, and whether it read all of it.
|
||||
pub struct ParsedLog {
|
||||
pub commits: Vec<Commit>,
|
||||
/// The stream was cut short — by [`MAX_LOG_BYTES`], or by a record past
|
||||
/// `MAX_RECORD` being dropped whole. The caller must not present the
|
||||
/// commits as "all of history": git returned more than was parsed.
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// Parses the output of the `log` invocation [`LOG_PRETTY`] belongs to.
|
||||
///
|
||||
/// Records are split on RS and fields on US. Fields are taken with `splitn`, so
|
||||
/// the body — the only field that can contain anything at all — absorbs every
|
||||
/// separator past the tenth instead of shifting the parse.
|
||||
pub fn parse_log(stdout: &[u8]) -> Vec<Commit> {
|
||||
pub fn parse_log(stdout: &[u8]) -> ParsedLog {
|
||||
let mut commits = Vec::new();
|
||||
let mut used = 0usize;
|
||||
let mut clipped = false;
|
||||
let mut on_record = |record: &[u8]| {
|
||||
used = used.saturating_add(record.len());
|
||||
if used > MAX_LOG_BYTES {
|
||||
clipped = true;
|
||||
return;
|
||||
}
|
||||
if let Some(commit) = parse_record(record) {
|
||||
@@ -413,8 +434,11 @@ pub fn parse_log(stdout: &[u8]) -> Vec<Commit> {
|
||||
};
|
||||
let mut split = RecordSplitter::new(REC_SEP);
|
||||
split.push(stdout, &mut on_record);
|
||||
split.finish(&mut on_record);
|
||||
commits
|
||||
let dropped = split.finish(&mut on_record);
|
||||
ParsedLog {
|
||||
commits,
|
||||
truncated: clipped || dropped > 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_record(record: &[u8]) -> Option<Commit> {
|
||||
@@ -646,7 +670,7 @@ pub fn load_commit(host: &dyn Host, root: &Path, rev: &str) -> Option<Commit> {
|
||||
if !out.success() {
|
||||
return None;
|
||||
}
|
||||
parse_log(&out.stdout).into_iter().next()
|
||||
parse_log(&out.stdout).commits.into_iter().next()
|
||||
}
|
||||
|
||||
/// One path a commit touched, with the line counts beside it.
|
||||
@@ -723,7 +747,10 @@ fn records(stdout: &[u8]) -> Vec<String> {
|
||||
let mut on_record = |record: &[u8]| out.push(String::from_utf8_lossy(record).into_owned());
|
||||
let mut split = RecordSplitter::new(0);
|
||||
split.push(stdout, &mut on_record);
|
||||
split.finish(&mut on_record);
|
||||
// A dropped record here is a >1 MiB *pathname* — losing that one row from
|
||||
// a commit's file list is the same answer `MAX_COMMIT_FILES` already gives
|
||||
// for lists that are merely long.
|
||||
let _ = split.finish(&mut on_record);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -886,7 +913,6 @@ pub fn load_page(
|
||||
// of its children, and dates do not guarantee that. A rebase or a
|
||||
// cherry-pick across timezones is enough to invert a pair.
|
||||
"--topo-order",
|
||||
"--parents",
|
||||
"--decorate=full",
|
||||
"--no-color",
|
||||
LOG_PRETTY,
|
||||
@@ -905,8 +931,13 @@ pub fn load_page(
|
||||
if !out.success() {
|
||||
return None;
|
||||
}
|
||||
let mut commits = parse_log(&out.stdout);
|
||||
let complete = commits.len() < count;
|
||||
let parsed = parse_log(&out.stdout);
|
||||
let mut commits = parsed.commits;
|
||||
// "End of history" needs both halves: git answered with fewer than asked
|
||||
// for, *and* the parse read everything git answered with. A stream cut at
|
||||
// `MAX_LOG_BYTES` also has fewer commits than `count` — calling that
|
||||
// complete would freeze paging on a truncated graph.
|
||||
let complete = !parsed.truncated && commits.len() < count;
|
||||
|
||||
let page: Vec<(Oid, SmallVec<[Oid; 2]>)> = commits
|
||||
.iter()
|
||||
@@ -950,27 +981,24 @@ pub fn load_page(
|
||||
|
||||
/// The revs to walk for a scope.
|
||||
///
|
||||
/// `HeadAndUpstream` resolves to shas first. Paging re-runs the walk with a
|
||||
/// larger `-n`, and a symbolic `HEAD` would let a commit pushed between the two
|
||||
/// runs change where page two starts — the second page would no longer be a
|
||||
/// superset of the first, which is the one thing paging here relies on.
|
||||
/// Every symbolic name resolves to a sha first. Paging re-runs the walk with a
|
||||
/// larger `-n`, and a symbolic `HEAD` or branch name would let a commit pushed
|
||||
/// between the two runs change where page two starts — the second page would
|
||||
/// no longer be a superset of the first, which is the one thing paging here
|
||||
/// relies on. (`--all` cannot be pinned; that scope accepts the reflow.)
|
||||
/// A name that no longer resolves — a deleted branch, an unborn HEAD — simply
|
||||
/// contributes nothing, which reads as "no history" rather than as a failure.
|
||||
fn scope_revs(host: &dyn Host, root: &Path, scope: &GraphScope) -> Vec<String> {
|
||||
match scope {
|
||||
GraphScope::Head => vec!["HEAD".to_string()],
|
||||
GraphScope::Head => rev(host, root, "HEAD^{commit}").into_iter().collect(),
|
||||
GraphScope::All => vec!["--all".to_string()],
|
||||
GraphScope::Refs(refs) => {
|
||||
let mut revs: Vec<String> = refs
|
||||
.iter()
|
||||
// A refname cannot begin with `-`, so anything that does is
|
||||
// either a mistake or an option smuggled in through a scope.
|
||||
.filter(|r| !r.is_empty() && !r.starts_with('-'))
|
||||
.cloned()
|
||||
.collect();
|
||||
if revs.is_empty() {
|
||||
revs.push("HEAD".to_string());
|
||||
}
|
||||
revs
|
||||
}
|
||||
GraphScope::Refs(refs) => refs
|
||||
.iter()
|
||||
// A refname cannot begin with `-`, so anything that does is
|
||||
// either a mistake or an option smuggled in through a scope.
|
||||
.filter(|r| !r.is_empty() && !r.starts_with('-'))
|
||||
.filter_map(|r| rev(host, root, &format!("{r}^{{commit}}")))
|
||||
.collect(),
|
||||
GraphScope::HeadAndUpstream => {
|
||||
let mut revs = Vec::new();
|
||||
if let Some(head) = rev(host, root, "HEAD^{commit}") {
|
||||
@@ -1127,7 +1155,14 @@ mod tests {
|
||||
lanes
|
||||
}
|
||||
|
||||
/// Lanes crossing the row's bottom edge, sorted.
|
||||
/// Lanes crossing the row's bottom edge, sorted and folded.
|
||||
///
|
||||
/// Folded, because one lane can legally carry a `Pass` *and* an `Out`: a
|
||||
/// merge whose second parent already has a lane reserved by another child
|
||||
/// sends its `Out` onto that lane, joining the line rather than opening a
|
||||
/// second one. Below the row that is a single line in a single colour (an
|
||||
/// `Out`'s colour is its lane), so the cut sees one line — which the
|
||||
/// assertions below verify before folding.
|
||||
fn bottom(row: &GraphRow) -> Vec<Lane> {
|
||||
let mut lanes: Vec<Lane> = row
|
||||
.edges
|
||||
@@ -1139,20 +1174,45 @@ mod tests {
|
||||
})
|
||||
.collect();
|
||||
lanes.sort_unstable();
|
||||
lanes.dedup();
|
||||
lanes
|
||||
}
|
||||
|
||||
/// The property the whole layout rests on: at any horizontal cut through
|
||||
/// the graph a lane carries at most one line, and what leaves a row's
|
||||
/// bottom is exactly what enters the next row's top. Together those two
|
||||
/// mean colour-by-lane can never put two visible lines in one colour.
|
||||
/// the graph a lane carries at most one visible line, and what leaves a
|
||||
/// row's bottom is exactly what enters the next row's top. Together those
|
||||
/// two mean colour-by-lane can never put two visible lines in one colour.
|
||||
///
|
||||
/// "Visible" carries the one nuance: an `Out` may land on a lane a `Pass`
|
||||
/// already crosses — a join, see [`bottom`] — and that pair is one line.
|
||||
/// Two `Pass`es or two `Out`s on one lane are still bugs.
|
||||
fn assert_lanes_line_up(rows: &[GraphRow]) {
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
for edges in [top(row), bottom(row)] {
|
||||
let mut once = edges.clone();
|
||||
once.dedup();
|
||||
assert_eq!(once, edges, "row {i} has two lines on one lane: {row:?}");
|
||||
}
|
||||
let once = |mut lanes: Vec<Lane>| {
|
||||
lanes.sort_unstable();
|
||||
let len = lanes.len();
|
||||
lanes.dedup();
|
||||
assert_eq!(lanes.len(), len, "row {i} doubles up a lane: {row:?}");
|
||||
};
|
||||
once(top(row));
|
||||
once(
|
||||
row.edges
|
||||
.iter()
|
||||
.filter_map(|e| match *e {
|
||||
Edge::Pass { lane, .. } => Some(lane),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
once(
|
||||
row.edges
|
||||
.iter()
|
||||
.filter_map(|e| match *e {
|
||||
Edge::Out { to, .. } => Some(to),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
for (i, pair) in rows.windows(2).enumerate() {
|
||||
assert_eq!(
|
||||
@@ -1330,6 +1390,37 @@ mod tests {
|
||||
assert_lanes_line_up(&rows);
|
||||
}
|
||||
|
||||
/// The commonest merge topology of all: "merge main into topic after main
|
||||
/// advanced". The merge's second parent (`c`) already has a lane reserved
|
||||
/// by another child (`x`), so the merge's `Out` *joins* that lane instead
|
||||
/// of opening a second one to the same commit — the row legally carries a
|
||||
/// `Pass` and an `Out` on lane 0, one line below the cut, not two.
|
||||
#[test]
|
||||
fn a_second_parent_joins_a_line_another_child_opened() {
|
||||
let page = [
|
||||
commit("x", &["c"]),
|
||||
commit("m", &["a", "c"]),
|
||||
commit("a", &["c"]),
|
||||
commit("c", &[]),
|
||||
];
|
||||
let rows = lay_out(&page);
|
||||
|
||||
assert_eq!(rows[0].edges.as_slice(), [out_at(0)]);
|
||||
let merge = &rows[1];
|
||||
assert_eq!(merge.node, 1, "the merge tips a lane of its own");
|
||||
assert_eq!(
|
||||
merge.edges.as_slice(),
|
||||
[pass_at(0), out_at(1), out_at(0)],
|
||||
"first parent inherits the node's lane; the second joins lane 0"
|
||||
);
|
||||
assert_eq!(
|
||||
rows[3].edges.as_slice(),
|
||||
[in_at(0), in_at(1)],
|
||||
"both lines still converge on the shared parent"
|
||||
);
|
||||
assert_lanes_line_up(&rows);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_parents_than_lanes_truncates_instead_of_panicking() {
|
||||
let parents: Vec<String> = (0..40).map(|i| format!("p{i}")).collect();
|
||||
@@ -1374,6 +1465,35 @@ mod tests {
|
||||
])
|
||||
}
|
||||
|
||||
/// A parse that could not read everything must say so — `load_page` turns
|
||||
/// `truncated` into `complete: false`, and a truncated graph that claimed
|
||||
/// to be the end of history would freeze paging on it forever.
|
||||
#[test]
|
||||
fn a_stream_the_parse_cannot_finish_is_never_called_complete() {
|
||||
// One record past MAX_RECORD: dropped whole by the splitter.
|
||||
let huge_body = "x".repeat(super::super::MAX_RECORD + 1);
|
||||
let stream = [
|
||||
one(SHA_A, SHA_B, "", "kept", ""),
|
||||
one(SHA_B, "", "", "monster", &huge_body),
|
||||
]
|
||||
.concat();
|
||||
let parsed = parse_log(stream.as_bytes());
|
||||
assert_eq!(parsed.commits.len(), 1, "the readable record survives");
|
||||
assert!(parsed.truncated);
|
||||
|
||||
// Cumulative bytes past MAX_LOG_BYTES: the tail is clipped.
|
||||
let body = "y".repeat(512 * 1024);
|
||||
let stream: String = (0..40)
|
||||
.map(|i| one(&format!("{i:040}"), "", "", "big", &body))
|
||||
.collect();
|
||||
let parsed = parse_log(stream.as_bytes());
|
||||
assert!(parsed.commits.len() < 40);
|
||||
assert!(parsed.truncated);
|
||||
|
||||
let parsed = parse_log(one(SHA_A, "", "", "small", "fine").as_bytes());
|
||||
assert!(!parsed.truncated, "an ordinary stream is read in full");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_multi_line_body_survives_the_record_split() {
|
||||
let stream = [
|
||||
@@ -1387,7 +1507,7 @@ mod tests {
|
||||
one(SHA_B, "", "", "second", ""),
|
||||
]
|
||||
.join("\n");
|
||||
let commits = parse_log(stream.as_bytes());
|
||||
let commits = parse_log(stream.as_bytes()).commits;
|
||||
|
||||
assert_eq!(commits.len(), 2);
|
||||
assert_eq!(commits[0].summary, "first");
|
||||
@@ -1402,7 +1522,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_merge_records_both_parents() {
|
||||
let stream = one(SHA_A, &format!("{SHA_B} {SHA_C}"), "", "merge", "");
|
||||
let commits = parse_log(stream.as_bytes());
|
||||
let commits = parse_log(stream.as_bytes()).commits;
|
||||
|
||||
assert_eq!(commits[0].parents.as_slice(), [SHA_B, SHA_C]);
|
||||
assert!(commits[0].is_merge());
|
||||
@@ -1413,7 +1533,7 @@ mod tests {
|
||||
fn decorations_map_to_their_ref_kinds() {
|
||||
let deco = "HEAD -> refs/heads/main, refs/remotes/origin/main, tag: refs/tags/v1.0";
|
||||
let stream = one(SHA_A, "", deco, "subject", "");
|
||||
let refs = parse_log(stream.as_bytes()).remove(0).refs;
|
||||
let refs = parse_log(stream.as_bytes()).commits.remove(0).refs;
|
||||
|
||||
assert_eq!(refs.len(), 3);
|
||||
assert_eq!(refs[0].kind, RefKind::LocalBranch);
|
||||
@@ -1427,7 +1547,7 @@ mod tests {
|
||||
assert_eq!(refs[2].short, "v1.0");
|
||||
|
||||
let detached = one(SHA_A, "", "HEAD, refs/tags/v2", "subject", "");
|
||||
let refs = parse_log(detached.as_bytes()).remove(0).refs;
|
||||
let refs = parse_log(detached.as_bytes()).commits.remove(0).refs;
|
||||
assert_eq!(refs[0].kind, RefKind::Head);
|
||||
assert!(refs[0].is_head);
|
||||
}
|
||||
@@ -1436,7 +1556,7 @@ mod tests {
|
||||
fn a_unit_separator_inside_a_body_does_not_shift_fields() {
|
||||
let body = "before\x1fafter\x1fand\x1fmore";
|
||||
let stream = one(SHA_A, "", "", "subject", body);
|
||||
let commits = parse_log(stream.as_bytes());
|
||||
let commits = parse_log(stream.as_bytes()).commits;
|
||||
|
||||
assert_eq!(
|
||||
commits[0].summary, "subject",
|
||||
@@ -1453,7 +1573,7 @@ mod tests {
|
||||
record(&[SHA_B, "only two fields"]),
|
||||
]
|
||||
.join("\n");
|
||||
let commits = parse_log(stream.as_bytes());
|
||||
let commits = parse_log(stream.as_bytes()).commits;
|
||||
|
||||
assert_eq!(commits.len(), 1, "{commits:?}");
|
||||
assert_eq!(commits[0].summary, "real");
|
||||
@@ -1517,7 +1637,7 @@ mod tests {
|
||||
let subject = "提".repeat(400);
|
||||
let body = "交".repeat(4000);
|
||||
let stream = one(SHA_A, "", "", &subject, &body);
|
||||
let commit = parse_log(stream.as_bytes()).remove(0);
|
||||
let commit = parse_log(stream.as_bytes()).commits.remove(0);
|
||||
|
||||
assert_eq!(
|
||||
commit.summary.len(),
|
||||
@@ -1573,7 +1693,7 @@ mod tests {
|
||||
assert_eq!(by_oid[SHA_C][0].upstream, None);
|
||||
// `%D` cannot carry an upstream at all, so a decoration parsed out of
|
||||
// a log record must not claim one.
|
||||
let logged = parse_log(one(SHA_A, "", "refs/heads/main", "s", "").as_bytes());
|
||||
let logged = parse_log(one(SHA_A, "", "refs/heads/main", "s", "").as_bytes()).commits;
|
||||
assert_eq!(logged[0].refs[0].upstream, None);
|
||||
}
|
||||
|
||||
|
||||
@@ -265,6 +265,9 @@ impl LineSplitter {
|
||||
pub struct RecordSplitter {
|
||||
sep: u8,
|
||||
tail: Vec<u8>,
|
||||
/// The record being assembled overran [`MAX_RECORD`] and is now being
|
||||
/// discarded up to its separator.
|
||||
discarding: bool,
|
||||
dropped: usize,
|
||||
}
|
||||
|
||||
@@ -275,6 +278,7 @@ impl RecordSplitter {
|
||||
RecordSplitter {
|
||||
sep,
|
||||
tail: Vec::new(),
|
||||
discarding: false,
|
||||
dropped: 0,
|
||||
}
|
||||
}
|
||||
@@ -283,40 +287,63 @@ impl RecordSplitter {
|
||||
let mut rest = chunk;
|
||||
while let Some(at) = rest.iter().position(|b| *b == self.sep) {
|
||||
let (record, after) = rest.split_at(at);
|
||||
if self.tail.is_empty() && self.dropped == 0 && record.len() <= MAX_RECORD {
|
||||
on_record(record);
|
||||
// An overlong record is dropped whole, never delivered cut short:
|
||||
// a truncated record still parses — a commit body cut mid-way
|
||||
// reads as the real message — and a wrong record is worse than a
|
||||
// missing one the caller is told about.
|
||||
if self.discarding {
|
||||
self.discarding = false;
|
||||
self.dropped += 1;
|
||||
} else if self.tail.is_empty() {
|
||||
// The common case — a whole record inside one chunk — is
|
||||
// borrowed straight from the input, no copy.
|
||||
if record.len() <= MAX_RECORD {
|
||||
on_record(record);
|
||||
} else {
|
||||
self.dropped += 1;
|
||||
}
|
||||
} else {
|
||||
self.keep(record);
|
||||
let joined = std::mem::take(&mut self.tail);
|
||||
self.dropped = 0;
|
||||
on_record(&joined);
|
||||
if self.discarding {
|
||||
self.discarding = false;
|
||||
self.dropped += 1;
|
||||
} else {
|
||||
let joined = std::mem::take(&mut self.tail);
|
||||
on_record(&joined);
|
||||
}
|
||||
}
|
||||
rest = &after[1..];
|
||||
}
|
||||
self.keep(rest);
|
||||
}
|
||||
|
||||
/// Emits a trailing record only if one was actually started. Unlike lines,
|
||||
/// well-formed `-z` output ends *with* a separator, so the common case here
|
||||
/// is emitting nothing.
|
||||
pub fn finish(mut self, mut on_record: impl FnMut(&[u8])) {
|
||||
if !self.tail.is_empty() {
|
||||
/// Emits a trailing record only if one was actually started — unlike
|
||||
/// lines, well-formed `-z` output ends *with* a separator, so the common
|
||||
/// case here is emitting nothing. Returns how many records were dropped
|
||||
/// whole for overrunning [`MAX_RECORD`]; non-zero means the parse is
|
||||
/// incomplete and the caller must not present it as the full answer.
|
||||
#[must_use]
|
||||
pub fn finish(mut self, mut on_record: impl FnMut(&[u8])) -> usize {
|
||||
if self.discarding {
|
||||
self.dropped += 1;
|
||||
} else if !self.tail.is_empty() {
|
||||
let joined = std::mem::take(&mut self.tail);
|
||||
on_record(&joined);
|
||||
}
|
||||
}
|
||||
|
||||
/// How many bytes were discarded for overrunning [`MAX_RECORD`]. Non-zero
|
||||
/// means the parse is incomplete and the caller should say so.
|
||||
pub fn dropped(&self) -> usize {
|
||||
self.dropped
|
||||
}
|
||||
|
||||
fn keep(&mut self, bytes: &[u8]) {
|
||||
let room = MAX_RECORD.saturating_sub(self.tail.len());
|
||||
let take = room.min(bytes.len());
|
||||
self.tail.extend_from_slice(&bytes[..take]);
|
||||
self.dropped += bytes.len() - take;
|
||||
if self.discarding {
|
||||
return;
|
||||
}
|
||||
if self.tail.len() + bytes.len() > MAX_RECORD {
|
||||
// Free what was buffered too — nobody will ever see this record.
|
||||
self.tail.clear();
|
||||
self.discarding = true;
|
||||
return;
|
||||
}
|
||||
self.tail.extend_from_slice(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,6 +503,67 @@ mod tests {
|
||||
assert!(got[0].starts_with("caf"), "{:?}", got[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_splitter_rejoins_across_chunks_and_emits_a_trailing_record() {
|
||||
let mut split = RecordSplitter::new(0);
|
||||
let mut got = Vec::new();
|
||||
split.push(b"alpha\0be", |r| got.push(r.to_vec()));
|
||||
assert_eq!(got, [b"alpha".to_vec()], "only the complete record so far");
|
||||
split.push(b"ta\0gamma", |r| got.push(r.to_vec()));
|
||||
// Well-formed `-z` output ends with a separator; a trailing record
|
||||
// without one still comes out at `finish`.
|
||||
assert_eq!(split.finish(|r| got.push(r.to_vec())), 0);
|
||||
assert_eq!(
|
||||
got,
|
||||
[b"alpha".to_vec(), b"beta".to_vec(), b"gamma".to_vec()]
|
||||
);
|
||||
}
|
||||
|
||||
/// An overlong record is dropped whole and *counted* — delivered cut
|
||||
/// short it would still parse, and a commit body cut mid-way reads as the
|
||||
/// real message.
|
||||
#[test]
|
||||
fn record_splitter_drops_an_absurd_record_whole_and_says_so() {
|
||||
let mut split = RecordSplitter::new(0);
|
||||
let mut got = Vec::new();
|
||||
let huge = vec![b'x'; MAX_RECORD + 5_000];
|
||||
split.push(b"before\0", |r| got.push(r.to_vec()));
|
||||
for piece in huge.chunks(64 * 1024) {
|
||||
split.push(piece, |r| got.push(r.to_vec()));
|
||||
}
|
||||
split.push(b"\0after\0", |r| got.push(r.to_vec()));
|
||||
let dropped = split.finish(|r| got.push(r.to_vec()));
|
||||
|
||||
assert_eq!(dropped, 1);
|
||||
assert_eq!(
|
||||
got,
|
||||
[b"before".to_vec(), b"after".to_vec()],
|
||||
"no truncated ghost between the two, and the next record survives"
|
||||
);
|
||||
|
||||
// A single-chunk oversized record takes the borrow fast path and must
|
||||
// be counted the same way.
|
||||
let mut split = RecordSplitter::new(0);
|
||||
let mut got: Vec<Vec<u8>> = Vec::new();
|
||||
let mut one = vec![b'y'; MAX_RECORD + 1];
|
||||
one.push(0);
|
||||
one.extend_from_slice(b"tail\0");
|
||||
split.push(&one, |r| got.push(r.to_vec()));
|
||||
assert_eq!(split.finish(|r| got.push(r.to_vec())), 1);
|
||||
assert_eq!(got, [b"tail".to_vec()]);
|
||||
}
|
||||
|
||||
/// A stream that *ends* mid-way through an oversized record still reports
|
||||
/// the drop.
|
||||
#[test]
|
||||
fn record_splitter_counts_a_truncated_trailing_record() {
|
||||
let mut split = RecordSplitter::new(0);
|
||||
let mut got: Vec<Vec<u8>> = Vec::new();
|
||||
split.push(&vec![b'z'; MAX_RECORD + 1], |r| got.push(r.to_vec()));
|
||||
assert_eq!(split.finish(|r| got.push(r.to_vec())), 1);
|
||||
assert!(got.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_splitter_caps_one_absurd_line() {
|
||||
let mut split = LineSplitter::default();
|
||||
|
||||
@@ -19,6 +19,13 @@ use crate::host::Host;
|
||||
/// macOS), so a big stage is split into several calls.
|
||||
pub const MAX_PATHSPECS_PER_CALL: usize = 200;
|
||||
|
||||
/// …and only so many *bytes*. The binding limit is not macOS's 256 KiB but
|
||||
/// Windows' `CreateProcess`, which caps the whole command line at 32,767
|
||||
/// UTF-16 units — 200 deep-tree paths at 200+ characters each sail past it.
|
||||
/// Sized with room for the prefix and the per-argument quoting the Windows
|
||||
/// join adds.
|
||||
pub const MAX_PATHSPEC_BYTES_PER_CALL: usize = 24 * 1024;
|
||||
|
||||
/// Long enough for a push over a slow link. Only applied to network operations
|
||||
/// — the local path has no deadline at all.
|
||||
pub const GIT_NETWORK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(600);
|
||||
@@ -202,10 +209,13 @@ impl GitOp {
|
||||
GitOp::DiscardWorktree { .. } => Destructive::LosesWorktreeEdits,
|
||||
GitOp::DiscardUntracked { .. } => Destructive::LosesUntrackedFiles,
|
||||
GitOp::DeleteBranch { .. } => Destructive::LosesCommits,
|
||||
// The stronger of the two truths: a hard reset clobbers worktree
|
||||
// edits *and* — pointed at an older commit — drops commits off
|
||||
// the branch. The dialog has to warn about the worse one.
|
||||
GitOp::Reset {
|
||||
mode: ResetMode::Hard,
|
||||
..
|
||||
} => Destructive::LosesWorktreeEdits,
|
||||
} => Destructive::LosesCommits,
|
||||
GitOp::Commit { amend: true, .. } => Destructive::RewritesHistory,
|
||||
GitOp::Push {
|
||||
force_with_lease: true,
|
||||
@@ -243,8 +253,12 @@ impl GitOp {
|
||||
/// 2.23 (2019), is still documented as EXPERIMENTAL, and has had its
|
||||
/// behaviour adjusted across releases; the two older forms have not moved
|
||||
/// in over a decade. tty7's whole point is that a remote host behaves like
|
||||
/// the local one, and a dev box on CentOS 7 (git 1.8) is a real thing —
|
||||
/// a version fork here would have to be tested twice forever.
|
||||
/// the local one, and ancient dev boxes are real. (The honest floor is
|
||||
/// git 1.8.5, not older: every path-carrying op spells its pathspecs
|
||||
/// `:(literal)`, which is where that magic arrived — CentOS 7's 1.8.3
|
||||
/// fails those with a clean "Invalid pathspec magic" rather than doing
|
||||
/// anything wrong.) A version fork here would have to be tested twice
|
||||
/// forever.
|
||||
///
|
||||
/// So there is no version probing at all. The one case that genuinely
|
||||
/// needs a different command is an unborn HEAD, and that needs no probe
|
||||
@@ -296,7 +310,12 @@ impl GitOp {
|
||||
}
|
||||
vec![out]
|
||||
}
|
||||
GitOp::CheckoutBranch { name } => vec![argv(&["checkout", name])],
|
||||
// The trailing `--` forces ref interpretation: without it a name
|
||||
// that no longer resolves (deleted out from under a stale branch
|
||||
// list) but matches a tracked *file* falls back to a path
|
||||
// checkout — which silently discards worktree edits to that file,
|
||||
// under an op whose `destructive()` says nothing is at risk.
|
||||
GitOp::CheckoutBranch { name } => vec![argv(&["checkout", name, "--"])],
|
||||
GitOp::CheckoutDetached { rev } => vec![argv(&["checkout", "--detach", rev])],
|
||||
GitOp::CreateBranch {
|
||||
name,
|
||||
@@ -511,25 +530,46 @@ fn unstage_prefix(head: &HeadState) -> &'static [&'static str] {
|
||||
&["reset", "-q", "HEAD"]
|
||||
} else {
|
||||
// There is no HEAD to reset against before the first commit — git
|
||||
// fails outright — so the index entry is dropped instead.
|
||||
&["rm", "--cached", "-r", "-q"]
|
||||
// fails outright — so the index entry is dropped instead. `-f`,
|
||||
// because without it git refuses a file whose staged content differs
|
||||
// from the file on disk (staged, then edited again) — and with
|
||||
// `--cached` the worktree is never touched, so nothing is at risk.
|
||||
&["rm", "--cached", "-r", "-q", "-f"]
|
||||
}
|
||||
}
|
||||
|
||||
/// `prefix -- <specs…>`, split so no single argv can hit `E2BIG`.
|
||||
/// `prefix -- <specs…>`, split so no single argv can hit `E2BIG` on unix or
|
||||
/// the 32,767-unit command-line cap on Windows — by count *and* by bytes,
|
||||
/// whichever fills first.
|
||||
///
|
||||
/// The `--` is not optional: without it a file named `HEAD` reads as a rev and
|
||||
/// one named `-f` reads as an option.
|
||||
fn batched(prefix: &[&str], specs: &[String]) -> Vec<Vec<String>> {
|
||||
specs
|
||||
.chunks(MAX_PATHSPECS_PER_CALL)
|
||||
.map(|chunk| {
|
||||
let mut out = argv(prefix);
|
||||
out.push("--".into());
|
||||
out.extend(chunk.iter().cloned());
|
||||
out
|
||||
})
|
||||
.collect()
|
||||
let mut out = Vec::new();
|
||||
let mut chunk: Vec<String> = Vec::new();
|
||||
let mut bytes = 0usize;
|
||||
let flush = |chunk: Vec<String>, out: &mut Vec<Vec<String>>| {
|
||||
let mut call = argv(prefix);
|
||||
call.push("--".into());
|
||||
call.extend(chunk);
|
||||
out.push(call);
|
||||
};
|
||||
for spec in specs {
|
||||
// Room for the quotes and the space the Windows argv join adds.
|
||||
let cost = spec.len() + 3;
|
||||
if !chunk.is_empty()
|
||||
&& (chunk.len() >= MAX_PATHSPECS_PER_CALL || bytes + cost > MAX_PATHSPEC_BYTES_PER_CALL)
|
||||
{
|
||||
flush(std::mem::take(&mut chunk), &mut out);
|
||||
bytes = 0;
|
||||
}
|
||||
bytes += cost;
|
||||
chunk.push(spec.clone());
|
||||
}
|
||||
if !chunk.is_empty() {
|
||||
flush(chunk, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What a failure means, from git's own words.
|
||||
@@ -652,7 +692,13 @@ pub fn run_op(
|
||||
let spawned = host.git_with_deadline(root, &borrowed, deadline);
|
||||
let out = spawned.map_err(|err| GitOpError {
|
||||
op: label,
|
||||
kind: GitOpErrorKind::Spawn,
|
||||
// A deadline expiry is its own kind: "git could not be run" tells
|
||||
// the user to check their install, when the truth is the job was
|
||||
// running — and on a remote host may still be.
|
||||
kind: match err.kind() {
|
||||
std::io::ErrorKind::TimedOut => GitOpErrorKind::Timeout,
|
||||
_ => GitOpErrorKind::Spawn,
|
||||
},
|
||||
message: err.to_string(),
|
||||
detail: err.to_string(),
|
||||
rerun_argv: rerun(),
|
||||
@@ -679,7 +725,13 @@ pub fn run_op(
|
||||
op: label,
|
||||
kind,
|
||||
message,
|
||||
detail: if stderr.is_empty() { stdout } else { stderr },
|
||||
// Both streams: a pull explains itself across the two, and
|
||||
// showing only one buries half the reason.
|
||||
detail: match (stderr.is_empty(), stdout.is_empty()) {
|
||||
(false, false) => format!("{stderr}\n{stdout}"),
|
||||
(false, true) => stderr,
|
||||
_ => stdout,
|
||||
},
|
||||
rerun_argv: rerun(),
|
||||
cwd: root.to_path_buf(),
|
||||
});
|
||||
@@ -865,11 +917,21 @@ mod tests {
|
||||
paths: vec![p("x")],
|
||||
}
|
||||
.commands(&unborn()),
|
||||
vec![vec!["rm", "--cached", "-r", "-q", "--", ":(literal)x"]],
|
||||
// `-f` because a staged-then-edited file otherwise refuses to
|
||||
// unstage before the first commit; `--cached` keeps it worktree-safe.
|
||||
vec![vec![
|
||||
"rm",
|
||||
"--cached",
|
||||
"-r",
|
||||
"-q",
|
||||
"-f",
|
||||
"--",
|
||||
":(literal)x"
|
||||
]],
|
||||
);
|
||||
assert_eq!(
|
||||
GitOp::UnstageAll.commands(&unborn()),
|
||||
vec![vec!["rm", "--cached", "-r", "-q", "--", "."]],
|
||||
vec![vec!["rm", "--cached", "-r", "-q", "-f", "--", "."]],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -918,6 +980,28 @@ mod tests {
|
||||
assert_eq!(batches[1][2], ":(literal)f200.txt");
|
||||
}
|
||||
|
||||
/// The count cap alone is not enough: 200 deep-tree paths at 200+
|
||||
/// characters each sail past Windows' 32,767-unit command line. Bytes
|
||||
/// split a batch before the count does.
|
||||
#[test]
|
||||
fn long_paths_split_a_batch_by_bytes_before_the_count_cap() {
|
||||
let long = "d/".repeat(150) + "file.rs"; // ~300 bytes each
|
||||
let paths: Vec<RepoPath> = (0..MAX_PATHSPECS_PER_CALL).map(|_| p(&long)).collect();
|
||||
let batches = GitOp::Stage { paths }.commands(&born());
|
||||
|
||||
assert!(batches.len() > 1, "200 × ~300B has to split");
|
||||
for batch in &batches {
|
||||
let bytes: usize = batch[2..].iter().map(|s| s.len() + 3).sum();
|
||||
assert!(
|
||||
bytes <= MAX_PATHSPEC_BYTES_PER_CALL,
|
||||
"batch of {bytes} bytes would overflow a Windows command line"
|
||||
);
|
||||
assert!(batch.len() >= 3, "no batch goes out empty");
|
||||
}
|
||||
let total: usize = batches.iter().map(|b| b.len() - 2).sum();
|
||||
assert_eq!(total, MAX_PATHSPECS_PER_CALL, "every path is still sent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_named_head_or_dash_f_is_never_read_as_a_rev_or_an_option() {
|
||||
for op in [
|
||||
@@ -1029,7 +1113,9 @@ mod tests {
|
||||
name: "feature".into(),
|
||||
}
|
||||
.commands(&born()),
|
||||
vec![vec!["checkout", "feature"]],
|
||||
// The trailing `--` keeps a stale branch name from falling back
|
||||
// to a worktree-clobbering *path* checkout.
|
||||
vec![vec!["checkout", "feature", "--"]],
|
||||
);
|
||||
assert_eq!(
|
||||
GitOp::CheckoutDetached {
|
||||
@@ -1210,10 +1296,13 @@ mod tests {
|
||||
for op in every_op() {
|
||||
for head in [born(), unborn()] {
|
||||
for batch in op.commands(&head) {
|
||||
// The two sanctioned `-f`s: `clean` (that is the verb's
|
||||
// whole meaning, and it is gated as destructive) and
|
||||
// `rm --cached` (never touches the worktree).
|
||||
let exempt = batch[0] == "clean"
|
||||
|| (batch[0] == "rm" && batch.iter().any(|a| a == "--cached"));
|
||||
assert!(
|
||||
!batch
|
||||
.iter()
|
||||
.any(|a| a == "--force" || a == "-f" && batch[0] != "clean"),
|
||||
!batch.iter().any(|a| a == "--force" || a == "-f" && !exempt),
|
||||
"{:?} would force: {batch:?}",
|
||||
op.label(),
|
||||
);
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! control panel, the file tree's decorations, and every button that is only
|
||||
//! enabled for some file states.
|
||||
//!
|
||||
//! One `git status --porcelain=v2 --branch -z` answers all of it. That format
|
||||
//! One `git status --porcelain=v2 --branch --show-stash -uall -z` answers all
|
||||
//! of it. That format
|
||||
//! is the only one that carries the staged and unstaged halves *separately*
|
||||
//! (the `XY` pair), a rename's old path, unmerged stages, submodule sub-state,
|
||||
//! and the branch header — getting the same picture out of `git diff` takes
|
||||
@@ -222,6 +223,12 @@ impl StatusEntry {
|
||||
if self.is_untracked() {
|
||||
return DecoStatus::Untracked;
|
||||
}
|
||||
// Explicit, not via the code match below: an ignored record carries no
|
||||
// change codes, so it would otherwise fall through to `Modified` the
|
||||
// day `--ignored` is passed — and light the whole ignored tree up.
|
||||
if matches!(self.kind, EntryKind::Ignored) {
|
||||
return DecoStatus::Ignored;
|
||||
}
|
||||
let worse = if code_rank(self.worktree) >= code_rank(self.index) {
|
||||
self.worktree
|
||||
} else {
|
||||
@@ -407,11 +414,13 @@ impl StatusIndex {
|
||||
for entry in &status.entries {
|
||||
let deco = entry.deco();
|
||||
index.insert(entry.path.as_str(), deco);
|
||||
// A rename's old path is no longer on disk, so no tree row will ask
|
||||
// for it — but the directory it left did lose a file, and the
|
||||
// rollup is the only place that can say so.
|
||||
// A rename's old path goes into the directory rollup only — the
|
||||
// directory it left did lose a file. Not into `files`: the path
|
||||
// can be occupied again (`git mv a b && echo x > a` emits an
|
||||
// untracked record for `a`), and that row belongs to whatever
|
||||
// occupies it now, not to the rename it outranks.
|
||||
if let Some(orig) = &entry.orig_path {
|
||||
index.insert(orig.as_str(), deco);
|
||||
index.rollup(orig.as_str(), deco);
|
||||
}
|
||||
}
|
||||
if index.files.len() > MAX_DECORATED_FILES {
|
||||
@@ -439,6 +448,12 @@ impl StatusIndex {
|
||||
.entry(repo_rel.to_string())
|
||||
.and_modify(|slot| *slot = (*slot).max(status))
|
||||
.or_insert(status);
|
||||
self.rollup(repo_rel, status);
|
||||
}
|
||||
|
||||
/// Only the ancestor walk — for a path that must not claim a file row of
|
||||
/// its own, like the old half of a rename.
|
||||
fn rollup(&mut self, repo_rel: &str, status: DecoStatus) {
|
||||
let mut cut = repo_rel;
|
||||
while let Some((parent, _)) = cut.rsplit_once('/') {
|
||||
self.dirs
|
||||
@@ -531,8 +546,12 @@ pub fn parse_porcelain_v2(stdout: &[u8]) -> ParsedStatus {
|
||||
let mut parser = Parser::default();
|
||||
let mut split = RecordSplitter::new(0);
|
||||
split.push(stdout, |record| parser.record(record));
|
||||
split.finish(|record| parser.record(record));
|
||||
parser.finish()
|
||||
let dropped = split.finish(|record| parser.record(record));
|
||||
let mut parsed = parser.finish();
|
||||
// A record past `MAX_RECORD` (a pathological pathname) is dropped whole;
|
||||
// the status must say it is not the full picture, same as the entry cap.
|
||||
parsed.truncated |= dropped > 0;
|
||||
parsed
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -793,15 +812,28 @@ fn head_state(oid: Option<String>, head_name: Option<String>) -> HeadState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole working tree state for the repository containing `cwd`, or `None`
|
||||
/// if there is no repository there.
|
||||
/// What a status probe learned. The middle answer is the load-bearing one:
|
||||
/// `Unreachable` is not an answer *about the repository* — the question could
|
||||
/// not be asked — and treating it as "no repository here" made a dropped link
|
||||
/// erase a panel that was showing perfectly good (if stale) data.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum StatusProbe {
|
||||
Status(Box<WorkingTreeStatus>),
|
||||
/// git ran and said so — an ordinary directory.
|
||||
NotARepo,
|
||||
/// git could not run, or ran and failed for a reason that is not "no
|
||||
/// repository": a dead link, a timeout, an `index.lock` held by someone
|
||||
/// else. Keep what is cached and ask again later.
|
||||
Unreachable,
|
||||
}
|
||||
|
||||
/// The whole working tree state for the repository containing `cwd`.
|
||||
///
|
||||
/// Three round trips in the common case — `rev-parse`, `status`, `read_dir` —
|
||||
/// and each one is an RPC on a remote workspace, which is why none of them is
|
||||
/// split into the several calls that would read more naturally.
|
||||
pub fn probe_status(host: &dyn Host, cwd: &Path) -> Option<WorkingTreeStatus> {
|
||||
let paths = super::git(
|
||||
host,
|
||||
pub fn probe_status(host: &dyn Host, cwd: &Path) -> StatusProbe {
|
||||
let Ok(out) = host.git(
|
||||
cwd,
|
||||
&[
|
||||
"rev-parse",
|
||||
@@ -810,16 +842,36 @@ pub fn probe_status(host: &dyn Host, cwd: &Path) -> Option<WorkingTreeStatus> {
|
||||
"--git-dir",
|
||||
"--git-common-dir",
|
||||
],
|
||||
)?;
|
||||
) else {
|
||||
return StatusProbe::Unreachable;
|
||||
};
|
||||
if !out.success() {
|
||||
// Exit 128 with this phrase is the ordinary answer for an ordinary
|
||||
// directory; the phrase has been stable (modulo case) since git 1.x.
|
||||
// Anything else is a repository that could not be read.
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_ascii_lowercase();
|
||||
return if stderr.contains("not a git repository") {
|
||||
StatusProbe::NotARepo
|
||||
} else {
|
||||
StatusProbe::Unreachable
|
||||
};
|
||||
}
|
||||
let paths = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
let mut lines = paths.lines().map(|l| l.trim_end_matches(['\n', '\r']));
|
||||
let root = PathBuf::from(lines.next()?);
|
||||
let Some(root) = lines.next().map(PathBuf::from) else {
|
||||
return StatusProbe::Unreachable;
|
||||
};
|
||||
let git_dir = lines.next();
|
||||
let home = super::repo_home(&root, git_dir, lines.next());
|
||||
let git_dir = PathBuf::from(git_dir?);
|
||||
let Some(git_dir) = git_dir.map(PathBuf::from) else {
|
||||
return StatusProbe::Unreachable;
|
||||
};
|
||||
|
||||
let out = host.git(cwd, STATUS_ARGS).ok()?;
|
||||
let Ok(out) = host.git(cwd, STATUS_ARGS) else {
|
||||
return StatusProbe::Unreachable;
|
||||
};
|
||||
if !out.success() {
|
||||
return None;
|
||||
return StatusProbe::Unreachable;
|
||||
}
|
||||
let mut parsed = parse_porcelain_v2(&out.stdout);
|
||||
if parsed.ahead_behind.is_none()
|
||||
@@ -832,7 +884,12 @@ pub fn probe_status(host: &dyn Host, cwd: &Path) -> Option<WorkingTreeStatus> {
|
||||
let operation = detect_operation(host, &git_dir, &listing);
|
||||
let prefilled_message =
|
||||
operation.and_then(|_| read_prefilled_message(host, &git_dir, &listing));
|
||||
Some(parsed.into_status(root, home, operation, prefilled_message))
|
||||
StatusProbe::Status(Box::new(parsed.into_status(
|
||||
root,
|
||||
home,
|
||||
operation,
|
||||
prefilled_message,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Ask for ahead/behind again when the header could not say.
|
||||
@@ -1436,6 +1493,48 @@ mod tests {
|
||||
index.dir("old").unwrap().changed,
|
||||
"the directory it left lost a file"
|
||||
);
|
||||
assert_eq!(
|
||||
index.file("old/home.rs"),
|
||||
None,
|
||||
"the old path holds no file row of its own"
|
||||
);
|
||||
}
|
||||
|
||||
/// `git mv a b && echo x > a`: the rename record's old path and a fresh
|
||||
/// untracked file share a spelling. The row on disk is the untracked file;
|
||||
/// the rename must not outrank it just because `Renamed > Untracked`.
|
||||
#[test]
|
||||
fn a_file_recreated_at_a_renames_old_path_decorates_as_itself() {
|
||||
let status = status_of(
|
||||
&[
|
||||
head_records(),
|
||||
rec(&[
|
||||
"2 R. N... 100644 100644 100644 ",
|
||||
SHA,
|
||||
" ",
|
||||
SHA,
|
||||
" R090 b.rs",
|
||||
]),
|
||||
rec(&["a.rs"]),
|
||||
rec(&["? a.rs"]),
|
||||
]
|
||||
.concat(),
|
||||
);
|
||||
let index = StatusIndex::build(&status);
|
||||
|
||||
assert_eq!(index.file("a.rs"), Some(DecoStatus::Untracked));
|
||||
assert_eq!(index.file("b.rs"), Some(DecoStatus::Renamed));
|
||||
}
|
||||
|
||||
/// `--ignored` is not passed today; the parser is future-proofed for it,
|
||||
/// and the decoration must be too — an ignored record carries no change
|
||||
/// codes and used to fall through to `Modified`.
|
||||
#[test]
|
||||
fn an_ignored_record_decorates_as_ignored_not_modified() {
|
||||
let parsed = parse_porcelain_v2(&[head_records(), rec(&["! target"])].concat());
|
||||
let entry = &parsed.entries[0];
|
||||
assert_eq!(entry.kind, EntryKind::Ignored);
|
||||
assert_eq!(entry.deco(), DecoStatus::Ignored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1465,6 +1564,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A probe that must have found a repository, unwrapped with a reason.
|
||||
fn probed(probe: StatusProbe, why: &str) -> WorkingTreeStatus {
|
||||
match probe {
|
||||
StatusProbe::Status(status) => *status,
|
||||
other => panic!("{why}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> Option<Scratch> {
|
||||
// The pid keeps two concurrent `cargo test` runs off each other's
|
||||
// fixture, since the directory is wiped on the way in — the same
|
||||
@@ -1524,7 +1631,10 @@ mod tests {
|
||||
assert!(run(&*host, repo, &["mv", "moved.txt", "renamed.txt"]));
|
||||
std::fs::write(repo.join("untracked.txt"), "loose\n").unwrap();
|
||||
|
||||
let status = probe_status(&*host, repo).expect("a repository was just created here");
|
||||
let status = probed(
|
||||
probe_status(&*host, repo),
|
||||
"a repository was just created here",
|
||||
);
|
||||
|
||||
match &status.head {
|
||||
HeadState::Branch { name, oid } => {
|
||||
@@ -1606,7 +1716,7 @@ mod tests {
|
||||
// Expected to fail — that is the point.
|
||||
run(&*host, repo, &["merge", "other"]);
|
||||
|
||||
let status = probe_status(&*host, repo).expect("still a repository mid-merge");
|
||||
let status = probed(probe_status(&*host, repo), "still a repository mid-merge");
|
||||
assert_eq!(status.operation, Some(RepoOperation::Merge));
|
||||
assert!(
|
||||
status
|
||||
@@ -1657,7 +1767,7 @@ mod tests {
|
||||
std::fs::write(repo.join("f.txt"), "two\n").unwrap();
|
||||
assert!(run(&*host, &repo, &["commit", "--quiet", "-am", "two"]));
|
||||
|
||||
let status = probe_status(&*host, &repo).expect("a repository with a remote");
|
||||
let status = probed(probe_status(&*host, &repo), "a repository with a remote");
|
||||
assert_eq!(status.upstream.as_deref(), Some("origin/main"));
|
||||
// Straight from `# branch.ab`; the `rev-list` fallback never runs here.
|
||||
assert_eq!(
|
||||
@@ -1668,13 +1778,20 @@ mod tests {
|
||||
assert!(status.is_clean());
|
||||
}
|
||||
|
||||
/// The two negative answers stay distinct: an ordinary directory is
|
||||
/// `NotARepo` (record it, stop asking), a directory git could not even be
|
||||
/// run in is `Unreachable` (keep what is cached, ask again later).
|
||||
#[test]
|
||||
fn outside_a_repository_there_is_no_status() {
|
||||
let host = crate::host::local::LocalHost::new();
|
||||
let Some(scratch) = scratch("not-a-repo") else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(probe_status(&*host, &scratch.0), None);
|
||||
assert_eq!(probe_status(&*host, Path::new("/no/such/tty7/path")), None);
|
||||
assert_eq!(probe_status(&*host, &scratch.0), StatusProbe::NotARepo);
|
||||
assert_eq!(
|
||||
probe_status(&*host, Path::new("/no/such/tty7/path")),
|
||||
StatusProbe::Unreachable,
|
||||
"a cwd that cannot be entered is not an answer about a repository"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,24 +669,32 @@ pub fn git_terminal_prompt_is_disabled(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
mkdir(h, &repo);
|
||||
let Some(()) = git_repo(h, &repo) else { return };
|
||||
|
||||
let configured = h.git(
|
||||
&repo,
|
||||
&[
|
||||
"config",
|
||||
"alias.tty7prompt",
|
||||
"!echo PROMPT=[$GIT_TERMINAL_PROMPT] REQUIRE=[$SSH_ASKPASS_REQUIRE]",
|
||||
],
|
||||
// Failures past this point assert rather than return: a broken alias or a
|
||||
// failing run is exactly a git that misbehaved, and returning would make
|
||||
// this case pass vacuously in precisely that situation.
|
||||
let out = h
|
||||
.git(
|
||||
&repo,
|
||||
&[
|
||||
"config",
|
||||
"alias.tty7prompt",
|
||||
"!echo PROMPT=[$GIT_TERMINAL_PROMPT] REQUIRE=[$SSH_ASKPASS_REQUIRE]",
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.success(),
|
||||
"config exited {:?}: {:?}",
|
||||
out.status,
|
||||
out.stderr_trimmed()
|
||||
);
|
||||
let out = h.git(&repo, &["tty7prompt"]).unwrap();
|
||||
assert!(
|
||||
out.success(),
|
||||
"alias run exited {:?}: {:?}",
|
||||
out.status,
|
||||
out.stderr_trimmed()
|
||||
);
|
||||
let Ok(out) = configured else { return };
|
||||
if !out.success() {
|
||||
return;
|
||||
}
|
||||
let Ok(out) = h.git(&repo, &["tty7prompt"]) else {
|
||||
return;
|
||||
};
|
||||
if !out.success() {
|
||||
return;
|
||||
}
|
||||
// A `push` that stops to ask for a username never comes back — and on the
|
||||
// far side of a control link there is no terminal to answer at anyway. The
|
||||
// remote host inherits this from the server's own local host, so both ends
|
||||
|
||||
@@ -50,7 +50,10 @@ const NO_PROMPT_ENV: &[(&str, Option<&str>)] = &[
|
||||
/// call, and this is the read path too.
|
||||
fn no_prompt_env() -> Vec<(&'static str, Option<&'static str>)> {
|
||||
let mut env = NO_PROMPT_ENV.to_vec();
|
||||
if std::env::var_os("GIT_SSH_COMMAND").is_none() {
|
||||
// `GIT_SSH` too: it is the older spelling of the same choice (plink on
|
||||
// Windows, most commonly), and `GIT_SSH_COMMAND` outranks it — forcing
|
||||
// ours would silently swap their transport out.
|
||||
if std::env::var_os("GIT_SSH_COMMAND").is_none() && std::env::var_os("GIT_SSH").is_none() {
|
||||
env.push(("GIT_SSH_COMMAND", Some("ssh -o BatchMode=yes")));
|
||||
}
|
||||
env
|
||||
|
||||
+118
-31
@@ -12,13 +12,6 @@
|
||||
//! entries a `git add` touched is a losing game; bumping a counter for the
|
||||
//! repository and letting readers notice they are behind is not.
|
||||
|
||||
// The watcher and the subscription gate now use this module, but the panel and
|
||||
// the file tree — the things that read the status and run the writes — are
|
||||
// still landing alongside it, so `status_of`, `index_of`, `run_git_op`,
|
||||
// `shell_quote` and the `FileTree`/`Editor` subscribers have no callers yet.
|
||||
// Take the allow off with the last of them; anything still unused then is.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
@@ -28,7 +21,7 @@ use std::time::{Duration, Instant};
|
||||
use gpui::{Context, Window};
|
||||
|
||||
use crate::core::git::ops::{GitOp, GitOpError, GitOpErrorKind, GitOpOutcome, run_op};
|
||||
use crate::core::git::status::{StatusIndex, WorkingTreeStatus, probe_status};
|
||||
use crate::core::git::status::{StatusIndex, StatusProbe, WorkingTreeStatus, probe_status};
|
||||
use crate::ui::app::Tty7App;
|
||||
use crate::ui::host_ops::{ByHost, Host, HostId, HostOps, InFlight, SharedHost, WatchSub};
|
||||
|
||||
@@ -44,6 +37,12 @@ use crate::ui::host_ops::{ByHost, Host, HostId, HostOps, InFlight, SharedHost, W
|
||||
/// (one repository on screen) keeps theoretical.
|
||||
pub const MAX_CONCURRENT_NETWORK_OPS: usize = 2;
|
||||
|
||||
/// How long a probe that could not reach its host rests before it is asked
|
||||
/// again. Only the render-driven retry waits this out — any real invalidation
|
||||
/// (a watcher event, a write, the Refresh button) bumps the epoch, which
|
||||
/// clears the rest and retries at once.
|
||||
pub const PROBE_FAILURE_RETRY: Duration = Duration::from_secs(10);
|
||||
|
||||
/// How long a failed watch open rests before it is tried again.
|
||||
///
|
||||
/// Without this the retry runs at frame rate: a failed open leaves the
|
||||
@@ -307,6 +306,14 @@ impl GitSubscriptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a probe hands back to the UI thread: [`StatusProbe`], with the
|
||||
/// decoration index pre-built off-thread and the panic case folded in.
|
||||
enum ProbeLanding {
|
||||
Status(Arc<WorkingTreeStatus>, Arc<StatusIndex>),
|
||||
NotARepo,
|
||||
Unreachable,
|
||||
}
|
||||
|
||||
/// One repository's `.git` watch and the burst it is feeding.
|
||||
#[derive(Default)]
|
||||
struct RepoWatch {
|
||||
@@ -331,6 +338,11 @@ pub struct ScmData {
|
||||
epoch: ByHost<PathBuf, u64>,
|
||||
/// repo root → the epoch the cached status was read at.
|
||||
read_at: ByHost<PathBuf, u64>,
|
||||
/// repo root → when a probe last came back *unreachable* — not "not a
|
||||
/// repository", but "the question could not be asked". The held status
|
||||
/// stays (stale beats blank), and `is_stale` sits out
|
||||
/// [`PROBE_FAILURE_RETRY`] so a dead link is not probed at frame rate.
|
||||
failed_at: ByHost<PathBuf, Instant>,
|
||||
probes: InFlight<(HostId, PathBuf)>,
|
||||
network: ByHost<PathBuf, Arc<AtomicUsize>>,
|
||||
/// repo root → its `.git` watch, once someone is looking. A plain map
|
||||
@@ -380,19 +392,28 @@ impl ScmData {
|
||||
}
|
||||
|
||||
/// Whether what we hold was read before the last thing that changed it.
|
||||
/// A repository we have never probed counts as stale.
|
||||
/// A repository we have never probed counts as stale — unless the last
|
||||
/// attempt could not reach the host and its rest has not passed yet.
|
||||
pub fn is_stale(&self, host: HostId, root: &Path) -> bool {
|
||||
match self.read_at.get(host, root) {
|
||||
let stale = match self.read_at.get(host, root) {
|
||||
Some(read) => *read < self.epoch(host, root),
|
||||
None => true,
|
||||
}
|
||||
};
|
||||
stale
|
||||
&& !self
|
||||
.failed_at
|
||||
.get(host, root)
|
||||
.is_some_and(|at| at.elapsed() < PROBE_FAILURE_RETRY)
|
||||
}
|
||||
|
||||
/// Mark a repository changed. Every write, every `.git` watcher event and
|
||||
/// every command boundary lands here; readers reprobe on their next look.
|
||||
/// A real change also ends a failure's rest: whatever made the epoch move
|
||||
/// is evidence the host is alive again.
|
||||
pub fn bump(&mut self, host: HostId, root: &Path) {
|
||||
let next = self.epoch(host, root) + 1;
|
||||
self.epoch.insert(host, root.to_path_buf(), next);
|
||||
self.failed_at.remove(host, root);
|
||||
self.probes.invalidate(&(host, root.to_path_buf()));
|
||||
}
|
||||
|
||||
@@ -403,9 +424,14 @@ impl ScmData {
|
||||
self.index.clear_host(host);
|
||||
self.epoch.clear_host(host);
|
||||
self.read_at.clear_host(host);
|
||||
self.failed_at.clear_host(host);
|
||||
self.network.clear_host(host);
|
||||
self.watches.retain(|(held, _), _| *held != host);
|
||||
self.subs.clear_host(host);
|
||||
// In-flight probe bookkeeping too: a probe whose landing never runs
|
||||
// (its work panicked, say) would otherwise hold `begin` false for
|
||||
// this key for the life of the process.
|
||||
self.probes.retain(|(held, _)| *held != host);
|
||||
self.wipe += 1;
|
||||
}
|
||||
|
||||
@@ -571,14 +597,24 @@ impl Tty7App {
|
||||
|
||||
let probe_root = root.clone();
|
||||
let this = cx.weak_entity();
|
||||
let again = host.clone();
|
||||
HostOps::run_detached(
|
||||
host,
|
||||
cx,
|
||||
move |h| {
|
||||
let status = probe_status(h, &probe_root)?;
|
||||
let index = StatusIndex::build(&status);
|
||||
Some((Arc::new(status), Arc::new(index)))
|
||||
// `catch_unwind` because a panic on the pool thread would skip
|
||||
// the landing entirely — and with it `probes.finish`, wedging
|
||||
// this repository's refresh for the life of the process.
|
||||
let probe = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
probe_status(h, &probe_root)
|
||||
}));
|
||||
match probe {
|
||||
Ok(StatusProbe::Status(status)) => {
|
||||
let index = StatusIndex::build(&status);
|
||||
ProbeLanding::Status(Arc::new(*status), Arc::new(index))
|
||||
}
|
||||
Ok(StatusProbe::NotARepo) => ProbeLanding::NotARepo,
|
||||
Ok(StatusProbe::Unreachable) | Err(_) => ProbeLanding::Unreachable,
|
||||
}
|
||||
},
|
||||
move |cx, result| {
|
||||
let data = cx.default_global::<ScmData>();
|
||||
@@ -591,8 +627,13 @@ impl Tty7App {
|
||||
if data.wipe != wipe || data.generation(id, &root) != sub_gen {
|
||||
return;
|
||||
}
|
||||
// Only a definitive answer counts as a read; an unreachable
|
||||
// host leaves what is cached (stale beats blank) and rests
|
||||
// before the next try — see `is_stale`.
|
||||
let definitive = !matches!(result, ProbeLanding::Unreachable);
|
||||
let mut not_a_repo = false;
|
||||
let changed = match result {
|
||||
Some((status, index)) => {
|
||||
ProbeLanding::Status(status, index) => {
|
||||
let same = data
|
||||
.status
|
||||
.get(id, root.as_path())
|
||||
@@ -606,13 +647,27 @@ impl Tty7App {
|
||||
// is what stops the next frame asking again: a pane whose
|
||||
// cwd is an ordinary directory would otherwise spawn a
|
||||
// `rev-parse` per frame, forever.
|
||||
None => {
|
||||
ProbeLanding::NotARepo => {
|
||||
not_a_repo = true;
|
||||
let held = data.status.remove(id, root.as_path()).is_some();
|
||||
data.index.remove(id, root.as_path());
|
||||
held
|
||||
}
|
||||
ProbeLanding::Unreachable => {
|
||||
data.failed_at.insert(id, root.clone(), Instant::now());
|
||||
false
|
||||
}
|
||||
};
|
||||
data.read_at.insert(id, root.clone(), at);
|
||||
if definitive {
|
||||
data.failed_at.remove(id, root.as_path());
|
||||
data.read_at.insert(id, root.clone(), at);
|
||||
}
|
||||
if not_a_repo {
|
||||
// Every cwd that resolved to this root must re-ask, or the
|
||||
// panel keeps drawing the Loading state of a repository
|
||||
// that is gone (`rm -rf .git` being the honest test).
|
||||
let _ = this.update(cx, |app, _| app.scm.forget_root(id, &root));
|
||||
}
|
||||
// `run_detached` lands with an `App` and no view, and writing
|
||||
// a global marks nothing dirty, so without this the panel and
|
||||
// the decorations wait for the next unrelated repaint.
|
||||
@@ -627,7 +682,13 @@ impl Tty7App {
|
||||
cx.refresh_windows();
|
||||
}
|
||||
if superseded {
|
||||
let _ = this.update(cx, |app, cx| app.scm_refresh(again, root, cx));
|
||||
// Through the debounce, not straight back into a probe:
|
||||
// during sustained churn on a repository whose status read
|
||||
// outlives the event interval, a direct relaunch runs
|
||||
// probes back to back for the whole of it. The bump-and-
|
||||
// wait path coalesces the retry with whatever is still
|
||||
// landing.
|
||||
let _ = this.update(cx, |app, cx| app.scm_invalidate(id, &root, cx));
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -832,6 +893,9 @@ impl Tty7App {
|
||||
cx.default_global::<ScmData>().clear_host(host);
|
||||
cx.default_global::<crate::terminal::git_status::GitStatusCache>()
|
||||
.clear_host(host);
|
||||
// The panel's own per-cwd caches too — `roots` grows one entry
|
||||
// per directory ever visited on the dead link otherwise.
|
||||
self.scm.forget_host(host);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -846,22 +910,29 @@ impl Tty7App {
|
||||
return;
|
||||
}
|
||||
let sub_gen = cx.default_global::<ScmData>().generation(id, &root);
|
||||
let wipe = cx.default_global::<ScmData>().wipe;
|
||||
let probe_root = root.clone();
|
||||
HostOps::run(
|
||||
host,
|
||||
cx,
|
||||
move |h| {
|
||||
let dirs = scm_watch_dirs(h, &probe_root)?;
|
||||
match h.watch(&dirs) {
|
||||
Ok(sub) => Some(Arc::new(sub)),
|
||||
Err(e) => {
|
||||
log::warn!("source control: no watch for {probe_root:?}: {e}");
|
||||
None
|
||||
// `catch_unwind` for the same reason the status probe carries
|
||||
// it: a panic here would skip the landing, and with it
|
||||
// `finish_watch_open` — `opening` would stay true forever.
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let dirs = scm_watch_dirs(h, &probe_root)?;
|
||||
match h.watch(&dirs) {
|
||||
Ok(sub) => Some(Arc::new(sub)),
|
||||
Err(e) => {
|
||||
log::warn!("source control: no watch for {probe_root:?}: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
.unwrap_or(None)
|
||||
},
|
||||
move |app, sub: Option<Arc<WatchSub>>, cx| {
|
||||
app.scm_watch_opened(id, root, sub_gen, sub, cx)
|
||||
app.scm_watch_opened(id, root, sub_gen, wipe, sub, cx)
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -871,13 +942,20 @@ impl Tty7App {
|
||||
host: HostId,
|
||||
root: PathBuf,
|
||||
sub_gen: u64,
|
||||
wipe: u64,
|
||||
sub: Option<Arc<WatchSub>>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let data = cx.default_global::<ScmData>();
|
||||
// Letting go while the watch was opening leaves the only `Arc` here,
|
||||
// so returning closes it.
|
||||
if data.generation(host, &root) != sub_gen || !data.is_subscribed(host, &root) {
|
||||
// so returning closes it. `wipe` closes the one gap `generation`
|
||||
// cannot: a disconnect resets generations to their default, so a
|
||||
// watch opened against the *previous* connection could otherwise be
|
||||
// installed for the re-subscribed repository.
|
||||
if data.wipe != wipe
|
||||
|| data.generation(host, &root) != sub_gen
|
||||
|| !data.is_subscribed(host, &root)
|
||||
{
|
||||
data.finish_watch_open(host, &root, None, Instant::now());
|
||||
return;
|
||||
}
|
||||
@@ -914,7 +992,7 @@ impl Tty7App {
|
||||
root: PathBuf,
|
||||
op: GitOp,
|
||||
then: Option<crate::ui::scm::actions::ScmFollowUp>,
|
||||
window: &Window,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(status) = status_of(cx, host.id(), &root) else {
|
||||
@@ -929,7 +1007,16 @@ impl Tty7App {
|
||||
let slot = if op.is_network() {
|
||||
match cx.default_global::<ScmData>().take_network_slot(id, &root) {
|
||||
Some(slot) => Some(slot),
|
||||
None => return,
|
||||
None => {
|
||||
// Said out loud: a swallowed click on Push looks exactly
|
||||
// like a push that finished instantly.
|
||||
gpui_component::WindowExt::push_notification(
|
||||
window,
|
||||
crate::ui::i18n::t(crate::ui::i18n::L10nKey::ScmNetworkBusy).to_string(),
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
|
||||
+13
-9
@@ -146,11 +146,6 @@ impl Tty7App {
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
let seed = DiffLoad::Loading;
|
||||
let epoch = match &seed {
|
||||
DiffLoad::Ready(snap) => Some(scm_epoch(cx, host, &snap.root)),
|
||||
_ => None,
|
||||
};
|
||||
self.remember_active_pane(window, cx);
|
||||
let Some(tab) = self.tabs.get_mut(active) else {
|
||||
return;
|
||||
@@ -161,25 +156,34 @@ impl Tty7App {
|
||||
cwd,
|
||||
source,
|
||||
focus_handle: focus_handle.clone(),
|
||||
load: seed,
|
||||
// Every open starts at Loading until its own probe lands. The old
|
||||
// panel-snapshot seeding died with the panel that held a snapshot
|
||||
// per source; re-seeding would need the caller to carry one.
|
||||
load: DiffLoad::Loading,
|
||||
loading: false,
|
||||
expanded: HashMap::new(),
|
||||
focus,
|
||||
scroll: gpui::ScrollHandle::new(),
|
||||
epoch,
|
||||
epoch: None,
|
||||
});
|
||||
window.focus(&focus_handle, cx);
|
||||
self.spawn_diff_probe(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Which file the open overlay is focused on — for the row that asked,
|
||||
/// which means the *source* has to match too: a file staged and edited
|
||||
/// again sits in two panel groups, and only the row whose patch is
|
||||
/// actually on screen may draw itself selected.
|
||||
pub(crate) fn diff_overlay_focus(
|
||||
&self,
|
||||
host: crate::ui::host_ops::HostId,
|
||||
cwd: &std::path::Path,
|
||||
source: &crate::terminal::git_diff::DiffSource,
|
||||
) -> Option<&str> {
|
||||
let overlay = self.tabs.get(self.active)?.diff_overlay.as_ref()?;
|
||||
(overlay.cwd == cwd && overlay.host_id == host).then_some(overlay.focus.as_deref())?
|
||||
(overlay.cwd == cwd && overlay.host_id == host && overlay.source == *source)
|
||||
.then_some(overlay.focus.as_deref())?
|
||||
}
|
||||
|
||||
pub(crate) fn close_diff_overlay(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -1149,7 +1153,7 @@ fn source_subject(source: &DiffSource, branch: String) -> SourceSubject {
|
||||
DiffSource::Worktree | DiffSource::Head => branch_of(None),
|
||||
// Staged is the branch too, but a patch that does not match the files
|
||||
// on disk — without the chip it is indistinguishable from the above.
|
||||
DiffSource::Staged => branch_of(Some("STAGED")),
|
||||
DiffSource::Staged => branch_of(Some(t(L10nKey::ScmChipStaged))),
|
||||
DiffSource::Commit { rev, label } => SourceSubject {
|
||||
icon: "icons/git-commit.svg",
|
||||
text: short_rev(rev),
|
||||
|
||||
@@ -241,6 +241,14 @@ impl<K: Eq + Hash + Clone> InFlight<K> {
|
||||
self.stale.extend(self.in_flight.iter().cloned());
|
||||
}
|
||||
|
||||
/// Drop every key the predicate rejects — bookkeeping for work that will
|
||||
/// never land (a host cleared away under an in-flight job). If the job
|
||||
/// does land after all, its `finish` is a no-op rather than a poison.
|
||||
pub fn retain(&mut self, keep: impl Fn(&K) -> bool) {
|
||||
self.in_flight.retain(|k| keep(k));
|
||||
self.stale.retain(|k| keep(k));
|
||||
}
|
||||
|
||||
pub fn finish(&mut self, key: &K) -> bool {
|
||||
self.in_flight.remove(key);
|
||||
!self.stale.remove(key)
|
||||
|
||||
+15
-4
@@ -845,6 +845,21 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
L10nKey::ScmCommitStaged => "Commit Staged",
|
||||
L10nKey::ScmStashAll => "Stash All",
|
||||
L10nKey::ScmNothingToCommit => "Nothing to commit",
|
||||
L10nKey::ScmNetworkBusy => "Another network operation is still running for this repository",
|
||||
L10nKey::ScmCommitNeedsMessage => "Write a commit message first",
|
||||
L10nKey::ScmDetailFilesFailed => "The file list could not be read",
|
||||
L10nKey::ScmTimeNow => "now",
|
||||
L10nKey::ScmTimeMinutes => "{n}m",
|
||||
L10nKey::ScmTimeHours => "{n}h",
|
||||
L10nKey::ScmTimeDays => "{n}d",
|
||||
L10nKey::ScmTimeMonths => "{n}mo",
|
||||
L10nKey::ScmTimeYears => "{n}y",
|
||||
L10nKey::ScmResetHardConfirm => {
|
||||
"Reset the branch to this commit? Commits after it fall off the branch, \
|
||||
and uncommitted changes are discarded."
|
||||
}
|
||||
L10nKey::ScmReset => "Reset",
|
||||
L10nKey::ScmChipStaged => "STAGED",
|
||||
L10nKey::ScmStage => "Stage Changes",
|
||||
L10nKey::ScmStageAll => "Stage All Changes",
|
||||
L10nKey::ScmUnstage => "Unstage Changes",
|
||||
@@ -1314,7 +1329,6 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
L10nKey::PanelMoreChangedFiles => {
|
||||
"… and {count} more changed files — run `git diff` to see them."
|
||||
}
|
||||
L10nKey::PanelUntracked => "{count} untracked",
|
||||
L10nKey::ScmFilesChanged => "{count} files changed",
|
||||
L10nKey::ScmStagedFileCount => "{count} files staged",
|
||||
L10nKey::AppMenuAbout => "About tty7",
|
||||
@@ -1425,9 +1439,6 @@ pub fn translate_variant_en(key: L10nKey, branch: &'static str) -> Option<&'stat
|
||||
(L10nKey::ScmStagedFileCount, "zero") => "No staged changes",
|
||||
(L10nKey::ScmStagedFileCount, "one") => "1 file staged",
|
||||
(L10nKey::ScmStagedFileCount, "other") => "{count} files staged",
|
||||
(L10nKey::PanelUntracked, "zero") => "0 untracked",
|
||||
(L10nKey::PanelUntracked, "one") => "1 untracked",
|
||||
(L10nKey::PanelUntracked, "other") => "{count} untracked",
|
||||
(L10nKey::PanelMoreChangedFiles, "zero") => {
|
||||
"… and 0 more changed files — run `git diff` to see them."
|
||||
}
|
||||
|
||||
+16
-4
@@ -895,6 +895,22 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::ScmCommitStaged => "ステージ済みをコミット",
|
||||
L10nKey::ScmStashAll => "すべてスタッシュ",
|
||||
L10nKey::ScmNothingToCommit => "コミットするものがありません",
|
||||
L10nKey::ScmNetworkBusy => "このリポジトリでは別のネットワーク操作が実行中です",
|
||||
L10nKey::ScmCommitNeedsMessage => "先にコミットメッセージを入力してください",
|
||||
L10nKey::ScmDetailFilesFailed => "ファイル一覧を読み込めませんでした",
|
||||
L10nKey::ScmTimeNow => "今",
|
||||
L10nKey::ScmTimeMinutes => "{n}分",
|
||||
// 「{n}時」は時刻に読めるので「時間」のまま。
|
||||
L10nKey::ScmTimeHours => "{n}時間",
|
||||
L10nKey::ScmTimeDays => "{n}日",
|
||||
L10nKey::ScmTimeMonths => "{n}か月",
|
||||
L10nKey::ScmTimeYears => "{n}年",
|
||||
L10nKey::ScmResetHardConfirm => {
|
||||
"ブランチをこのコミットへリセットしますか?それ以降のコミットはブランチから外れ、\
|
||||
未コミットの変更は破棄されます。"
|
||||
}
|
||||
L10nKey::ScmReset => "リセット",
|
||||
L10nKey::ScmChipStaged => "ステージ済み",
|
||||
L10nKey::ScmStage => "変更をステージ",
|
||||
L10nKey::ScmStageAll => "すべての変更をステージ",
|
||||
L10nKey::ScmUnstage => "ステージを取り消す",
|
||||
@@ -1359,7 +1375,6 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::PanelMoreChangedFiles => {
|
||||
"… さらに変更されたファイル {count} 個 — 表示するには `git diff` を実行してください"
|
||||
}
|
||||
L10nKey::PanelUntracked => "未追跡 {count}",
|
||||
L10nKey::ScmFilesChanged => "{count} 個のファイルが変更されました",
|
||||
L10nKey::ScmStagedFileCount => "{count} 個のファイルがステージされました",
|
||||
L10nKey::AppMenuAbout => "tty7 について",
|
||||
@@ -1468,9 +1483,6 @@ pub fn translate_variant_ja(key: L10nKey, branch: &'static str) -> Option<&'stat
|
||||
(L10nKey::ScmStagedFileCount, "zero") => "ステージされた変更はありません",
|
||||
(L10nKey::ScmStagedFileCount, "one") => "1 個のファイルがステージされました",
|
||||
(L10nKey::ScmStagedFileCount, "other") => "{count} 個のファイルがステージされました",
|
||||
(L10nKey::PanelUntracked, "zero") => "未追跡 0",
|
||||
(L10nKey::PanelUntracked, "one") => "未追跡 1",
|
||||
(L10nKey::PanelUntracked, "other") => "未追跡 {count}",
|
||||
(L10nKey::PanelMoreChangedFiles, "zero") => {
|
||||
"… さらに変更されたファイル 0 個 — 表示するには `git diff` を実行してください"
|
||||
}
|
||||
|
||||
+16
-64
@@ -630,7 +630,6 @@ pub enum L10nKey {
|
||||
PanelNoChanges,
|
||||
PanelNoChangesHint,
|
||||
PanelMoreChangedFiles,
|
||||
PanelUntracked,
|
||||
PanelSessionSubtitle,
|
||||
PanelProcessesSubtitle,
|
||||
PanelPortsSubtitle,
|
||||
@@ -660,6 +659,18 @@ pub enum L10nKey {
|
||||
ScmCommitStaged,
|
||||
ScmStashAll,
|
||||
ScmNothingToCommit,
|
||||
ScmNetworkBusy,
|
||||
ScmCommitNeedsMessage,
|
||||
ScmDetailFilesFailed,
|
||||
ScmTimeNow,
|
||||
ScmTimeMinutes,
|
||||
ScmTimeHours,
|
||||
ScmTimeDays,
|
||||
ScmTimeMonths,
|
||||
ScmTimeYears,
|
||||
ScmResetHardConfirm,
|
||||
ScmReset,
|
||||
ScmChipStaged,
|
||||
ScmStage,
|
||||
ScmStageAll,
|
||||
ScmUnstage,
|
||||
@@ -1122,70 +1133,13 @@ pub enum L10nKey {
|
||||
/// **Delete a key from this list as soon as something renders it.**
|
||||
#[allow(dead_code)]
|
||||
const SCM_KEYS_AWAITING_A_CALLER: &[L10nKey] = &[
|
||||
L10nKey::ScmGroupMerge,
|
||||
L10nKey::ScmGroupStaged,
|
||||
L10nKey::ScmGroupChanges,
|
||||
L10nKey::ScmGroupUntracked,
|
||||
L10nKey::ScmCommitPlaceholder,
|
||||
L10nKey::ScmCommitButton,
|
||||
L10nKey::ScmCommitAllButton,
|
||||
L10nKey::ScmCommitAmendButton,
|
||||
L10nKey::ScmCommitAndPush,
|
||||
L10nKey::ScmCommitAndSync,
|
||||
L10nKey::ScmAmendLastCommit,
|
||||
L10nKey::ScmCommitStaged,
|
||||
L10nKey::ScmStashAll,
|
||||
L10nKey::ScmNothingToCommit,
|
||||
L10nKey::ScmStage,
|
||||
L10nKey::ScmStageAll,
|
||||
L10nKey::ScmUnstage,
|
||||
L10nKey::ScmUnstageAll,
|
||||
L10nKey::ScmDiscard,
|
||||
L10nKey::ScmDiscardAll,
|
||||
L10nKey::ScmDiscardConfirm,
|
||||
L10nKey::ScmOpenConflict,
|
||||
L10nKey::ScmMarkResolved,
|
||||
L10nKey::ScmUnrepresentablePath,
|
||||
L10nKey::ScmPublishBranch,
|
||||
L10nKey::ScmDetached,
|
||||
L10nKey::ScmAmendBadge,
|
||||
L10nKey::ScmSync,
|
||||
L10nKey::ScmPush,
|
||||
L10nKey::ScmPull,
|
||||
L10nKey::ScmFetch,
|
||||
L10nKey::ScmCheckoutBranch,
|
||||
L10nKey::ScmCreateBranch,
|
||||
L10nKey::ScmCommitDetailTitle,
|
||||
L10nKey::ScmCommitStaged,
|
||||
L10nKey::ScmRefresh,
|
||||
L10nKey::ScmResetToCommit,
|
||||
L10nKey::ScmSearchBranches,
|
||||
L10nKey::ScmStashAndSwitch,
|
||||
L10nKey::ScmGraphTitle,
|
||||
L10nKey::ScmGraphLoadMore,
|
||||
L10nKey::ScmGraphFilterPlaceholder,
|
||||
L10nKey::ScmGraphAllBranches,
|
||||
L10nKey::ScmGraphEmpty,
|
||||
L10nKey::ScmGraphCurrentBranch,
|
||||
L10nKey::ScmCheckoutCommit,
|
||||
L10nKey::ScmCreateBranchHere,
|
||||
L10nKey::ScmResetSoft,
|
||||
L10nKey::ScmResetMixed,
|
||||
L10nKey::ScmResetHard,
|
||||
L10nKey::ScmCommitDetailTitle,
|
||||
L10nKey::ScmCherryPick,
|
||||
L10nKey::ScmRevertCommit,
|
||||
L10nKey::ScmResetToCommit,
|
||||
L10nKey::ScmRefresh,
|
||||
L10nKey::ScmTooManyChanges,
|
||||
L10nKey::ScmOpenChanges,
|
||||
L10nKey::ScmDiscardAllConfirm,
|
||||
L10nKey::ScmAmendConfirm,
|
||||
L10nKey::ScmOpMerge,
|
||||
L10nKey::ScmOpRebase,
|
||||
L10nKey::ScmOpCherryPick,
|
||||
L10nKey::ScmOpRevert,
|
||||
L10nKey::ScmOpBisect,
|
||||
L10nKey::ScmOpAm,
|
||||
L10nKey::ScmSwitchRepository,
|
||||
L10nKey::DiffViewSplit,
|
||||
L10nKey::DiffViewUnified,
|
||||
];
|
||||
|
||||
pub fn set_locale(gui_language: &str) {
|
||||
@@ -1844,7 +1798,6 @@ mod tests {
|
||||
L10nKey::PanelNoChanges,
|
||||
L10nKey::PanelNoChangesHint,
|
||||
L10nKey::PanelMoreChangedFiles,
|
||||
L10nKey::PanelUntracked,
|
||||
L10nKey::PanelSessionSubtitle,
|
||||
L10nKey::PanelProcessesSubtitle,
|
||||
L10nKey::PanelPortsSubtitle,
|
||||
@@ -2263,7 +2216,6 @@ mod tests {
|
||||
L10nKey::SettingsAliasesLinked,
|
||||
L10nKey::SettingsRulesOpenedWithConnection,
|
||||
L10nKey::SettingsOfflineMachines,
|
||||
L10nKey::PanelUntracked,
|
||||
L10nKey::PanelMoreChangedFiles,
|
||||
L10nKey::ScmFilesChanged,
|
||||
L10nKey::ScmStagedFileCount,
|
||||
|
||||
+16
-5
@@ -808,7 +808,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::ScmGroupStaged => "暂存的更改",
|
||||
L10nKey::ScmGroupChanges => "更改",
|
||||
L10nKey::ScmGroupUntracked => "未跟踪",
|
||||
L10nKey::ScmCommitPlaceholder => "写点什么改了…",
|
||||
L10nKey::ScmCommitPlaceholder => "说说改了什么…",
|
||||
L10nKey::ScmCommitButton => "提交",
|
||||
L10nKey::ScmCommitAllButton => "提交全部",
|
||||
L10nKey::ScmCommitAmendButton => "提交(修订)",
|
||||
@@ -818,6 +818,21 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::ScmCommitStaged => "提交已暂存的更改",
|
||||
L10nKey::ScmStashAll => "全部贮藏",
|
||||
L10nKey::ScmNothingToCommit => "没有可提交的内容",
|
||||
L10nKey::ScmNetworkBusy => "这个仓库还有一个网络操作在进行中",
|
||||
L10nKey::ScmCommitNeedsMessage => "先写一条提交信息",
|
||||
L10nKey::ScmDetailFilesFailed => "无法读取文件列表",
|
||||
L10nKey::ScmTimeNow => "刚刚",
|
||||
L10nKey::ScmTimeMinutes => "{n}分",
|
||||
L10nKey::ScmTimeHours => "{n}时",
|
||||
L10nKey::ScmTimeDays => "{n}天",
|
||||
// "个月" 而不是 "月":"3月" 会被读成月份名。
|
||||
L10nKey::ScmTimeMonths => "{n}个月",
|
||||
L10nKey::ScmTimeYears => "{n}年",
|
||||
L10nKey::ScmResetHardConfirm => {
|
||||
"把分支重置到这个提交?之后的提交会从分支上消失,未提交的更改会被丢弃。"
|
||||
}
|
||||
L10nKey::ScmReset => "重置",
|
||||
L10nKey::ScmChipStaged => "已暂存",
|
||||
L10nKey::ScmStage => "暂存更改",
|
||||
L10nKey::ScmStageAll => "暂存全部更改",
|
||||
L10nKey::ScmUnstage => "取消暂存",
|
||||
@@ -1251,7 +1266,6 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::SftpErrorUnsafeRemoteName => "拒绝不安全的远程名称 {name}",
|
||||
L10nKey::SftpErrorInvalidOctalMode => "无效的八进制模式",
|
||||
L10nKey::PanelMoreChangedFiles => "…还有 {count} 个变更文件——运行 `git diff` 查看。",
|
||||
L10nKey::PanelUntracked => "{count} 个未跟踪文件",
|
||||
L10nKey::ScmFilesChanged => "{count} 个文件改动",
|
||||
L10nKey::ScmStagedFileCount => "已暂存 {count} 个文件",
|
||||
L10nKey::AppMenuAbout => "关于 tty7",
|
||||
@@ -1358,9 +1372,6 @@ pub fn translate_variant_zh(key: L10nKey, branch: &'static str) -> Option<&'stat
|
||||
(L10nKey::ScmStagedFileCount, "zero") => "没有暂存的更改",
|
||||
(L10nKey::ScmStagedFileCount, "one") => "已暂存 1 个文件",
|
||||
(L10nKey::ScmStagedFileCount, "other") => "已暂存 {count} 个文件",
|
||||
(L10nKey::PanelUntracked, "zero") => "0 个未跟踪文件",
|
||||
(L10nKey::PanelUntracked, "one") => "1 个未跟踪文件",
|
||||
(L10nKey::PanelUntracked, "other") => "{count} 个未跟踪文件",
|
||||
(L10nKey::PanelMoreChangedFiles, "zero") => "…还有 0 个变更文件——运行 `git diff` 查看。",
|
||||
(L10nKey::PanelMoreChangedFiles, "one") => "…还有 1 个变更文件——运行 `git diff` 查看。",
|
||||
(L10nKey::PanelMoreChangedFiles, "other") => {
|
||||
|
||||
+11
-2
@@ -124,7 +124,7 @@ impl Tty7App {
|
||||
PromptLevel::Warning,
|
||||
&confirm_question(&op, loss),
|
||||
None,
|
||||
&[t(L10nKey::Cancel), confirm_verb(loss)],
|
||||
&[t(L10nKey::Cancel), confirm_verb(&op, loss)],
|
||||
cx,
|
||||
);
|
||||
cx.spawn_in(window, async move |app, cx| {
|
||||
@@ -465,6 +465,12 @@ pub(crate) fn split_upstream(upstream: &str) -> Option<(&str, &str)> {
|
||||
|
||||
/// The question a destructive operation has to answer before it runs.
|
||||
fn confirm_question(op: &GitOp, loss: Destructive) -> String {
|
||||
// Its own question, not the discard one: a hard reset to an older commit
|
||||
// drops commits off the branch, and a dialog that says "Discard every
|
||||
// change in this repository?" never mentions the part that hurts.
|
||||
if matches!(op, GitOp::Reset { .. }) {
|
||||
return t(L10nKey::ScmResetHardConfirm).to_string();
|
||||
}
|
||||
match loss {
|
||||
Destructive::RewritesHistory => t(L10nKey::ScmAmendConfirm).to_string(),
|
||||
// One file gets named; a whole group does not, because a list of two
|
||||
@@ -476,7 +482,10 @@ fn confirm_question(op: &GitOp, loss: Destructive) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn confirm_verb(loss: Destructive) -> &'static str {
|
||||
fn confirm_verb(op: &GitOp, loss: Destructive) -> &'static str {
|
||||
if matches!(op, GitOp::Reset { .. }) {
|
||||
return t(L10nKey::ScmReset);
|
||||
}
|
||||
match loss {
|
||||
Destructive::RewritesHistory => t(L10nKey::ScmAmendLastCommit),
|
||||
_ => t(L10nKey::ScmDiscard),
|
||||
|
||||
+13
-3
@@ -241,7 +241,12 @@ impl Tty7App {
|
||||
if let Some(commit) = commit {
|
||||
open.commit = Some(Arc::new(commit));
|
||||
}
|
||||
open.files = Some(Arc::new(files.unwrap_or_default()));
|
||||
match files {
|
||||
Some(files) => open.files = Some(Arc::new(files)),
|
||||
// A failed read is not an empty commit — see
|
||||
// `CommitDetailView::files_failed`.
|
||||
None => open.files_failed = true,
|
||||
}
|
||||
cx.notify();
|
||||
},
|
||||
);
|
||||
@@ -531,7 +536,12 @@ impl Tty7App {
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let Some(files) = detail.files.clone() else {
|
||||
return self.detail_note(t(L10nKey::PanelLoading).to_string(), cx);
|
||||
let note = if detail.files_failed {
|
||||
L10nKey::ScmDetailFilesFailed
|
||||
} else {
|
||||
L10nKey::PanelLoading
|
||||
};
|
||||
return self.detail_note(t(note).to_string(), cx);
|
||||
};
|
||||
let list = v_flex().child(self.detail_summary(&files, mono, cx));
|
||||
// The label rides along on the source so the overlay's header can say
|
||||
@@ -649,7 +659,7 @@ impl Tty7App {
|
||||
let sf = panel_surface(cx);
|
||||
let deco = crate::ui::diff_overlay::deco_status(file.status);
|
||||
let (name, dir) = split_display_path(&file.path);
|
||||
let selected = self.diff_overlay_focus(detail.repo.host, &detail.repo.root)
|
||||
let selected = self.diff_overlay_focus(detail.repo.host, &detail.repo.root, source)
|
||||
== Some(file.path.as_str());
|
||||
|
||||
h_flex()
|
||||
|
||||
+80
-16
@@ -74,6 +74,10 @@ use crate::ui::scm::state::RepoKey;
|
||||
/// phi, so 12px occupies `round(12 × 1.618) = 19px`, and 20 is the first even
|
||||
/// pitch above it.
|
||||
const GRAPH_ROW_H: f32 = 20.;
|
||||
/// Rows materialized above and below the visible band, so a fast scroll never
|
||||
/// outruns the window into blank space, and the "load more" band — laid out
|
||||
/// one row past the window's end — stays below the fold until it is real.
|
||||
const GRAPH_WINDOW_MARGIN: usize = 4;
|
||||
|
||||
/// Header of the section itself: fold, title, count, filter tile, scope picker.
|
||||
///
|
||||
@@ -314,6 +318,14 @@ struct GraphPaint {
|
||||
/// transparency when one is configured, which would let the lane line show
|
||||
/// straight down the middle of the node.
|
||||
surface: Hsla,
|
||||
/// The selected row and the fill its band paints under the node, so a
|
||||
/// hollow node's hole matches the selection band it sits on instead of
|
||||
/// punching through to the resting surface. Hover is not covered — it
|
||||
/// lives in gpui's element state, which a paint closure cannot read — so
|
||||
/// a hovered ring keeps the resting hole; one step of fill under a 3px
|
||||
/// hole, against a whole selected band showing the wrong colour.
|
||||
selected: Option<usize>,
|
||||
selected_surface: Hsla,
|
||||
/// Whether a "load more" band follows the last row.
|
||||
more: bool,
|
||||
}
|
||||
@@ -419,6 +431,11 @@ fn paint_graph(p: &GraphPaint, bounds: Bounds<Pixels>, window: &mut Window) {
|
||||
// because the border is part of that same SDF — stacking would blend the
|
||||
// inner edge over the outer one's already-blended edge, and a 3px hole
|
||||
// is where that shows.
|
||||
let hole = if p.selected == Some(i) {
|
||||
p.selected_surface
|
||||
} else {
|
||||
p.surface
|
||||
};
|
||||
if row.parents > 1 {
|
||||
// A merge is a ring. It is the one row shape a reader scans for,
|
||||
// and an outline reads at 8px where a second fill colour does not.
|
||||
@@ -426,7 +443,7 @@ fn paint_graph(p: &GraphPaint, bounds: Bounds<Pixels>, window: &mut Window) {
|
||||
window.paint_quad(quad(
|
||||
dot(r),
|
||||
Corners::all(px(r)),
|
||||
p.surface,
|
||||
hole,
|
||||
Edges::all(px(GRAPH_LINE_W)),
|
||||
ink,
|
||||
BorderStyle::Solid,
|
||||
@@ -437,7 +454,7 @@ fn paint_graph(p: &GraphPaint, bounds: Bounds<Pixels>, window: &mut Window) {
|
||||
window.paint_quad(quad(
|
||||
dot(GRAPH_DOT_R),
|
||||
Corners::all(px(GRAPH_DOT_R)),
|
||||
p.surface,
|
||||
hole,
|
||||
Edges::all(px(GRAPH_LINE_W)),
|
||||
ink,
|
||||
BorderStyle::Solid,
|
||||
@@ -527,7 +544,7 @@ impl Tty7App {
|
||||
Some(page) if page.commits.is_empty() => {
|
||||
self.panel_empty(t(L10nKey::ScmGraphEmpty), None, cx)
|
||||
}
|
||||
Some(page) => self.graph_body(repo, &page, query.as_deref(), cx),
|
||||
Some(page) => self.graph_body(repo, &page, query.as_deref(), height, cx),
|
||||
};
|
||||
let (backing, handle) = self.graph_resize(ceiling, cx);
|
||||
|
||||
@@ -631,6 +648,29 @@ impl Tty7App {
|
||||
);
|
||||
}
|
||||
|
||||
/// Which commit indices the list shows for this page and query, resolved
|
||||
/// through the cache on `GraphState` — see its doc for why it exists.
|
||||
fn graph_visible_rows(
|
||||
&mut self,
|
||||
page: &Arc<CommitPage>,
|
||||
query: Option<&str>,
|
||||
) -> Arc<Vec<usize>> {
|
||||
let key = Arc::as_ptr(page) as usize;
|
||||
if let Some((held_query, held_page, rows)) = &self.scm.graph.filter_cache {
|
||||
if *held_page == key && held_query.as_deref() == query {
|
||||
return rows.clone();
|
||||
}
|
||||
}
|
||||
let rows: Arc<Vec<usize>> = Arc::new(match query {
|
||||
None => (0..page.commits.len()).collect(),
|
||||
Some(q) => (0..page.commits.len())
|
||||
.filter(|i| matches_query(&page.commits[*i], q))
|
||||
.collect(),
|
||||
});
|
||||
self.scm.graph.filter_cache = Some((query.map(str::to_string), key, rows.clone()));
|
||||
rows
|
||||
}
|
||||
|
||||
/// The filter box's text, if it has any.
|
||||
fn graph_query(&self, cx: &Context<Self>) -> Option<String> {
|
||||
let input = self.scm.graph.search.as_ref()?;
|
||||
@@ -693,11 +733,15 @@ impl Tty7App {
|
||||
|
||||
impl Tty7App {
|
||||
/// The scrolling list: rows underneath, one canvas over the gutter.
|
||||
///
|
||||
/// `height` is the section's height — the ceiling on how much of the list
|
||||
/// can be on screen, and so on how many rows become elements.
|
||||
fn graph_body(
|
||||
&mut self,
|
||||
repo: &RepoKey,
|
||||
page: &Arc<CommitPage>,
|
||||
query: Option<&str>,
|
||||
height: f32,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let panel_w = cx.global::<crate::core::config::Config>().right_panel_width;
|
||||
@@ -710,22 +754,31 @@ impl Tty7App {
|
||||
// So the filter hides the gutter entirely and the list becomes a flat
|
||||
// search result — which is what it actually is.
|
||||
let filtering = query.is_some();
|
||||
let rows: Vec<usize> = match query {
|
||||
None => (0..page.commits.len()).collect(),
|
||||
Some(q) => (0..page.commits.len())
|
||||
.filter(|i| matches_query(&page.commits[*i], q))
|
||||
.collect(),
|
||||
};
|
||||
let rows = self.graph_visible_rows(page, query);
|
||||
// No row at the cap either: `load_page` clamps there, so "load more"
|
||||
// past it could only refetch what is already on screen.
|
||||
let more = query.is_none() && !page.complete && page.commits.len() < MAX_GRAPH_COMMITS;
|
||||
let bands = rows.len() + usize::from(more);
|
||||
|
||||
// Only the rows that can be on screen become elements — the row count
|
||||
// is bounded by the cap at 5000, and a taffy pass over 5000 flex
|
||||
// children per frame is most of a frame. The stack below keeps its
|
||||
// full fixed height so the scroll range is unchanged; a top padding
|
||||
// stands in for everything scrolled past. The canvas needs no such
|
||||
// treatment: its paint is already clipped to the content mask.
|
||||
let scrolled = (-self.scm.graph.scroll.offset().y.as_f32()).max(0.);
|
||||
let first = ((scrolled / GRAPH_ROW_H) as usize)
|
||||
.saturating_sub(GRAPH_WINDOW_MARGIN)
|
||||
.min(rows.len());
|
||||
let visible = (height / GRAPH_ROW_H).ceil() as usize + GRAPH_WINDOW_MARGIN * 2;
|
||||
let last = first.saturating_add(visible).min(rows.len());
|
||||
|
||||
// With the gutter gone the text takes the panel's own inset, so a
|
||||
// search result does not sit in a column of empty space.
|
||||
let indent = if filtering { CONTENT_INSET } else { gutter };
|
||||
let list = v_flex().children(
|
||||
rows.iter()
|
||||
let list = v_flex().pt(px(first as f32 * GRAPH_ROW_H)).children(
|
||||
rows[first..last]
|
||||
.iter()
|
||||
.map(|i| self.graph_row(repo, page, *i, indent, now, cx)),
|
||||
);
|
||||
let mut stack = div()
|
||||
@@ -736,6 +789,7 @@ impl Tty7App {
|
||||
.children(more.then(|| self.graph_load_more(gutter, cx)));
|
||||
|
||||
if !filtering {
|
||||
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
|
||||
let paint = GraphPaint {
|
||||
page: page.clone(),
|
||||
max_lanes: cap,
|
||||
@@ -744,7 +798,14 @@ impl Tty7App {
|
||||
// The hole in a hollow node has to be the exact fill behind it,
|
||||
// or the lane line running underneath shows through. The
|
||||
// section is flush on the panel, so that fill is the sidebar's.
|
||||
surface: gpui::rgb(cx.global::<crate::ui::presets::Surfaces>().sidebar.base).into(),
|
||||
surface: gpui::rgb(sf.base).into(),
|
||||
selected: self
|
||||
.scm
|
||||
.graph
|
||||
.selected
|
||||
.as_deref()
|
||||
.and_then(|oid| page.commits.iter().position(|c| c.oid == oid)),
|
||||
selected_surface: gpui::rgb(sf.selected).into(),
|
||||
more,
|
||||
};
|
||||
stack = stack.child(
|
||||
@@ -866,12 +927,15 @@ impl Tty7App {
|
||||
})
|
||||
.on_click(cx.listener({
|
||||
let repo = repo.clone();
|
||||
let oid = oid.clone();
|
||||
// The row already holds everything the detail view renders, so
|
||||
// it hands its own commit over and no `git show` is run.
|
||||
let seed = commit.clone();
|
||||
// it hands its own commit over and no `git show` is run. The
|
||||
// listener carries the page `Arc` and an index, not a clone of
|
||||
// the commit: with up to 5000 rows a frame, one deep `Commit`
|
||||
// clone per row (an 8KB body, refs) was most of the frame.
|
||||
let page = page.clone();
|
||||
move |this, _, _, cx| {
|
||||
this.graph_open_commit(repo.clone(), oid.clone(), Some(seed.clone()), cx)
|
||||
let seed = page.commits[i].clone();
|
||||
this.graph_open_commit(repo.clone(), seed.oid.clone(), Some(seed), cx)
|
||||
}
|
||||
}))
|
||||
.context_menu({
|
||||
|
||||
+31
-3
@@ -218,6 +218,19 @@ impl Tty7App {
|
||||
host: host.id(),
|
||||
root,
|
||||
});
|
||||
// An override whose host has left the registry is dropped, not worked
|
||||
// around: falling back to the *pane's* host while keeping the
|
||||
// override's root would probe the wrong machine for that path and
|
||||
// cache the answer under a mismatched key.
|
||||
if self
|
||||
.scm
|
||||
.repo_override
|
||||
.as_ref()
|
||||
.is_some_and(|k| crate::ui::host_registry::HostRegistry::get(cx, k.host).is_none())
|
||||
{
|
||||
self.scm.repo_override = None;
|
||||
self.scm.override_tab = None;
|
||||
}
|
||||
// An explicit pick from the switcher wins over the pane's own
|
||||
// repository, so everything below reads through `active_repo`.
|
||||
let repo = self
|
||||
@@ -869,7 +882,7 @@ impl Tty7App {
|
||||
// does.
|
||||
.text_color(if live { fg } else { muted })
|
||||
.disabled(!live)
|
||||
.when(!live, |b| b.tooltip(t(L10nKey::ScmNothingToCommit)))
|
||||
.when(!live, |b| b.tooltip(t(plan.reason)))
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.scm_commit(
|
||||
repo_for_button.clone(),
|
||||
@@ -1292,8 +1305,9 @@ impl Tty7App {
|
||||
let path = entry.path.as_str().to_string();
|
||||
let (name, dir) = split_display_path(&path);
|
||||
let (letter, deco) = row_status(entry, group);
|
||||
let selected = self.diff_overlay_focus(repo.host, &repo.root) == Some(path.as_str());
|
||||
let source = group_diff_source(group);
|
||||
let selected =
|
||||
self.diff_overlay_focus(repo.host, &repo.root, &source) == Some(path.as_str());
|
||||
let id = SharedString::from(format!("scm-row-{group:?}-{path}"));
|
||||
let actions = self.scm_row_actions(
|
||||
&id,
|
||||
@@ -1875,6 +1889,10 @@ pub(crate) fn operation_label(op: RepoOperation) -> L10nKey {
|
||||
pub(crate) struct CommitPlan {
|
||||
pub(crate) label: L10nKey,
|
||||
pub(crate) enabled: bool,
|
||||
/// Why the button is disabled, when it is. "Nothing to commit" and "write
|
||||
/// a message" call for opposite actions, and one tooltip for both sends
|
||||
/// the user staging files they already staged.
|
||||
pub(crate) reason: L10nKey,
|
||||
}
|
||||
|
||||
/// Decide both from the state of the index.
|
||||
@@ -1894,9 +1912,19 @@ pub(crate) fn commit_plan(status: &WorkingTreeStatus, amend: bool, message: &str
|
||||
L10nKey::ScmCommitAllButton
|
||||
};
|
||||
let has_message = !message.trim().is_empty();
|
||||
// Mid-merge, an empty message is still committable: `ops` sends
|
||||
// `--allow-empty-message` for exactly this, because refusing would strand
|
||||
// the merge behind a message the user deliberately cleared.
|
||||
let merging = matches!(status.operation, Some(RepoOperation::Merge));
|
||||
let something = staged || tracked_edits || amend;
|
||||
CommitPlan {
|
||||
label,
|
||||
enabled: (staged || tracked_edits || amend) && (has_message || amend),
|
||||
enabled: something && (has_message || amend || merging),
|
||||
reason: if something {
|
||||
L10nKey::ScmCommitNeedsMessage
|
||||
} else {
|
||||
L10nKey::ScmNothingToCommit
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
-6
@@ -62,19 +62,25 @@ const YEAR: i64 = DAY * 365;
|
||||
|
||||
/// `"2h"` / `"3d"` / `"5mo"` — a graph row has about 26px for this.
|
||||
///
|
||||
/// Through the i18n table like `home::relative_time`, in the compact spelling
|
||||
/// this column's width demands — "now" is still an English word, and the row,
|
||||
/// the tooltip, the detail byline and the overlay header all read it.
|
||||
///
|
||||
/// `now` is a parameter rather than a clock read so the whole thing stays a
|
||||
/// pure function, and so a test can sit exactly on a boundary.
|
||||
pub(crate) fn relative_time(now_unix: i64, then_unix: i64) -> String {
|
||||
use crate::ui::i18n::{L10nKey, t, t_fmt};
|
||||
// A commit stamped in the future (clock skew across machines is routine in
|
||||
// a shared repo) reads as "now" rather than as a negative age.
|
||||
let delta = (now_unix - then_unix).max(0);
|
||||
let unit = |key: L10nKey, n: i64| t_fmt(key, &[("n", &n.to_string())]);
|
||||
match delta {
|
||||
d if d < MINUTE => "now".to_string(),
|
||||
d if d < HOUR => format!("{}m", d / MINUTE),
|
||||
d if d < DAY => format!("{}h", d / HOUR),
|
||||
d if d < MONTH => format!("{}d", d / DAY),
|
||||
d if d < YEAR => format!("{}mo", d / MONTH),
|
||||
d => format!("{}y", d / YEAR),
|
||||
d if d < MINUTE => t(L10nKey::ScmTimeNow).to_string(),
|
||||
d if d < HOUR => unit(L10nKey::ScmTimeMinutes, d / MINUTE),
|
||||
d if d < DAY => unit(L10nKey::ScmTimeHours, d / HOUR),
|
||||
d if d < MONTH => unit(L10nKey::ScmTimeDays, d / DAY),
|
||||
d if d < YEAR => unit(L10nKey::ScmTimeMonths, d / MONTH),
|
||||
d => unit(L10nKey::ScmTimeYears, d / YEAR),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +161,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn relative_time_covers_every_bucket() {
|
||||
crate::ui::i18n::set_locale("en");
|
||||
let now = 1_800_000_000i64;
|
||||
let ago = |secs: i64| relative_time(now, now - secs);
|
||||
assert_eq!(ago(0), "now");
|
||||
@@ -174,7 +181,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn relative_time_clamps_commits_from_the_future() {
|
||||
crate::ui::i18n::set_locale("en");
|
||||
let now = 1_800_000_000i64;
|
||||
assert_eq!(relative_time(now, now + DAY), "now");
|
||||
}
|
||||
|
||||
/// The row, the tooltip and the overlay byline all read this — it goes
|
||||
/// through the i18n table like `home::relative_time`, in compact form.
|
||||
#[test]
|
||||
fn relative_time_speaks_the_ui_language() {
|
||||
crate::ui::i18n::set_locale("zh-CN");
|
||||
let now = 1_800_000_000i64;
|
||||
assert_eq!(relative_time(now, now), "刚刚");
|
||||
assert_eq!(relative_time(now, now - 2 * HOUR), "2时");
|
||||
// “3月”会被读成月份名,所以是“个月”。
|
||||
assert_eq!(relative_time(now, now - 3 * MONTH), "3个月");
|
||||
crate::ui::i18n::set_locale("en");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,30 @@ impl ScmPanelState {
|
||||
self.repo_override.as_ref().or(self.repo.as_ref())
|
||||
}
|
||||
|
||||
/// Drop everything keyed to a host that left the registry. Without this,
|
||||
/// `roots` and friends grow one entry per directory ever visited on a
|
||||
/// link that no longer exists. Drafts survive on purpose: a reconnect
|
||||
/// brings the same repositories back, and unsent messages with them.
|
||||
pub(crate) fn forget_host(&mut self, host: HostId) {
|
||||
self.roots.retain(|(h, _), _| *h != host);
|
||||
self.root_lookups.retain(|(h, _)| *h != host);
|
||||
self.probe_attempt.retain(|(h, _), _| *h != host);
|
||||
self.branches.retain(|k, _| k.host != host);
|
||||
self.branches_loading.retain(|k| k.host != host);
|
||||
if self.repo_override.as_ref().is_some_and(|k| k.host == host) {
|
||||
self.repo_override = None;
|
||||
self.override_tab = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// A probe just answered "no repository" for `root`: every directory that
|
||||
/// resolved to it has to re-ask. Left cached, the panel keeps mapping the
|
||||
/// cwd to a repository that is gone and draws its Loading state forever.
|
||||
pub(crate) fn forget_root(&mut self, host: HostId, root: &std::path::Path) {
|
||||
self.roots
|
||||
.retain(|(h, _), (_, held)| !(*h == host && held.as_deref() == Some(root)));
|
||||
}
|
||||
|
||||
pub(crate) fn draft(&self, repo: &RepoKey) -> &str {
|
||||
self.drafts.get(repo).map(String::as_str).unwrap_or("")
|
||||
}
|
||||
@@ -175,6 +199,11 @@ pub(crate) struct GraphState {
|
||||
/// own entity; without this the box would take text the list never sees.
|
||||
pub(crate) search: Option<Entity<InputState>>,
|
||||
pub(crate) search_sub: Option<gpui::Subscription>,
|
||||
/// Which commit indices the list shows, cached per (page identity, query).
|
||||
/// The filter case-folds every subject and author; re-running that over
|
||||
/// 5000 commits on every frame while the box is open is real work, and
|
||||
/// even the unfiltered identity list is 40KB of indices a frame.
|
||||
pub(crate) filter_cache: Option<(Option<String>, usize, Arc<Vec<usize>>)>,
|
||||
/// An open "name a branch at this commit" input, and the rev it starts
|
||||
/// from. The panel's own naming row cannot serve this: it always creates
|
||||
/// at HEAD, and the whole point here is the commit under the cursor.
|
||||
@@ -219,6 +248,11 @@ pub(crate) struct CommitDetailView {
|
||||
/// that is not in this repository.
|
||||
pub(crate) commit: Option<Arc<Commit>>,
|
||||
pub(crate) files: Option<Arc<Vec<CommitFile>>>,
|
||||
/// The file read came back with nothing — git errored, or the link
|
||||
/// dropped mid-read. Distinct from "still loading", and from an empty
|
||||
/// list: "0 files changed" for a commit whose files could not be read
|
||||
/// would be a confident lie.
|
||||
pub(crate) files_failed: bool,
|
||||
/// A long body starts folded — a merge from a bot can run to fifty lines,
|
||||
/// and the file list is what the reader came for.
|
||||
pub(crate) body_expanded: bool,
|
||||
@@ -238,6 +272,7 @@ impl CommitDetailView {
|
||||
loaded: false,
|
||||
commit: seed.map(Arc::new),
|
||||
files: None,
|
||||
files_failed: false,
|
||||
body_expanded: false,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user