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
+234
View File
@@ -0,0 +1,234 @@
package detail
import (
"fmt"
"path"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"submarine/internal/submodule"
"submarine/internal/tui/theme"
)
// previewBudget is the number of file lines shown in the change preview.
const previewBudget = 10
// pickPreview chooses which files of a sorted change list to show. Every
// kind that occurs gets at least one line; the remaining lines go to the
// files in sort order. Both results keep the sort order. A single hidden
// file would cost a "1 more" line, so it is shown instead.
func pickPreview(files []submodule.FileChange, budget int) (shown, hidden []submodule.FileChange) {
if len(files) <= budget+1 {
return files, nil
}
pick := make([]bool, len(files))
var seen [submodule.NumChangeKinds]bool
left := budget
for i, f := range files {
if !seen[f.Kind] && left > 0 {
seen[f.Kind], pick[i] = true, true
left--
}
}
for i := range files {
if left > 0 && !pick[i] {
pick[i] = true
left--
}
}
for i, f := range files {
if pick[i] {
shown = append(shown, f)
} else {
hidden = append(hidden, f)
}
}
return shown, hidden
}
// changeLines renders the change preview: a summary line and a flat list of
// files, a colored letter in front of each.
func (m Model) changeLines(c *submodule.Changes, label func(string) string) []string {
t := m.theme
muted, text := theme.Fg(t.Muted), theme.Fg(t.Text)
plural := "files"
if c.Total == 1 {
plural = "file"
}
// summary over all files: count, kinds, lines
parts := []string{text.Render(fmt.Sprintf("%d %s", c.Total, plural))}
if k := m.kindCounts(c.Counts(nil)); k != "" {
parts = append(parts, k)
}
sep := muted.Render(" · ")
if c.Added+c.Removed > 0 {
// line totals are the least important part: drop them if they don't fit
if withLines := append(parts, m.lineCounts(c.Added, c.Removed)); labelWidth+lipgloss.Width(strings.Join(withLines, sep)) <= m.width {
parts = withLines
}
}
out := []string{label("Changes") + strings.Join(parts, sep)}
shown, hidden := pickPreview(c.Files, previewBudget)
files := m.fileLines(shown)
// a rule under the summary, as wide as the summary or the file table
ruleW := lipgloss.Width(out[0]) - labelWidth
for _, l := range files {
ruleW = max(ruleW, lipgloss.Width(l)-labelWidth)
}
ruleW = min(ruleW, m.width-labelWidth)
out = append(out, label("")+muted.Render(strings.Repeat(m.theme.Glyphs.Rule, ruleW)))
out = append(out, files...)
if more := c.Total - len(shown); more > 0 {
line := label("") + muted.Render(fmt.Sprintf("… %d more", more))
if k := m.kindCounts(c.Counts(hidden)); k != "" {
line += muted.Render(" (") + k + muted.Render(")")
}
out = append(out, line)
}
return out
}
// kindCounts renders "2M 1A 1?", each in the color of its kind.
func (m Model) kindCounts(n [submodule.NumChangeKinds]int) string {
var parts []string
for k := submodule.ChangeKind(0); k < submodule.NumChangeKinds; k++ {
if n[k] > 0 {
parts = append(parts, m.theme.ChangeStyle(k, lipgloss.NewStyle()).Render(fmt.Sprintf("%d%s", n[k], theme.ChangeLetter(k))))
}
}
return strings.Join(parts, " ")
}
// lineCounts renders "+20 −4"; zero parts are omitted.
func (m Model) lineCounts(added, removed int) string {
t := m.theme
var parts []string
if added > 0 {
parts = append(parts, theme.Fg(t.Added).Render(fmt.Sprintf("+%d", added)))
}
if removed > 0 {
parts = append(parts, theme.Fg(t.Danger).Render(fmt.Sprintf("%s%d", t.Glyphs.Minus, removed)))
}
return strings.Join(parts, " ")
}
// maxPathWidth caps the path column, so the counts stay close to the names
// in wide panes; longer paths have their directory cut.
const maxPathWidth = 48
// minPathWidth is the path width below which the age column is dropped.
const minPathWidth = 16
// fileRow is one file of the preview, split into its columns.
type fileRow struct {
kind submodule.ChangeKind
name, prefix, dir string // prefix ("← ") is kept when dir is cut
counts string // rendered
age string // e.g. "19m ago"; empty for removed files
}
func (r fileRow) pathWidth() int {
w := lipgloss.Width(r.name)
if r.dir != "" {
w += 2 + lipgloss.Width(r.prefix) + lipgloss.Width(r.dir)
}
return w
}
// fileLines renders the files as a table in the value column:
//
// M name dir/ +20 −4 19m ago
//
// The path column is as wide as the widest path (up to maxPathWidth and the
// pane width); counts are right-aligned next to it, followed by the age. If
// space is short the age column goes first, then the directory is cut from
// the left, the name last.
func (m Model) fileLines(files []submodule.FileChange) []string {
t := m.theme
muted, text := theme.Fg(t.Muted), theme.Fg(t.Text)
rows := make([]fileRow, len(files))
pathW, countsW, ageW := 0, 0, 0
for i, f := range files {
r := fileRow{kind: f.Kind}
r.name, r.dir = splitPath(f.Path)
if f.OrigPath != "" {
r.prefix, r.dir = t.Glyphs.From+" ", f.OrigPath
}
switch {
case f.Submodule:
r.counts = muted.Render("submodule")
case f.Binary:
r.counts = muted.Render("bin")
default:
r.counts = m.lineCounts(f.Added, f.Removed)
}
if !f.Modified.IsZero() {
r.age = theme.Ago(f.Modified)
}
rows[i] = r
pathW = max(pathW, r.pathWidth())
countsW = max(countsW, lipgloss.Width(r.counts))
ageW = max(ageW, lipgloss.Width(r.age))
}
avail := m.width - labelWidth - 3 // letter and its gap
if countsW > 0 {
avail -= countsW + 2
}
if ageW > 0 && avail-ageW-2 < min(pathW, minPathWidth) {
ageW = 0 // too narrow: drop the age column
}
if ageW > 0 {
avail -= ageW + 2
}
pathW = max(min(pathW, maxPathWidth, avail), 1)
out := make([]string, len(rows))
for i, r := range rows {
name := ansi.Truncate(r.name, pathW, "…")
dir := ""
if room := pathW - lipgloss.Width(name) - 2 - lipgloss.Width(r.prefix); room >= 2 && r.dir != "" {
dir = r.dir
if w := lipgloss.Width(dir); w > room {
dir = ansi.TruncateLeft(dir, w-(room-1), "…")
}
dir = r.prefix + dir
}
path := text.Bold(true).Render(name)
if dir != "" {
path += " " + muted.Render(dir)
}
line := strings.Repeat(" ", labelWidth) +
t.ChangeStyle(r.kind, lipgloss.NewStyle()).Render(theme.ChangeLetter(r.kind)) + " " + path
line += strings.Repeat(" ", pathW-lipgloss.Width(path))
if countsW > 0 {
line += strings.Repeat(" ", 2+countsW-lipgloss.Width(r.counts)) + r.counts
}
if ageW > 0 && r.age != "" {
line += " " + muted.Render(r.age)
}
out[i] = strings.TrimRight(line, " ")
}
return out
}
// splitPath returns the base name and the directory with a trailing slash.
// A path ending in a slash (an untracked directory) keeps it on the name.
func splitPath(p string) (name, dir string) {
suffix := ""
if strings.HasSuffix(p, "/") {
p, suffix = strings.TrimSuffix(p, "/"), "/"
}
name = path.Base(p) + suffix
if d := path.Dir(p); d != "." {
dir = d + "/"
}
return name, dir
}
+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)
}
}
}
+4
View File
@@ -82,6 +82,10 @@ func (m Model) lines(s *submodule.Submodule) []string {
if st.Checkout == submodule.CheckoutDetached {
out = append(out, label("")+muted.Render("detached HEAD on pin"))
}
if st.Changes != nil {
out = append(out, "")
out = append(out, m.changeLines(st.Changes, label)...)
}
out = append(out, "")
// ref