Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ for removals, magenta hunk headers. The diff is computed against the repository'
(`git diff <base>...<branch>`), 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
Expand Down
6 changes: 6 additions & 0 deletions docs/improvements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 18 additions & 2 deletions features_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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--
Expand All @@ -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)
}
}

Expand Down
9 changes: 6 additions & 3 deletions git.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 63 additions & 5 deletions main_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"context"
"fmt"
"os"
"os/exec"
Expand Down Expand Up @@ -2769,27 +2770,27 @@ 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)
}

fake := filepath.Join(bin, "delta")
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)
}

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)
}
}
Expand All @@ -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 {
Expand All @@ -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()
Expand Down
Loading
Loading