返回 DeepSeek-Reasonix
diffview.go
根目录 / internal / cli / diffview.go
1 // Renders a unified diff as line-numbered, syntax-highlighted rows on
2 // green/red background bars with a +/- gutter.
3 package cli
4
5 import (
6 "encoding/json"
7 "fmt"
8 "regexp"
9 "strconv"
10 "strings"
11
12 "github.com/alecthomas/chroma/v2"
13 "github.com/alecthomas/chroma/v2/formatters"
14 "github.com/alecthomas/chroma/v2/lexers"
15 "github.com/alecthomas/chroma/v2/styles"
16 "github.com/charmbracelet/x/ansi"
17
18 "reasonix/internal/event"
19 "reasonix/internal/i18n"
20 )
21
22 const tabWidth = 4
23
24 const (
25 // diffFoldLimit is the max lines to show in a diff when folding is enabled
26 // (/diff-fold toggle). 0 means show all lines.
27 diffFoldLimit = 40
28
29 bgDiffAdd = "\033[48;5;22m"
30 bgDiffDel = "\033[48;5;52m"
31 fgDiffAdd = "\033[1;38;5;46m"
32 fgDiffDel = "\033[1;38;5;203m"
33 )
34
35 var (
36 diffChromaFmt = formatters.Get("terminal256")
37 hunkRE = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@`)
38 )
39
40 // Resolve on each render so runtime theme switches and theme-sweep preview
41 // frames cannot retain syntax colours from the previous light/dark mode.
42 func activeDiffChromaStyle() *chroma.Style {
43 mode := chroma.Dark
44 if activeCLITheme.name == "light" {
45 mode = chroma.Light
46 }
47 return styles.GetForMode("github-dark", mode)
48 }
49
50 // diffStat renders a change's "+A -B" tally, green/red, omitting a zero side.
51 func diffStat(d event.FileDiff) string {
52 parts := make([]string, 0, 2)
53 if d.Added > 0 {
54 parts = append(parts, green("+"+strconv.Itoa(d.Added)))
55 }
56 if d.Removed > 0 {
57 parts = append(parts, red("-"+strconv.Itoa(d.Removed)))
58 }
59 return strings.Join(parts, " ")
60 }
61
62 func diffPath(args string) string {
63 var p struct {
64 Path string `json:"path"`
65 }
66 _ = json.Unmarshal([]byte(args), &p)
67 return p.Path
68 }
69
70 // diffBlock renders a writer call as a header line ("✎ name path +A -B") plus
71 // the highlighted, folded diff body. Returns nil when there's no textual diff.
72 func diffBlock(name, args string, d event.FileDiff, width, maxLines int) []string {
73 if d.Diff == "" {
74 return nil
75 }
76 path := diffPath(args)
77 header := " " + toolDot(name) + " " + toolHead(name, path, width)
78 if stat := diffStat(d); stat != "" {
79 header += " " + stat
80 }
81 return append([]string{header}, diffBody(d, path, width, maxLines)...)
82 }
83
84 // diffBody renders the hunks with a line-number gutter, dropping the file and
85 // "@@" headers (a dim "⋮" marks each hunk jump) and folding past maxLines to a
86 // "+N more" footer. path selects the syntax lexer.
87 func diffBody(d event.FileDiff, path string, width, maxLines int) []string {
88 if d.Diff == "" {
89 return nil
90 }
91 src := strings.Split(strings.TrimRight(d.Diff, "\n"), "\n")
92 // Drop the "--- a/… / +++ b/…" header pair positionally — matching the prefix
93 // on every line would eat real content (a deleted SQL "-- x" renders "--- x",
94 // an added "++ y" renders "+++ y").
95 if len(src) >= 2 && strings.HasPrefix(src[0], "--- ") && strings.HasPrefix(src[1], "+++ ") {
96 src = src[2:]
97 }
98 gw := gutterWidth(src)
99
100 var rows []string
101 oldNo, newNo, hunks := 0, 0, 0
102 for _, ln := range src {
103 if ln == "" {
104 continue
105 }
106 switch ln[0] {
107 case '@':
108 if m := hunkRE.FindStringSubmatch(ln); m != nil {
109 oldNo, newNo = atoi(m[1]), atoi(m[3])
110 }
111 if hunks > 0 {
112 rows = append(rows, " "+dim("⋮"))
113 }
114 hunks++
115 case '+':
116 rows = append(rows, diffBar('+', ln[1:], path, width, bgSGR(activeCLITheme.diffAddBG), fgSGR(activeCLITheme.success), newNo, gw))
117 newNo++
118 case '-':
119 rows = append(rows, diffBar('-', ln[1:], path, width, bgSGR(activeCLITheme.diffDelBG), fgSGR(activeCLITheme.err), oldNo, gw))
120 oldNo++
121 case '\\':
122 rows = append(rows, " "+dim(clampPlain(ln, width-2)))
123 default:
124 code := ln
125 if ln[0] == ' ' {
126 code = ln[1:]
127 }
128 rows = append(rows, diffContext(code, path, width, newNo, gw))
129 oldNo++
130 newNo++
131 }
132 }
133
134 if maxLines > 0 && len(rows) > maxLines {
135 folded := len(rows) - (maxLines - 1)
136 rows = rows[:maxLines-1]
137 rows = append(rows, " "+dim(fmt.Sprintf(i18n.M.DiffFoldedFmt, folded)))
138 }
139 return rows
140 }
141
142 // diffBar draws one added/removed row on a full-width coloured background. The
143 // bg is re-applied after every chroma reset — \033[0m would otherwise end the
144 // bar mid-line — and padded to the bar width so it runs edge to edge.
145 func diffBar(sign byte, code, path string, width int, bg, signFg string, lineNo, gw int) string {
146 gutter := dim(lpad(strconv.Itoa(lineNo), gw))
147 barW := max(width-2-gw-1, 4)
148 code = clampPlain(code, barW-2)
149 if !colorOn() {
150 return " " + gutter + " " + string(sign) + " " + code
151 }
152 hl := reapplyBG(highlightCode(path, code), bg)
153 pad := max(barW-2-visibleWidth(code), 0)
154 return " " + gutter + " " + bg + signFg + string(sign) + ansiReset + bg + " " + hl + strings.Repeat(" ", pad) + ansiReset
155 }
156
157 // diffContext draws an unchanged line: the gutter, no background, code aligned
158 // under the +/- rows' code column.
159 func diffContext(code, path string, width, lineNo, gw int) string {
160 gutter := dim(lpad(strconv.Itoa(lineNo), gw))
161 return " " + gutter + " " + highlightClamped(code, path, width-4-gw)
162 }
163
164 func gutterWidth(lines []string) int {
165 max := 0
166 for _, ln := range lines {
167 m := hunkRE.FindStringSubmatch(ln)
168 if m == nil {
169 continue
170 }
171 for _, p := range [][2]int{{1, 2}, {3, 4}} {
172 end := atoi(m[p[0]])
173 if m[p[1]] != "" {
174 end += atoi(m[p[1]])
175 } else {
176 end++
177 }
178 if end > max {
179 max = end
180 }
181 }
182 }
183 if w := len(strconv.Itoa(max)); w > 2 {
184 return w
185 }
186 return 2
187 }
188
189 func lpad(s string, w int) string {
190 if len(s) >= w {
191 return s
192 }
193 return strings.Repeat(" ", w-len(s)) + s
194 }
195
196 func atoi(s string) int {
197 n, _ := strconv.Atoi(s)
198 return n
199 }
200
201 func highlightClamped(code, path string, w int) string {
202 c := clampPlain(code, w)
203 if !colorOn() {
204 return c
205 }
206 return highlightCode(path, c)
207 }
208
209 func clampPlain(s string, w int) string {
210 if w < 1 {
211 w = 1
212 }
213 return ansi.Truncate(expandTabs(s), w, "")
214 }
215
216 // expandTabs replaces tabs with spaces to the next tabWidth stop. A literal tab
217 // has zero StringWidth but the terminal advances it to a tab stop, so leaving
218 // tabs in a background-bar row overflows the bar — expand them so the measured
219 // width matches what's drawn.
220 func expandTabs(s string) string {
221 if !strings.ContainsRune(s, '\t') {
222 return s
223 }
224 var b strings.Builder
225 col := 0
226 for _, r := range s {
227 if r == '\t' {
228 n := tabWidth - col%tabWidth
229 for range n {
230 b.WriteByte(' ')
231 }
232 col += n
233 continue
234 }
235 b.WriteRune(r)
236 col++
237 }
238 return b.String()
239 }
240
241 func reapplyBG(s, bg string) string {
242 if s == "" {
243 return s
244 }
245 return strings.ReplaceAll(s, ansiReset, ansiReset+bg)
246 }
247
248 // highlightCode returns code with chroma ANSI foreground colours for the lexer
249 // matched by path (plain fallback for unknown types). It emits no background, so
250 // it composes onto a diff bar; the caller re-applies the bar background.
251 func highlightCode(path, code string) string {
252 if code == "" {
253 return code
254 }
255 lexer := lexers.Match(path)
256 if lexer == nil {
257 lexer = lexers.Fallback
258 }
259 it, err := lexer.Tokenise(nil, code)
260 if err != nil {
261 return code
262 }
263 var b strings.Builder
264 if diffChromaFmt.Format(&b, activeDiffChromaStyle(), it) != nil {
265 return code
266 }
267 return strings.TrimRight(b.String(), "\n")
268 }
269
269 lines GO