Preview uncommitted changes in the detail pane

For a dirty submodule, list the changed files so the user can remember
what they were doing there:

  Changes   5 files · 2M 1A 1D 1R · +37 −12
            ──────────────────────────────────
            M  render.go  src/render/  +20 −4  3d ago
            ?  scratch.txt                     5m ago

- Status.Changes: files changed against HEAD (staged and unstaged
  combined) with kind, line counts and modification time; sorted by kind,
  then by size; capped at 200 with totals over all files.
- Git provider: parse `status --porcelain=v2 -z` and one
  `diff HEAD --numstat -z -M`, only for dirty submodules.
- Detail pane: flat list with a colored status letter per file, like IDE
  source-control views; counts and age in compact columns; up to 10 files,
  every kind keeps a line, the rest is summarized as "n more".
- Fixture gains libs/work with every kind of change; mock data extended.
- Docs: F24, change preview in the UX guide, D17.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Simon Franken
2026-10-21 15:55:46 +02:00
co-authored by Claude Opus 5.5
parent 64fe34b5b0
commit 19c6f17eb2
17 changed files with 882 additions and 32 deletions
+103 -7
View File
@@ -56,23 +56,119 @@ func parseGitmodules(out string) []gitmodule {
return mods
}
// parseWorktree summarizes `git status --porcelain=v2` output. A conflict
// outranks modifications, which outrank untracked files.
func parseWorktree(out string) submodule.Worktree {
// entry is one changed path of `git status --porcelain=v2 -z`.
type entry struct {
path, orig string
kind submodule.ChangeKind
tracked bool // false for untracked files
submodule bool // a nested submodule pointer changed
}
// parseStatus parses `git status --porcelain=v2 -z`. Entries that don't
// differ from HEAD (e.g. added to the index, then deleted again) are
// dropped.
func parseStatus(out string) []entry {
var es []entry
recs := strings.Split(out, "\x00")
for i := 0; i < len(recs); i++ {
rec := recs[i]
if len(rec) < 3 {
continue
}
switch rec[0] {
case '?':
es = append(es, entry{path: rec[2:], kind: submodule.ChangeUntracked})
case 'u': // u XY sub m1 m2 m3 mW h1 h2 h3 path
if f := strings.SplitN(rec, " ", 11); len(f) == 11 {
es = append(es, entry{path: f[10], kind: submodule.ChangeConflicted, tracked: true})
}
case '1': // 1 XY sub mH mI mW hH hI path
if f := strings.SplitN(rec, " ", 9); len(f) == 9 {
if e, ok := trackedEntry(f[1], f[2], f[8], ""); ok {
es = append(es, e)
}
}
case '2': // 2 XY sub mH mI mW hH hI Xscore path NUL origPath
if f := strings.SplitN(rec, " ", 10); len(f) == 10 && i+1 < len(recs) {
i++
if e, ok := trackedEntry(f[1], f[2], f[9], recs[i]); ok {
es = append(es, e)
}
}
}
}
return es
}
func trackedEntry(xy, sub, path, orig string) (entry, bool) {
if len(xy) != 2 {
return entry{}, false
}
x, y := xy[0], xy[1]
e := entry{path: path, orig: orig, tracked: true, submodule: strings.HasPrefix(sub, "S")}
switch {
case x == 'R' || y == 'R':
e.kind = submodule.ChangeRenamed
case x == 'A' && y == 'D':
return entry{}, false
case x == 'A' || y == 'A' || x == 'C' || y == 'C':
e.kind = submodule.ChangeAdded
case x == 'D' || y == 'D':
e.kind = submodule.ChangeRemoved
default:
e.kind = submodule.ChangeModified
}
return e, true
}
// worktreeOf summarizes entries: a conflict outranks tracked changes, which
// outrank untracked files.
func worktreeOf(es []entry) submodule.Worktree {
w := submodule.WorktreeClean
for _, line := range strings.Split(out, "\n") {
for _, e := range es {
switch {
case strings.HasPrefix(line, "u "):
case e.kind == submodule.ChangeConflicted:
return submodule.WorktreeConflict
case strings.HasPrefix(line, "1 "), strings.HasPrefix(line, "2 "):
case e.tracked:
w = submodule.WorktreeModified
case strings.HasPrefix(line, "? ") && w == submodule.WorktreeClean:
case w == submodule.WorktreeClean:
w = submodule.WorktreeUntracked
}
}
return w
}
// numstat holds the line counts of one file.
type numstat struct {
added, removed int
binary bool
}
// parseNumstat parses `git diff --numstat -z -M`, keyed by the (new) path.
func parseNumstat(out string) map[string]numstat {
m := map[string]numstat{}
recs := strings.Split(out, "\x00")
for i := 0; i < len(recs); i++ {
f := strings.SplitN(recs[i], "\t", 3)
if len(f) != 3 {
continue
}
path := f[2]
if path == "" { // rename: the next two records are old and new path
if i+2 >= len(recs) {
break
}
path = recs[i+2]
i += 2
}
n := numstat{binary: f[0] == "-"}
n.added, _ = strconv.Atoi(f[0])
n.removed, _ = strconv.Atoi(f[1])
m[path] = n
}
return m
}
// parseLeftRight parses `git rev-list --left-right --count a...b`.
func parseLeftRight(out string) (left, right int, err error) {
f := strings.Fields(out)