返回 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 := width - 2 - gw - 1
148 if barW < 4 {
149 barW = 4
150 }
151 code = clampPlain(code, barW-2)
152 if !colorOn() {
153 return " " + gutter + " " + string(sign) + " " + code
154 }
155 hl := reapplyBG(highlightCode(path, code), bg)
156 pad := barW - 2 - visibleWidth(code)
157 if pad < 0 {
158 pad = 0
159 }
160 return " " + gutter + " " + bg + signFg + string(sign) + ansiReset + bg + " " + hl + strings.Repeat(" ", pad) + ansiReset
161 }
162
163 // diffContext draws an unchanged line: the gutter, no background, code aligned
164 // under the +/- rows' code column.
165 func diffContext(code, path string, width, lineNo, gw int) string {
166 gutter := dim(lpad(strconv.Itoa(lineNo), gw))
167 return " " + gutter + " " + highlightClamped(code, path, width-4-gw)
168 }
169
170 func gutterWidth(lines []string) int {
171 max := 0
172 for _, ln := range lines {
173 m := hunkRE.FindStringSubmatch(ln)
174 if m == nil {
175 continue
176 }
177 for _, p := range [][2]int{{1, 2}, {3, 4}} {
178 end := atoi(m[p[0]])
179 if m[p[1]] != "" {
180 end += atoi(m[p[1]])
181 } else {
182 end++
183 }
184 if end > max {
185 max = end
186 }
187 }
188 }
189 if w := len(strconv.Itoa(max)); w > 2 {
190 return w
191 }
192 return 2
193 }
194
195 func lpad(s string, w int) string {
196 if len(s) >= w {
197 return s
198 }
199 return strings.Repeat(" ", w-len(s)) + s
200 }
201
202 func atoi(s string) int {
203 n, _ := strconv.Atoi(s)
204 return n
205 }
206
207 func highlightClamped(code, path string, w int) string {
208 c := clampPlain(code, w)
209 if !colorOn() {
210 return c
211 }
212 return highlightCode(path, c)
213 }
214
215 func clampPlain(s string, w int) string {
216 if w < 1 {
217 w = 1
218 }
219 return ansi.Truncate(expandTabs(s), w, "")
220 }
221
222 // expandTabs replaces tabs with spaces to the next tabWidth stop. A literal tab
223 // has zero StringWidth but the terminal advances it to a tab stop, so leaving
224 // tabs in a background-bar row overflows the bar — expand them so the measured
225 // width matches what's drawn.
226 func expandTabs(s string) string {
227 if !strings.ContainsRune(s, '\t') {
228 return s
229 }
230 var b strings.Builder
231 col := 0
232 for _, r := range s {
233 if r == '\t' {
234 n := tabWidth - col%tabWidth
235 for i := 0; i < n; i++ {
236 b.WriteByte(' ')
237 }
238 col += n
239 continue
240 }
241 b.WriteRune(r)
242 col++
243 }
244 return b.String()
245 }
246
247 func reapplyBG(s, bg string) string {
248 if s == "" {
249 return s
250 }
251 return strings.ReplaceAll(s, ansiReset, ansiReset+bg)
252 }
253
254 // highlightCode returns code with chroma ANSI foreground colours for the lexer
255 // matched by path (plain fallback for unknown types). It emits no background, so
256 // it composes onto a diff bar; the caller re-applies the bar background.
257 func highlightCode(path, code string) string {
258 if code == "" {
259 return code
260 }
261 lexer := lexers.Match(path)
262 if lexer == nil {
263 lexer = lexers.Fallback
264 }
265 it, err := lexer.Tokenise(nil, code)
266 if err != nil {
267 return code
268 }
269 var b strings.Builder
270 if diffChromaFmt.Format(&b, activeDiffChromaStyle(), it) != nil {
271 return code
272 }
273 return strings.TrimRight(b.String(), "\n")
274 }
275
275 lines GO