detached 734bf87

Wrap long edit diff lines

commit
734bf87c9c178aba726e72a7c4597445d46986c3
author
patriceckhart · · 2026-07-23 12:23 UTC
parent
09b92ac
signature
unsigned
2 files · +146 -54
M packages/tui/view.go +77 -54
@@ -1549,15 +1549,15 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s
15491549 if inDiff && len(l) > 0 {
15501550 switch l[0] {
15511551 case '+':
1552- out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath))
1552+ out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath)...)
15531553 newLine++
15541554 continue
15551555 case '-':
1556- out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath))
1556+ out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath)...)
15571557 oldLine++
15581558 continue
15591559 case ' ':
1560- out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath))
1560+ out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath)...)
15611561 oldLine++
15621562 newLine++
15631563 continue
@@ -1613,34 +1613,17 @@ func parseHunkHeader(l string) (oldStart, newStart int, ok bool) {
16131613 return o, n, true
16141614 }
16151615
1616-// renderDiffRow renders one unified-diff line with a read-style gutter
1617-// (6-cell right-aligned line number, muted) followed by the +/-/space
1618-// marker and the code. Code is syntax-highlighted if sourcePath hints
1619-// at a known language; falls back to the plain diff color otherwise.
1620-func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byte, sourcePath string) string {
1616+// renderDiffRow renders one unified-diff source line as one or more visual
1617+// rows. The first row carries the read-style line-number gutter; continuation
1618+// rows use a blank gutter so wrapped content cannot be mistaken for another
1619+// source line. Code is syntax-highlighted if sourcePath hints at a known
1620+// language and otherwise uses the diff color.
1621+func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byte, sourcePath string) []string {
16211622 if len(line) == 0 {
1622- return ""
1623+ return []string{""}
16231624 }
16241625 code := expandTabs(line[1:]) // strip the leading marker; expand tabs
16251626
1626- // Syntax-highlight the code half when we know the language. Use
1627- // the same HighlightCode pipeline as renderNumberedFile so the
1628- // palette matches.
1629- lang := LanguageFromPath(sourcePath)
1630- var codeRendered string
1631- if lang != "" {
1632- if h := v.Theme.HighlightCode(code, lang); len(h) == 1 {
1633- codeRendered = h[0]
1634- }
1635- }
1636- if codeRendered == "" {
1637- if mark == ' ' {
1638- codeRendered = v.Theme.FG256(v.Theme.Muted, code)
1639- } else {
1640- codeRendered = v.Theme.FG256(color, code)
1641- }
1642- }
1643-
16441627 // Gutter shape: sign + number share a color so they read as one
16451628 // visual token ("+123") instead of a neutral line number next to
16461629 // a stray marker. Unchanged context lines get a muted gutter and
@@ -1655,39 +1638,79 @@ func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byt
16551638 default:
16561639 gutterText = fmt.Sprintf(" %3d ", lineNo)
16571640 }
1658- var gutter string
1641+ gutterColor := color
16591642 if mark == ' ' {
1660- gutter = v.Theme.FG256(v.Theme.Muted, gutterText)
1661- } else {
1662- gutter = v.Theme.FG256(color, gutterText)
1643+ gutterColor = v.Theme.Muted
16631644 }
1664- row := " " + gutter + codeRendered
16651645
1666- // Cheap width clamp: truncate visible text if the raw code is too
1667- // long. We work on the pre-ANSI code string because measuring ansi
1668- // output is unreliable.
1669- maxCode := width - 4 /* indent */ - 7 /* gutter (sign+5 digits+tab) */
1670- if maxCode > 0 && len(code) > maxCode {
1671- trunc := strings.Repeat(".", maxCode)
1672- if maxCode > 3 {
1673- trunc = code[:maxCode-3] + "..."
1646+ maxCode := width - visibleWidth(" "+gutterText)
1647+ if maxCode < 1 {
1648+ maxCode = 1
1649+ }
1650+ chunks := wrapCodeLine(code, maxCode)
1651+ lang := LanguageFromPath(sourcePath)
1652+ out := make([]string, 0, len(chunks))
1653+ for i, chunk := range chunks {
1654+ rowGutter := gutterText
1655+ if i > 0 {
1656+ rowGutter = strings.Repeat(" ", visibleWidth(gutterText))
16741657 }
1658+ codeRendered := ""
16751659 if lang != "" {
1676- if h := v.Theme.HighlightCode(trunc, lang); len(h) == 1 {
1660+ if h := v.Theme.HighlightCode(chunk, lang); len(h) == 1 {
16771661 codeRendered = h[0]
1678- } else if mark == ' ' {
1679- codeRendered = v.Theme.FG256(v.Theme.Muted, trunc)
1680- } else {
1681- codeRendered = v.Theme.FG256(color, trunc)
16821662 }
1683- } else if mark == ' ' {
1684- codeRendered = v.Theme.FG256(v.Theme.Muted, trunc)
1685- } else {
1686- codeRendered = v.Theme.FG256(color, trunc)
16871663 }
1688- row = " " + gutter + codeRendered
1664+ if codeRendered == "" {
1665+ codeColor := color
1666+ if mark == ' ' {
1667+ codeColor = v.Theme.Muted
1668+ }
1669+ codeRendered = v.Theme.FG256(codeColor, chunk)
1670+ }
1671+ out = append(out, " "+v.Theme.FG256(gutterColor, rowGutter)+codeRendered)
1672+ }
1673+ return out
1674+}
1675+
1676+// wrapCodeLine splits code to a terminal cell width without dropping any
1677+// characters. It prefers breaking after whitespace, then falls back to a hard
1678+// rune boundary for long tokens. Keeping the whitespace on the preceding row
1679+// means joining the returned chunks reconstructs the expanded source exactly.
1680+func wrapCodeLine(line string, width int) []string {
1681+ if width < 1 || visibleWidth(line) <= width {
1682+ return []string{line}
1683+ }
1684+ runes := []rune(line)
1685+ var out []string
1686+ for start := 0; start < len(runes); {
1687+ cells := 0
1688+ end := start
1689+ lastBreak := -1
1690+ for end < len(runes) {
1691+ runeWidth := runewidth.RuneWidth(runes[end])
1692+ if cells+runeWidth > width && end > start {
1693+ break
1694+ }
1695+ cells += runeWidth
1696+ end++
1697+ if runes[end-1] == ' ' || runes[end-1] == '\t' {
1698+ lastBreak = end
1699+ }
1700+ if cells >= width {
1701+ break
1702+ }
1703+ }
1704+ if end == start { // A rune wider than the requested width.
1705+ end++
1706+ }
1707+ if end < len(runes) && lastBreak > start {
1708+ end = lastBreak
1709+ }
1710+ out = append(out, string(runes[start:end]))
1711+ start = end
16891712 }
1690- return row
1713+ return out
16911714 }
16921715
16931716 // renderImageBlock returns the lines for one image, inline if possible.
@@ -2048,13 +2071,13 @@ func (v *View) renderUnifiedDiff(text string, width int, sourcePath string) []st
20482071 }
20492072 switch l[0] {
20502073 case '+':
2051- out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath))
2074+ out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath)...)
20522075 newLine++
20532076 case '-':
2054- out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath))
2077+ out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath)...)
20552078 oldLine++
20562079 case ' ':
2057- out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath))
2080+ out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath)...)
20582081 oldLine++
20592082 newLine++
20602083 default:
@@ -1549,15 +1549,15 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s
1549 if inDiff && len(l) > 0 {1549 if inDiff && len(l) > 0 {
1550 switch l[0] {1550 switch l[0] {
1551 case '+':1551 case '+':
1552- out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath))1552+ out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath)...)
1553 newLine++1553 newLine++
1554 continue1554 continue
1555 case '-':1555 case '-':
1556- out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath))1556+ out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath)...)
1557 oldLine++1557 oldLine++
1558 continue1558 continue
1559 case ' ':1559 case ' ':
1560- out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath))1560+ out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath)...)
1561 oldLine++1561 oldLine++
1562 newLine++1562 newLine++
1563 continue1563 continue
@@ -1613,34 +1613,17 @@ func parseHunkHeader(l string) (oldStart, newStart int, ok bool) {
1613 return o, n, true1613 return o, n, true
1614 }1614 }
1615 1615
1616-// renderDiffRow renders one unified-diff line with a read-style gutter1616+// renderDiffRow renders one unified-diff source line as one or more visual
1617-// (6-cell right-aligned line number, muted) followed by the +/-/space1617+// rows. The first row carries the read-style line-number gutter; continuation
1618-// marker and the code. Code is syntax-highlighted if sourcePath hints1618+// rows use a blank gutter so wrapped content cannot be mistaken for another
1619-// at a known language; falls back to the plain diff color otherwise.1619+// source line. Code is syntax-highlighted if sourcePath hints at a known
1620-func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byte, sourcePath string) string {1620+// language and otherwise uses the diff color.
1621+func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byte, sourcePath string) []string {
1621 if len(line) == 0 {1622 if len(line) == 0 {
1622- return ""1623+ return []string{""}
1623 }1624 }
1624 code := expandTabs(line[1:]) // strip the leading marker; expand tabs1625 code := expandTabs(line[1:]) // strip the leading marker; expand tabs
1625 1626
1626- // Syntax-highlight the code half when we know the language. Use
1627- // the same HighlightCode pipeline as renderNumberedFile so the
1628- // palette matches.
1629- lang := LanguageFromPath(sourcePath)
1630- var codeRendered string
1631- if lang != "" {
1632- if h := v.Theme.HighlightCode(code, lang); len(h) == 1 {
1633- codeRendered = h[0]
1634- }
1635- }
1636- if codeRendered == "" {
1637- if mark == ' ' {
1638- codeRendered = v.Theme.FG256(v.Theme.Muted, code)
1639- } else {
1640- codeRendered = v.Theme.FG256(color, code)
1641- }
1642- }
1643-
1644 // Gutter shape: sign + number share a color so they read as one1627 // Gutter shape: sign + number share a color so they read as one
1645 // visual token ("+123") instead of a neutral line number next to1628 // visual token ("+123") instead of a neutral line number next to
1646 // a stray marker. Unchanged context lines get a muted gutter and1629 // a stray marker. Unchanged context lines get a muted gutter and
@@ -1655,39 +1638,79 @@ func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byt
1655 default:1638 default:
1656 gutterText = fmt.Sprintf(" %3d ", lineNo)1639 gutterText = fmt.Sprintf(" %3d ", lineNo)
1657 }1640 }
1658- var gutter string1641+ gutterColor := color
1659 if mark == ' ' {1642 if mark == ' ' {
1660- gutter = v.Theme.FG256(v.Theme.Muted, gutterText)1643+ gutterColor = v.Theme.Muted
1661- } else {
1662- gutter = v.Theme.FG256(color, gutterText)
1663 }1644 }
1664- row := " " + gutter + codeRendered
1665 1645
1666- // Cheap width clamp: truncate visible text if the raw code is too1646+ maxCode := width - visibleWidth(" "+gutterText)
1667- // long. We work on the pre-ANSI code string because measuring ansi1647+ if maxCode < 1 {
1668- // output is unreliable.1648+ maxCode = 1
1669- maxCode := width - 4 /* indent */ - 7 /* gutter (sign+5 digits+tab) */1649+ }
1670- if maxCode > 0 && len(code) > maxCode {1650+ chunks := wrapCodeLine(code, maxCode)
1671- trunc := strings.Repeat(".", maxCode)1651+ lang := LanguageFromPath(sourcePath)
1672- if maxCode > 3 {1652+ out := make([]string, 0, len(chunks))
1673- trunc = code[:maxCode-3] + "..."1653+ for i, chunk := range chunks {
1654+ rowGutter := gutterText
1655+ if i > 0 {
1656+ rowGutter = strings.Repeat(" ", visibleWidth(gutterText))
1674 }1657 }
1658+ codeRendered := ""
1675 if lang != "" {1659 if lang != "" {
1676- if h := v.Theme.HighlightCode(trunc, lang); len(h) == 1 {1660+ if h := v.Theme.HighlightCode(chunk, lang); len(h) == 1 {
1677 codeRendered = h[0]1661 codeRendered = h[0]
1678- } else if mark == ' ' {
1679- codeRendered = v.Theme.FG256(v.Theme.Muted, trunc)
1680- } else {
1681- codeRendered = v.Theme.FG256(color, trunc)
1682 }1662 }
1683- } else if mark == ' ' {
1684- codeRendered = v.Theme.FG256(v.Theme.Muted, trunc)
1685- } else {
1686- codeRendered = v.Theme.FG256(color, trunc)
1687 }1663 }
1688- row = " " + gutter + codeRendered1664+ if codeRendered == "" {
1665+ codeColor := color
1666+ if mark == ' ' {
1667+ codeColor = v.Theme.Muted
1668+ }
1669+ codeRendered = v.Theme.FG256(codeColor, chunk)
1670+ }
1671+ out = append(out, " "+v.Theme.FG256(gutterColor, rowGutter)+codeRendered)
1672+ }
1673+ return out
1674+}
1675+
1676+// wrapCodeLine splits code to a terminal cell width without dropping any
1677+// characters. It prefers breaking after whitespace, then falls back to a hard
1678+// rune boundary for long tokens. Keeping the whitespace on the preceding row
1679+// means joining the returned chunks reconstructs the expanded source exactly.
1680+func wrapCodeLine(line string, width int) []string {
1681+ if width < 1 || visibleWidth(line) <= width {
1682+ return []string{line}
1683+ }
1684+ runes := []rune(line)
1685+ var out []string
1686+ for start := 0; start < len(runes); {
1687+ cells := 0
1688+ end := start
1689+ lastBreak := -1
1690+ for end < len(runes) {
1691+ runeWidth := runewidth.RuneWidth(runes[end])
1692+ if cells+runeWidth > width && end > start {
1693+ break
1694+ }
1695+ cells += runeWidth
1696+ end++
1697+ if runes[end-1] == ' ' || runes[end-1] == '\t' {
1698+ lastBreak = end
1699+ }
1700+ if cells >= width {
1701+ break
1702+ }
1703+ }
1704+ if end == start { // A rune wider than the requested width.
1705+ end++
1706+ }
1707+ if end < len(runes) && lastBreak > start {
1708+ end = lastBreak
1709+ }
1710+ out = append(out, string(runes[start:end]))
1711+ start = end
1689 }1712 }
1690- return row1713+ return out
1691 }1714 }
1692 1715
1693 // renderImageBlock returns the lines for one image, inline if possible.1716 // renderImageBlock returns the lines for one image, inline if possible.
@@ -2048,13 +2071,13 @@ func (v *View) renderUnifiedDiff(text string, width int, sourcePath string) []st
2048 }2071 }
2049 switch l[0] {2072 switch l[0] {
2050 case '+':2073 case '+':
2051- out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath))2074+ out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath)...)
2052 newLine++2075 newLine++
2053 case '-':2076 case '-':
2054- out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath))2077+ out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath)...)
2055 oldLine++2078 oldLine++
2056 case ' ':2079 case ' ':
2057- out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath))2080+ out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath)...)
2058 oldLine++2081 oldLine++
2059 newLine++2082 newLine++
2060 default:2083 default:
A packages/tui/view_diff_wrap_test.go +69 -0
@@ -0,0 +1,69 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestRenderDiffRowWrapsWithoutLosingContent(t *testing.T) {
9+ const width = 30
10+ code := `const message = "alpha beta gamma delta epsilon"`
11+ v := View{Theme: Dark}
12+
13+ rows := v.renderDiffRow("+"+code, width, v.Theme.Tool, 7, '+', "sample.go")
14+ if len(rows) < 2 {
15+ t.Fatalf("long diff line did not wrap: %q", rows)
16+ }
17+
18+ var rebuilt strings.Builder
19+ for i, row := range rows {
20+ plain := stripANSI(row)
21+ if got := visibleWidth(row); got > width {
22+ t.Errorf("row %d width = %d, want <= %d: %q", i, got, width, plain)
23+ }
24+ prefix := " "
25+ if i == 0 {
26+ prefix = " + 7 "
27+ }
28+ if !strings.HasPrefix(plain, prefix) {
29+ t.Fatalf("row %d prefix = %q, want %q", i, plain, prefix)
30+ }
31+ rebuilt.WriteString(strings.TrimPrefix(plain, prefix))
32+ }
33+ if got := rebuilt.String(); got != code {
34+ t.Fatalf("wrapped content changed:\n got: %q\nwant: %q", got, code)
35+ }
36+ if strings.Contains(stripANSI(strings.Join(rows, "\n")), "...") {
37+ t.Fatalf("wrapped diff still contains truncation ellipsis: %q", rows)
38+ }
39+}
40+
41+func TestRenderDiffRowWrapsUnicodeByDisplayWidth(t *testing.T) {
42+ const width = 18
43+ code := "名前 = 🙂🙂🙂 café résumé"
44+ v := View{Theme: Dark}
45+
46+ rows := v.renderDiffRow("-"+code, width, v.Theme.Error, 12, '-', "")
47+ if len(rows) < 2 {
48+ t.Fatalf("long Unicode diff line did not wrap: %q", rows)
49+ }
50+
51+ var rebuilt strings.Builder
52+ for i, row := range rows {
53+ plain := stripANSI(row)
54+ if got := visibleWidth(row); got > width {
55+ t.Errorf("row %d width = %d, want <= %d: %q", i, got, width, plain)
56+ }
57+ prefix := " "
58+ if i == 0 {
59+ prefix = " - 12 "
60+ }
61+ if !strings.HasPrefix(plain, prefix) {
62+ t.Fatalf("row %d prefix = %q, want %q", i, plain, prefix)
63+ }
64+ rebuilt.WriteString(strings.TrimPrefix(plain, prefix))
65+ }
66+ if got := rebuilt.String(); got != code {
67+ t.Fatalf("wrapped Unicode content changed:\n got: %q\nwant: %q", got, code)
68+ }
69+}
@@ -0,0 +1,69 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestRenderDiffRowWrapsWithoutLosingContent(t *testing.T) {
9+ const width = 30
10+ code := `const message = "alpha beta gamma delta epsilon"`
11+ v := View{Theme: Dark}
12+
13+ rows := v.renderDiffRow("+"+code, width, v.Theme.Tool, 7, '+', "sample.go")
14+ if len(rows) < 2 {
15+ t.Fatalf("long diff line did not wrap: %q", rows)
16+ }
17+
18+ var rebuilt strings.Builder
19+ for i, row := range rows {
20+ plain := stripANSI(row)
21+ if got := visibleWidth(row); got > width {
22+ t.Errorf("row %d width = %d, want <= %d: %q", i, got, width, plain)
23+ }
24+ prefix := " "
25+ if i == 0 {
26+ prefix = " + 7 "
27+ }
28+ if !strings.HasPrefix(plain, prefix) {
29+ t.Fatalf("row %d prefix = %q, want %q", i, plain, prefix)
30+ }
31+ rebuilt.WriteString(strings.TrimPrefix(plain, prefix))
32+ }
33+ if got := rebuilt.String(); got != code {
34+ t.Fatalf("wrapped content changed:\n got: %q\nwant: %q", got, code)
35+ }
36+ if strings.Contains(stripANSI(strings.Join(rows, "\n")), "...") {
37+ t.Fatalf("wrapped diff still contains truncation ellipsis: %q", rows)
38+ }
39+}
40+
41+func TestRenderDiffRowWrapsUnicodeByDisplayWidth(t *testing.T) {
42+ const width = 18
43+ code := "名前 = 🙂🙂🙂 café résumé"
44+ v := View{Theme: Dark}
45+
46+ rows := v.renderDiffRow("-"+code, width, v.Theme.Error, 12, '-', "")
47+ if len(rows) < 2 {
48+ t.Fatalf("long Unicode diff line did not wrap: %q", rows)
49+ }
50+
51+ var rebuilt strings.Builder
52+ for i, row := range rows {
53+ plain := stripANSI(row)
54+ if got := visibleWidth(row); got > width {
55+ t.Errorf("row %d width = %d, want <= %d: %q", i, got, width, plain)
56+ }
57+ prefix := " "
58+ if i == 0 {
59+ prefix = " - 12 "
60+ }
61+ if !strings.HasPrefix(plain, prefix) {
62+ t.Fatalf("row %d prefix = %q, want %q", i, plain, prefix)
63+ }
64+ rebuilt.WriteString(strings.TrimPrefix(plain, prefix))
65+ }
66+ if got := rebuilt.String(); got != code {
67+ t.Fatalf("wrapped Unicode content changed:\n got: %q\nwant: %q", got, code)
68+ }
69+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close