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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ own right and leaves the group entry alone, exactly as the web UI does.
|---|---|
| `D` | from a pull request, open its diff |
| `^u` / `^d` | scroll the file's diff |
| `enter` | open the whole file |

The changed files are the list and the selected file's diff is the pane beside
it, as a unified diff: additions green, deletions red, three lines of context.
Expand All @@ -229,6 +230,13 @@ A binary file, or one over 256 KiB, says what it is instead of being rendered.
A renamed file reads as a new one: the change entry names only where the file
landed, so its previous content is at a path boardwalk cannot ask for.

`enter` opens the selected file whole — the change marked in the gutter
rather than colouring the code, so it reads as source. `d` takes the marks
away and leaves the file. Syntax highlighting comes from the file's name, and
a language boardwalk has no lexer for is shown plainly rather than guessed
at. Lines the pull request removed sit where they were, dimmed, and go away
with `d`.

Review comments sit under the line they were written against, marked resolved
or unresolved, and a file with discussion on it carries the count in the list
beside its name. A comment whose line the diff's three lines of context never
Expand All @@ -253,6 +261,14 @@ only source at all if your token cannot read your organisation's identities.
A tenant where that is locked down behaves exactly as boardwalk did before
any of this. See Setup.

### File

| key | |
|---|---|
| `j` / `k` | move the line cursor |
| `g` / `G` | top / bottom |
| `d` | show / hide the change marks |

### Session

| key | |
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.27.1

require (
github.com/JohannesKaufmann/html-to-markdown v1.6.0
github.com/alecthomas/chroma/v2 v2.20.0
github.com/aymanbagabas/go-udiff v0.4.1
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
Expand All @@ -15,7 +16,6 @@ require (

require (
github.com/PuerkitoBio/goquery v1.9.2 // indirect
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
Expand Down
149 changes: 149 additions & 0 deletions internal/ui/composefile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package ui

import (
"strings"

"github.com/aymanbagabas/go-udiff"
)

// fileLine is one row of the file view: a line of the file, or a line the
// pull request removed, shown where it used to be.
//
// Both numbers are kept because the two sides of a diff advance at different
// rates. New is what a review comment anchors to and what the cursor reports;
// Old is what a removed line is known by, since it no longer exists in the
// new file to have a number there. Whichever side a row does not belong to
// is zero, which no real line number ever is.
type fileLine struct {
Text string
New int
Old int
Kind udiff.OpKind
}

// composeFile lays the pull request's changes over the whole file.
//
// The diff pane shows hunks: the changed lines and three lines either side.
// This shows the file, with the changes marked in place — which is the point
// of the view, since the reason a change is wrong is usually in the part of
// the file the hunk does not reach.
//
// text is the new side of the file, or the old side for a file the pull
// request deleted, in which case it is empty and every row comes from the
// hunks. Removed lines are spliced in at the new-side position they were
// removed from, so a rewrite reads as a rewrite rather than as an unexplained
// pair of additions.
//
// It is a pure function over the text and the hunks, and deliberately knows
// nothing about rendering: a row numbered from the wrong side puts a "+"
// against code nobody touched, which looks entirely plausible on screen and
// can only be caught by asserting the numbers directly.
func composeFile(text string, hunks []*udiff.Hunk) []fileLine {
lines := splitLines(text)

// Both maps are keyed by new-side line number. inserted marks the lines
// the pull request added; removed holds what it took out, keyed by the
// line it sat in front of.
inserted := map[int]bool{}
removed := map[int][]fileLine{}
// oldOf carries the old-side number of the unchanged lines inside a
// hunk, where the diff states it outright. Outside the hunks it has to
// be derived — see shifts below.
oldOf := map[int]int{}
// shifts is how far the old side trails the new one after each hunk:
// old = new + delta. A hunk that adds two lines and removes one leaves
// every later line of the new file one number ahead of its old self, and
// a view that assumed the two stayed level would number most of the file
// wrongly the moment anything changed.
type shift struct{ afterNew, delta int }
var shifts []shift
delta := 0
// after the last line of the new file there is nothing to splice in
// front of, so trailing removals are collected separately.
var trailing []fileLine

for _, h := range hunks {
positions := diffLineNumbers(h)
lastNew := 0
for i, l := range h.Lines {
pos := positions[i]
if pos.right > lastNew {
lastNew = pos.right
}
switch l.Kind {
case udiff.Equal:
oldOf[pos.right] = pos.left
case udiff.Insert:
delta--
inserted[pos.right] = true
case udiff.Delete:
delta++
row := fileLine{Text: trimNewline(l.Content), Old: pos.left, Kind: udiff.Delete}
// A deletion's own right-hand position is zero — it is not in
// the new file — so it belongs in front of whichever new line
// follows it, which is the one the sides were level at.
switch next := nextRight(h, positions, i); {
case next == 0 || next > len(lines):
trailing = append(trailing, row)
default:
removed[next] = append(removed[next], row)
}
}
}
shifts = append(shifts, shift{afterNew: lastNew, delta: delta})
}

out := make([]fileLine, 0, len(lines)+len(trailing))
next, running := 0, 0
for i, text := range lines {
n := i + 1
out = append(out, removed[n]...)

// Every hunk the walk has now passed contributes its shift to the
// lines beyond it.
for next < len(shifts) && shifts[next].afterNew < n {
running = shifts[next].delta
next++
}

kind := udiff.Equal
if inserted[n] {
kind = udiff.Insert
}
row := fileLine{Text: text, New: n, Kind: kind}
if kind == udiff.Equal {
if old, ok := oldOf[n]; ok {
row.Old = old
} else {
row.Old = n + running
}
}
out = append(out, row)
}
return append(out, trailing...)
}

// nextRight is the new-side line number of the first row after i that has
// one, which is where a deletion at i belongs. Zero when the hunk ends
// without another.
func nextRight(h *udiff.Hunk, positions []diffLine, i int) int {
for j := i + 1; j < len(h.Lines); j++ {
if positions[j].right != 0 {
return positions[j].right
}
}
return 0
}

// splitLines breaks a file into its lines without inventing a last empty one
// for the trailing newline nearly every file ends with.
func splitLines(text string) []string {
if text == "" {
return nil
}
return strings.Split(strings.TrimSuffix(text, "\n"), "\n")
}

// trimNewline drops the newline a hunk line was split on. A line without one
// is the last line of a file that does not end with one.
func trimNewline(s string) string { return strings.TrimSuffix(s, "\n") }
190 changes: 190 additions & 0 deletions internal/ui/composefile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package ui

import (
"fmt"
"strings"
"testing"

"github.com/aymanbagabas/go-udiff"
)

// show renders a composed file the way an assertion reads best: one line per
// row, gutter and both numbers, so a wrong number is visible in the failure
// rather than having to be worked out from it.
func show(lines []fileLine) string {
var b strings.Builder
for _, l := range lines {
mark := " "
switch l.Kind {
case udiff.Insert:
mark = "+"
case udiff.Delete:
mark = "-"
}
old, new := " ", " "
if l.Old > 0 {
old = fmt.Sprintf("%2d", l.Old)
}
if l.New > 0 {
new = fmt.Sprintf("%2d", l.New)
}
fmt.Fprintf(&b, "%s %s %s %s\n", mark, old, new, l.Text)
}
return b.String()
}

// TestComposeFileWithNoHunks — a file the pull request lists as changed but
// whose text is identical at both commits, and the plain case underneath
// every other: every line is context, numbered from one.
func TestComposeFileWithNoHunks(t *testing.T) {
got := composeFile("alpha\nbeta\ngamma\n", nil)

want := ` 1 1 alpha
2 2 beta
3 3 gamma
`
if show(got) != want {
t.Errorf("composed:\n%s\nwant:\n%s", show(got), want)
}
}

// TestComposeFilePlacesInsertionsAndDeletions is the one that matters. The
// hunk covers new lines 2 to 4 and removes an old line 2 while adding two: the
// two sides advance at different rates, and a row numbered from the wrong one
// puts a + against code nobody touched.
func TestComposeFilePlacesInsertionsAndDeletions(t *testing.T) {
text := "func tail() {\n\tdefer close(ch)\n\tgo poll(ctx)\n}\n"
hunks := []*udiff.Hunk{hunk(1, 1,
line(udiff.Equal, "func tail() {\n"),
line(udiff.Delete, "\tgo poll()\n"),
line(udiff.Insert, "\tdefer close(ch)\n"),
line(udiff.Insert, "\tgo poll(ctx)\n"),
line(udiff.Equal, "}\n"),
)}

want := ` 1 1 func tail() {
- 2 go poll()
+ 2 defer close(ch)
+ 3 go poll(ctx)
3 4 }
`
if got := show(composeFile(text, hunks)); got != want {
t.Errorf("composed:\n%s\nwant:\n%s", got, want)
}
}

// TestComposeFileKeepsUntouchedLinesOutsideTheHunks — the whole point of a
// full-file view. The hunk covers three lines in the middle; everything
// before and after it is still the file and still numbered correctly.
func TestComposeFileKeepsUntouchedLinesOutsideTheHunks(t *testing.T) {
text := "one\ntwo\nCHANGED\nfour\nfive\n"
hunks := []*udiff.Hunk{hunk(3, 3,
line(udiff.Delete, "three\n"),
line(udiff.Insert, "CHANGED\n"),
)}

want := ` 1 1 one
2 2 two
- 3 three
+ 3 CHANGED
4 4 four
5 5 five
`
if got := show(composeFile(text, hunks)); got != want {
t.Errorf("composed:\n%s\nwant:\n%s", got, want)
}
}

// TestComposeFileWithTwoHunks — the numbering has to survive a gap. The
// second hunk's new-side start is what says where it lands, and drifting by
// the first hunk's net change is the classic way to get this wrong.
func TestComposeFileWithTwoHunks(t *testing.T) {
text := "A+\nb\nc\nd\ne\nF+\n"
hunks := []*udiff.Hunk{
hunk(1, 1, line(udiff.Insert, "A+\n")),
hunk(5, 6, line(udiff.Insert, "F+\n")),
}

want := `+ 1 A+
1 2 b
2 3 c
3 4 d
4 5 e
+ 6 F+
`
if got := show(composeFile(text, hunks)); got != want {
t.Errorf("composed:\n%s\nwant:\n%s", got, want)
}
}

// TestComposeFileWithADeletionAtTheEnd — a deletion after the last surviving
// line has no following line to splice in front of, and must not be dropped.
func TestComposeFileWithADeletionAtTheEnd(t *testing.T) {
text := "kept\n"
hunks := []*udiff.Hunk{hunk(1, 1,
line(udiff.Equal, "kept\n"),
line(udiff.Delete, "gone\n"),
)}

want := ` 1 1 kept
- 2 gone
`
if got := show(composeFile(text, hunks)); got != want {
t.Errorf("composed:\n%s\nwant:\n%s", got, want)
}
}

// TestComposeFileOfADeletedFile — no new side at all. Every line is a
// removal, numbered on the old side only.
func TestComposeFileOfADeletedFile(t *testing.T) {
hunks := []*udiff.Hunk{hunk(1, 1,
line(udiff.Delete, "one\n"),
line(udiff.Delete, "two\n"),
)}

want := `- 1 one
- 2 two
`
if got := show(composeFile("", hunks)); got != want {
t.Errorf("composed:\n%s\nwant:\n%s", got, want)
}
}

// TestComposeFileWithNoTrailingNewline — a last line without one must not
// become a phantom empty row at the bottom of every file.
func TestComposeFileWithNoTrailingNewline(t *testing.T) {
got := composeFile("alpha\nbeta", nil)
if len(got) != 2 {
t.Fatalf("composed %d lines, want 2:\n%s", len(got), show(got))
}
if got[1].Text != "beta" {
t.Errorf("last line = %q, want %q", got[1].Text, "beta")
}
}

func TestComposeFileOfAnEmptyFile(t *testing.T) {
if got := composeFile("", nil); len(got) != 0 {
t.Errorf("composed %d lines for an empty file, want none:\n%s", len(got), show(got))
}
}

// TestComposeFileNumbersEveryNewLine — the line cursor reports the number
// inline feedback anchors to, so every line of the new file must carry one.
func TestComposeFileNumbersEveryNewLine(t *testing.T) {
text := "a\nb\nc\nd\n"
hunks := []*udiff.Hunk{hunk(2, 2, line(udiff.Insert, "b\n"))}

n := 0
for _, l := range composeFile(text, hunks) {
if l.Kind == udiff.Delete {
continue
}
n++
if l.New != n {
t.Errorf("row %d carries new-side number %d, want %d", n, l.New, n)
}
}
if n != 4 {
t.Errorf("%d rows belong to the new file, want 4", n)
}
}
Loading
Loading