diff --git a/README.md b/README.md index b5312e2..c9591f2 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,11 @@ for removals, magenta hunk headers. The diff is computed against the repository' (`git diff ...`), so it shows only the changes introduced on that branch since it diverged. The view is scrollable for large diffs; the header shows which base it was compared to. +A large diff can take a few seconds to lay out through delta (about 2s for 120,000 lines). While +it loads, the view shows a spinner, the step, the line count and the time so far, for example +`⠹ delta is styling 121,800 lines… 1.4s`. The keys keep working: `q` goes back at once and stops +delta, so a diff you no longer want does not keep a CPU core busy. + ## Resolving the default branch The default branch is used as the diff base, as the merge target for the `✓` indicator, and to diff --git a/docs/improvements.md b/docs/improvements.md index da7d4e8..b11207f 100644 --- a/docs/improvements.md +++ b/docs/improvements.md @@ -248,6 +248,12 @@ It left two findings on purpose: dropping the test-only `riskCommitCount`/`loadD (churn in a dozen tests for no behavior change), and keying the merge sets by full ref (it only matters for a local branch literally named like a remote one, e.g. `origin/x`). +**`v` progress and delta cancel (2026-10-11).** A 121,800-line diff spent 0.05s in `git diff` and +1.9s in delta behind a still "loading diff…" line, which read as a frozen screen. The read and the +layout are now two cmds (`diffReadMsg`, then `styleCmd`), so the loading line turns a spinner and +gives the step, the line count and the time. Leaving the diff, or starting another layout, kills +the running delta through `diffCancel`; before, its answer was dropped but it ran to the end. + **12. `main.go` split** into `git.go`, `model.go`, `view.go`, `cli.go`, `settings.go`, `proc_*.go` by an AST script — no hand edits in the move. **13.** `BINDIR` was already overridable; the Makefile now rebuilds on any `*.go` change. diff --git a/features_test.go b/features_test.go index 65b8772..8818a99 100644 --- a/features_test.go +++ b/features_test.go @@ -356,6 +356,22 @@ func TestDiffLoadsInBackground(t *testing.T) { if o := stripANSI(m.diffView()); !strings.Contains(o, "loading diff") { t.Fatalf("the view must say it is loading:\n%s", o) } + // The batch is the read and the spinner tick, in that order; the tick is + // not run, as waiting on its timer only slows the test. + read := cmd().(tea.BatchMsg)[0]().(diffReadMsg) + stale := read + stale.seq-- + if nm, c := m.Update(stale); c != nil || nm.(model).diffRaw != "" { + t.Fatal("a stale read must be dropped, and must not start a layout") + } + nm, cmd = m.Update(read) + m = nm.(model) + if cmd == nil || m.diffBase != "origin/main" || !m.diffLoading { + t.Fatalf("the read must land and start the layout: base %q", m.diffBase) + } + if o := stripANSI(m.diffView()); !strings.Contains(o, "styling 8 lines…") || strings.Contains(o, "delta is") { + t.Fatalf("the view must give the size, and not name a delta that is not there:\n%s", o) + } msg := cmd().(diffLoadedMsg) old := msg old.seq-- @@ -366,8 +382,8 @@ func TestDiffLoadsInBackground(t *testing.T) { } nm, _ = m.Update(msg) m = nm.(model) - if m.diffLoading || !strings.Contains(strings.Join(m.diffLines, "\n"), "+b") || m.diffBase != "origin/main" { - t.Fatalf("diff not applied: base %q lines %q", m.diffBase, m.diffLines) + if m.diffLoading || !strings.Contains(strings.Join(m.diffLines, "\n"), "+b") { + t.Fatalf("diff not applied: lines %q", m.diffLines) } } diff --git a/git.go b/git.go index 953b56a..6750184 100644 --- a/git.go +++ b/git.go @@ -835,17 +835,20 @@ func loadDiffRef(ref, name string) (diff, base string, err error) { // its output is used as-is, so side-by-side, line numbers and theme all come // from the user's own delta config; the width is passed so delta lays the // columns out for this terminal. Without delta (or if it fails) the raw lines -// are returned for colorizeDiffLine. -func styleDiff(diff string, width int) (lines []string, styled bool) { +// are returned for colorizeDiffLine. Cancelling ctx kills delta. +func styleDiff(ctx context.Context, diff string, width int) (lines []string, styled bool) { if strings.TrimSpace(diff) == "" { return nil, false } if path, err := exec.LookPath("delta"); err == nil { - cmd := exec.Command(path, "--paging=never", "--width="+strconv.Itoa(width)) + cmd := exec.CommandContext(ctx, path, "--paging=never", "--width="+strconv.Itoa(width)) cmd.Stdin = strings.NewReader(diff) if out, err := cmd.Output(); err == nil { return strings.Split(strings.TrimRight(string(out), "\n"), "\n"), true } + if ctx.Err() != nil { + return nil, false // nobody wants the answer; skip the fallback's work too + } } // The file contents are someone else's text; clean each line like a subject. // Tabs are widened first, or cleanText would flatten indentation to one space. diff --git a/main_test.go b/main_test.go index a7e5496..4c0204d 100644 --- a/main_test.go +++ b/main_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "os" "os/exec" @@ -2769,11 +2770,11 @@ func TestStyleDiffUsesDeltaWhenPresent(t *testing.T) { bin := t.TempDir() t.Setenv("PATH", bin) - lines, styled := styleDiff(diff, 80) + lines, styled := styleDiff(context.Background(), diff, 80) if styled || len(lines) != 6 { t.Fatalf("without delta the raw lines must come back: styled=%v lines=%q", styled, lines) } - if lines, styled := styleDiff(" \n", 80); styled || lines != nil { + if lines, styled := styleDiff(context.Background(), " \n", 80); styled || lines != nil { t.Fatalf("an empty diff has no lines: styled=%v lines=%q", styled, lines) } @@ -2781,7 +2782,7 @@ func TestStyleDiffUsesDeltaWhenPresent(t *testing.T) { if err := os.WriteFile(fake, []byte("#!/bin/sh\necho \"delta $*\"\n/bin/cat >/dev/null\n"), 0o755); err != nil { t.Fatal(err) } - lines, styled = styleDiff(diff, 120) + lines, styled = styleDiff(context.Background(), diff, 120) if !styled || len(lines) != 1 || lines[0] != "delta --paging=never --width=120" { t.Fatalf("delta must run with the terminal width and its output shown as-is: styled=%v lines=%q", styled, lines) } @@ -2789,7 +2790,7 @@ func TestStyleDiffUsesDeltaWhenPresent(t *testing.T) { if err := os.WriteFile(fake, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { t.Fatal(err) } - if lines, styled := styleDiff(diff, 80); styled || len(lines) != 6 { + if lines, styled := styleDiff(context.Background(), diff, 80); styled || len(lines) != 6 { t.Fatalf("a failing delta must fall back to the raw lines: styled=%v lines=%q", styled, lines) } } @@ -2803,7 +2804,7 @@ func TestDiffRerendersOnResize(t *testing.T) { t.Fatal(err) } m := model{width: 80, height: 24, state: stateDiff, diffRaw: "-a\n+b\n"} - m.diffLines, m.diffStyled = styleDiff(m.diffRaw, m.width) + m.diffLines, m.diffStyled = styleDiff(context.Background(), m.diffRaw, m.width) nm, cmd := m.Update(tea.WindowSizeMsg{Width: 140, Height: 40}) m = nm.(model) if cmd == nil { @@ -2828,6 +2829,63 @@ func TestDiffRerendersOnResize(t *testing.T) { } } +// Leaving the diff kills a delta still at work: its answer would be dropped, +// and on a big diff it would hold a CPU core for seconds. +func TestLeavingDiffStopsDelta(t *testing.T) { + bin := t.TempDir() + t.Setenv("PATH", bin) + // exec, so the kill reaches the process holding delta's output pipe. + if err := os.WriteFile(filepath.Join(bin, "delta"), []byte("#!/bin/sh\nexec /bin/sleep 30\n"), 0o755); err != nil { + t.Fatal(err) + } + m := model{width: 80, height: 24, state: stateDiff, diffRaw: "-a\n+b\n", diffLoading: true} + cmd := m.styleCmd() + done := make(chan tea.Msg, 1) + go func() { done <- cmd() }() + nm, _ := m.Update(key("q")) + if nm.(model).state != stateList { + t.Fatal("q must leave the diff") + } + select { + case msg := <-done: + if lm := msg.(diffLoadedMsg); lm.lines != nil { + t.Fatalf("a stopped run must skip the raw-line fallback, got %q", lm.lines) + } + case <-time.After(10 * time.Second): + t.Fatal("delta still runs after the user left the diff") + } +} + +// The loading line turns while the diff loads, and only one tick chain runs: +// a tick for an older request, or after the load lands, does not re-arm. +func TestDiffSpinnerTicksWhileLoading(t *testing.T) { + m := model{width: 80, height: 24, state: stateDiff, diffSeq: 2, diffLoading: true, + diffNote: "delta is styling 121,800 lines…", diffStart: time.Now().Add(-1500 * time.Millisecond)} + nm, cmd := m.Update(diffTickMsg{seq: 2}) + m = nm.(model) + if m.spinnerFrame != 1 || cmd == nil { + t.Fatalf("the tick must advance and re-arm: frame %d", m.spinnerFrame) + } + if o := stripANSI(m.diffView()); !strings.Contains(o, spinnerFrames[1]+" delta is styling 121,800 lines… 1.5s") { + t.Fatalf("the loading line must show the spinner, the step, and the time:\n%s", o) + } + if _, cmd := m.Update(diffTickMsg{seq: 1}); cmd != nil { + t.Fatal("a tick for an older request must not re-arm") + } + m.diffLoading = false + if _, cmd := m.Update(diffTickMsg{seq: 2}); cmd != nil { + t.Fatal("the tick must not re-arm once the diff has landed") + } +} + +func TestGroupDigits(t *testing.T) { + for n, want := range map[int]string{0: "0", 999: "999", 1000: "1,000", 121800: "121,800", 1234567: "1,234,567"} { + if got := groupDigits(n); got != want { + t.Errorf("groupDigits(%d) = %q, want %q", n, got, want) + } + } +} + // result returns m's deletion result for the named branch, failing if none ran. func result(t *testing.T, m model, name string) deleteResult { t.Helper() diff --git a/model.go b/model.go index 4f07324..fd25bc4 100644 --- a/model.go +++ b/model.go @@ -1,7 +1,9 @@ package main import ( + "context" "fmt" + "os/exec" "path" "slices" "sort" @@ -124,16 +126,19 @@ type model struct { // confirmAfterRisk). The confirm screen opens when the counts land. measuring bool - spinnerFrame int // animation frame for the deleting spinner (deletion counts derive from results) + spinnerFrame int // animation frame for the deleting and diff-loading spinners (deletion counts derive from results) - diffBranch string // branch whose diff is shown in stateDiff - diffBase string // base ref the diff was computed against - diffRaw string // unified diff as git produced it; delta is re-run from it on resize - diffLines []string // display lines of the diff being viewed - diffStyled bool // lines came from delta and carry their own colors - diffTop int // scroll offset within diffLines - diffLoading bool // the diff (or its re-layout) is being made in the background - diffSeq int // bumped per diff request, so a slow answer for an old one is dropped + diffBranch string // branch whose diff is shown in stateDiff + diffBase string // base ref the diff was computed against + diffRaw string // unified diff as git produced it; delta is re-run from it on resize + diffLines []string // display lines of the diff being viewed + diffStyled bool // lines came from delta and carry their own colors + diffTop int // scroll offset within diffLines + diffLoading bool // the diff (or its re-layout) is being made in the background + diffSeq int // bumped per diff request, so a slow answer for an old one is dropped + diffNote string // what the loading line says the load is doing + diffStart time.Time // when v was pressed; the loading line shows the time since + diffCancel context.CancelFunc // stops the running layout (see stopStyle) bodyTop int // scroll offset within the confirm/force/result body (see page) @@ -228,39 +233,67 @@ type riskMsg struct { counts []riskCount } -// diffLoadedMsg carries a diff made in the background; seq ties it to one request. +// diffReadMsg carries the diff git produced; seq ties it to one request. The +// read and the layout are two steps so the loading line can give the size +// while delta works on it. +type diffReadMsg struct { + seq int + base string + raw string + delta bool // delta is on the PATH, so the layout step is the slow one + err error +} + +// diffCmd reads b's diff off the update loop. +func diffCmd(seq int, b branch) tea.Cmd { + return func() tea.Msg { + msg := diffReadMsg{seq: seq} + msg.raw, msg.base, msg.err = loadDiffRef(b.ref(), b.name) + _, err := exec.LookPath("delta") + msg.delta = err == nil + return msg + } +} + +// diffLoadedMsg carries a diff laid out in the background; seq ties it to one request. type diffLoadedMsg struct { seq int - base string // "" on a re-layout: the base is unchanged - raw string lines []string styled bool - err error } -// diffCmd reads b's diff and lays it out, off the update loop: a big diff -// through delta takes long enough to freeze the screen. -func diffCmd(seq int, b branch, width int) tea.Cmd { +// styleCmd lays m.diffRaw out off the update loop: a big diff through delta +// takes seconds. An earlier run still at work is stopped first. +func (m *model) styleCmd() tea.Cmd { + m.stopStyle() + ctx, cancel := context.WithCancel(context.Background()) + m.diffCancel = cancel + seq, raw, width := m.diffSeq, m.diffRaw, m.width return func() tea.Msg { msg := diffLoadedMsg{seq: seq} - msg.raw, msg.base, msg.err = loadDiffRef(b.ref(), b.name) - if msg.err == nil { - msg.lines, msg.styled = styleDiff(msg.raw, width) - } + msg.lines, msg.styled = styleDiff(ctx, raw, width) return msg } } +// stopStyle kills a running delta. Its answer would be dropped anyway, and on +// a big diff it holds a CPU core for seconds after the user has left. +func (m *model) stopStyle() { + if m.diffCancel != nil { + m.diffCancel() + m.diffCancel = nil + } +} + // restyleTickMsg fires a moment after a resize (see the WindowSizeMsg case). type restyleTickMsg struct{ seq int } -// restyleCmd re-runs delta on a diff already read, for a new terminal width. -func restyleCmd(seq int, raw string, width int) tea.Cmd { - return func() tea.Msg { - msg := diffLoadedMsg{seq: seq, raw: raw} - msg.lines, msg.styled = styleDiff(raw, width) - return msg - } +// diffTickMsg advances the loading-line spinner. seq ties it to one request, +// so a quick v, q, v cannot leave two tick chains running. +type diffTickMsg struct{ seq int } + +func diffTickCmd(seq int) tea.Cmd { + return tea.Tick(spinnerInterval, func(time.Time) tea.Msg { return diffTickMsg{seq} }) } // branchDeletedMsg reports the outcome of one branch's async deletion; idx is @@ -273,9 +306,12 @@ type branchDeletedMsg struct { // spinnerTickMsg advances the deleting-view spinner animation. type spinnerTickMsg struct{} -// spinnerFrames are the braille frames cycled while deletions are in flight. +// spinnerFrames are the braille frames cycled while deletions are in flight +// or a diff loads, one per spinnerInterval. var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} +const spinnerInterval = 120 * time.Millisecond + // deleteBranchCmd wraps the deleteBranch worker as a tea.Cmd so deletions run off // the update loop. It captures only a branch value (never the model), so each runs // independently and concurrently under tea.Batch. @@ -287,7 +323,7 @@ func deleteBranchCmd(idx int, b branch, force, wantRemote bool) tea.Cmd { // spinnerTickCmd schedules the next spinner frame. func spinnerTickCmd() tea.Cmd { - return tea.Tick(120*time.Millisecond, func(time.Time) tea.Msg { return spinnerTickMsg{} }) + return tea.Tick(spinnerInterval, func(time.Time) tea.Msg { return spinnerTickMsg{} }) } // loadFirst runs the startup reads. The repo check runs beside them rather than @@ -500,7 +536,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil // a later resize took over, or the user left } m.diffLoading = true - return m, restyleCmd(m.diffSeq, m.diffRaw, m.width) + return m, m.styleCmd() case mergeInfoMsg: if msg.gen == m.loadGen { m.applyMergeInfo(msg.info) @@ -509,23 +545,41 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case riskMsg: return m.landRisk(msg) - case diffLoadedMsg: + case diffReadMsg: if msg.seq != m.diffSeq || m.state != stateDiff { return m, nil // the user asked for another diff, or left } - m.diffLoading = false if msg.err != nil { + m.diffLoading = false m.err = msg.err.Error() m.state = stateList return m, nil } - if msg.base != "" { - m.diffBase = msg.base + m.diffBase, m.diffRaw = msg.base, msg.raw + if strings.TrimSpace(msg.raw) == "" { + m.diffLoading = false + return m, nil + } + m.diffNote = "styling " + groupDigits(strings.Count(msg.raw, "\n")) + " lines…" + if msg.delta { + m.diffNote = "delta is " + m.diffNote } - m.diffRaw = msg.raw + return m, m.styleCmd() + case diffLoadedMsg: + if msg.seq != m.diffSeq || m.state != stateDiff { + return m, nil // the user asked for another diff, or left + } + m.diffLoading = false + m.stopStyle() // the run is over; this only frees its context m.diffLines, m.diffStyled = msg.lines, msg.styled m.clampDiff() return m, nil + case diffTickMsg: + if msg.seq == m.diffSeq && m.state == stateDiff && m.diffLoading { + m.spinnerFrame++ + return m, diffTickCmd(msg.seq) + } + return m, nil case undoDoneMsg: return m, m.applyUndo(msg) case repoLoadedMsg: @@ -804,8 +858,9 @@ func (m model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.diffBranch, m.diffBase = b.name, "" m.diffRaw, m.diffLines, m.diffStyled = "", nil, false m.diffTop, m.diffLoading = 0, true + m.diffNote, m.diffStart = "loading diff…", time.Now() m.state = stateDiff - return m, diffCmd(m.diffSeq, *b, m.width) + return m, tea.Batch(diffCmd(m.diffSeq, *b), diffTickCmd(m.diffSeq)) } case "c": if b := m.cur(); b != nil && !b.isCurrent && !m.switching && !m.fetching { @@ -948,6 +1003,7 @@ func (m *model) clampDiff() { func (m model) updateDiff(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "q", "esc", "v": + m.stopStyle() m.state = stateList case "ctrl+c": return m, tea.Quit diff --git a/view.go b/view.go index 032bb6c..96d1f5e 100644 --- a/view.go +++ b/view.go @@ -4,6 +4,7 @@ import ( "fmt" "strconv" "strings" + "time" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" @@ -180,7 +181,8 @@ func (m model) diffView() string { if len(m.diffLines) == 0 { msg := "no changes — branch matches " + base if m.diffLoading { - msg = "loading diff…" + spin := spinnerFrames[m.spinnerFrame%len(spinnerFrames)] + msg = fmt.Sprintf("%s %s %.1fs", spin, m.diffNote, time.Since(m.diffStart).Seconds()) } b.WriteString(dimStyle.Render(msg)) b.WriteString("\n\n") @@ -814,3 +816,12 @@ func (m model) resultParts() (header, body, footer []string) { } func (m model) resultView() string { return m.page(m.resultParts()) } + +// groupDigits writes n with commas between groups of three digits (121,800). +func groupDigits(n int) string { + s := strconv.Itoa(n) + for i := len(s) - 3; i > 0; i -= 3 { + s = s[:i] + "," + s[i:] + } + return s +}