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
+65
View File
@@ -0,0 +1,65 @@
package detail
import (
"fmt"
"testing"
"submarine/internal/submodule"
)
func files(kind submodule.ChangeKind, n int) []submodule.FileChange {
var fs []submodule.FileChange
for i := range n {
fs = append(fs, submodule.FileChange{Path: fmt.Sprintf("%d-%d", kind, i), Kind: kind})
}
return fs
}
func TestPickPreview(t *testing.T) {
t.Run("fits", func(t *testing.T) {
all := append(files(submodule.ChangeModified, 2), files(submodule.ChangeUntracked, 1)...)
shown, hidden := pickPreview(all, 5)
if len(shown) != 3 || len(hidden) != 0 {
t.Errorf("shown %d, hidden %d; want 3, 0", len(shown), len(hidden))
}
})
t.Run("one over the budget shows all", func(t *testing.T) {
shown, hidden := pickPreview(files(submodule.ChangeModified, 6), 5)
if len(shown) != 6 || len(hidden) != 0 {
t.Errorf("shown %d, hidden %d; want 6, 0", len(shown), len(hidden))
}
})
t.Run("every kind keeps a line", func(t *testing.T) {
all := files(submodule.ChangeModified, 8)
all = append(all, files(submodule.ChangeRemoved, 1)...)
all = append(all, files(submodule.ChangeUntracked, 3)...)
shown, hidden := pickPreview(all, 4)
if len(shown) != 4 || len(hidden) != 8 {
t.Fatalf("shown %d, hidden %d; want 4, 8", len(shown), len(hidden))
}
want := []string{"1-0", "1-1", "3-0", "5-0"} // sort order is kept
for i, f := range shown {
if f.Path != want[i] {
t.Errorf("shown[%d] = %q, want %q (all: %+v)", i, f.Path, want[i], shown)
}
}
if n := (&submodule.Changes{}).Counts(hidden); n[submodule.ChangeModified] != 6 || n[submodule.ChangeUntracked] != 2 {
t.Errorf("hidden counts = %v", n)
}
})
}
func TestSplitPath(t *testing.T) {
for _, tt := range []struct{ in, name, dir string }{
{"README.md", "README.md", ""},
{"src/a/b.go", "b.go", "src/a/"},
{"tmp/", "tmp/", ""},
{"src/tmp/", "tmp/", "src/"},
} {
if name, dir := splitPath(tt.in); name != tt.name || dir != tt.dir {
t.Errorf("splitPath(%q) = %q, %q; want %q, %q", tt.in, name, dir, tt.name, tt.dir)
}
}
}