Archived
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:
co-authored by
Claude Opus 5.5
parent
64fe34b5b0
commit
19c6f17eb2
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -25,6 +25,42 @@ func IssueWord(k submodule.IssueKind) string {
|
||||
}
|
||||
}
|
||||
|
||||
// ChangeLetter is the one-letter label of a changed file.
|
||||
func ChangeLetter(k submodule.ChangeKind) string {
|
||||
switch k {
|
||||
case submodule.ChangeConflicted:
|
||||
return "!"
|
||||
case submodule.ChangeModified:
|
||||
return "M"
|
||||
case submodule.ChangeAdded:
|
||||
return "A"
|
||||
case submodule.ChangeRemoved:
|
||||
return "D"
|
||||
case submodule.ChangeRenamed:
|
||||
return "R"
|
||||
default:
|
||||
return "?"
|
||||
}
|
||||
}
|
||||
|
||||
// ChangeStyle returns base with the color of a change kind.
|
||||
func (t Theme) ChangeStyle(k submodule.ChangeKind, base lipgloss.Style) lipgloss.Style {
|
||||
switch k {
|
||||
case submodule.ChangeConflicted:
|
||||
return base.Foreground(t.Danger).Bold(true)
|
||||
case submodule.ChangeModified:
|
||||
return base.Foreground(t.Warn)
|
||||
case submodule.ChangeAdded:
|
||||
return base.Foreground(t.Added)
|
||||
case submodule.ChangeRemoved:
|
||||
return base.Foreground(t.Danger)
|
||||
case submodule.ChangeRenamed:
|
||||
return base.Foreground(t.Primary)
|
||||
default:
|
||||
return base.Foreground(t.Muted)
|
||||
}
|
||||
}
|
||||
|
||||
// LevelStyle returns base with the text color (and weight) for a level.
|
||||
func (t Theme) LevelStyle(l submodule.Level, base lipgloss.Style) lipgloss.Style {
|
||||
switch l {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
type Theme struct {
|
||||
Primary lipgloss.TerminalColor // accent: branch names, commit hashes
|
||||
Info lipgloss.TerminalColor
|
||||
Added lipgloss.TerminalColor // added lines and files
|
||||
Warn lipgloss.TerminalColor
|
||||
Danger lipgloss.TerminalColor
|
||||
Muted lipgloss.TerminalColor
|
||||
@@ -31,6 +32,7 @@ func Default() Theme {
|
||||
return Theme{
|
||||
Primary: lipgloss.Color("6"), // cyan
|
||||
Info: lipgloss.Color("4"), // blue
|
||||
Added: lipgloss.Color("2"), // green
|
||||
Warn: lipgloss.Color("3"), // yellow
|
||||
Danger: lipgloss.Color("1"), // red
|
||||
Muted: lipgloss.Color("8"), // bright black / grey
|
||||
@@ -49,17 +51,17 @@ func Fg(c lipgloss.TerminalColor) lipgloss.Style {
|
||||
|
||||
// Glyphs are the few symbols used next to the state words.
|
||||
type Glyphs struct {
|
||||
Staged, Ahead, Behind, Collapsed, Expanded, Problems string
|
||||
Staged, Ahead, Behind, Collapsed, Expanded, Problems, Minus, From, Rule string
|
||||
}
|
||||
|
||||
func Unicode() Glyphs {
|
||||
return Glyphs{
|
||||
Staged: "●", Ahead: "↑", Behind: "↓", Collapsed: "▸", Expanded: "▾", Problems: "·",
|
||||
Staged: "●", Ahead: "↑", Behind: "↓", Collapsed: "▸", Expanded: "▾", Problems: "·", Minus: "−", From: "←", Rule: "─",
|
||||
}
|
||||
}
|
||||
|
||||
func ASCII() Glyphs {
|
||||
return Glyphs{
|
||||
Staged: "*", Ahead: "^", Behind: "v", Collapsed: ">", Expanded: "v", Problems: ".",
|
||||
Staged: "*", Ahead: "^", Behind: "v", Collapsed: ">", Expanded: "v", Problems: ".", Minus: "-", From: "<-", Rule: "-",
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user