Skip to content

Commit 8a66ab1

Browse files
authored
feat: display lines numbers in diff (#308)
Adds diff gutter line numbers to the UI diff formatter output (rendered via extmarks),
1 parent e23f440 commit 8a66ab1

10 files changed

Lines changed: 10855 additions & 13682 deletions

lua/opencode/ui/formatter.lua

Lines changed: 87 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -939,40 +939,98 @@ function M._format_code(output, lines, language)
939939
output:add_line('`````')
940940
end
941941

942-
---@param output Output Output object to write to
943-
---@param code string
944-
---@param file_type string
942+
---@param lines string[]
943+
local function parse_diff_line_numbers(lines)
944+
local numbered_lines = {}
945+
local old_line
946+
local new_line
947+
local max_line_number = 0
948+
949+
for idx, line in ipairs(lines) do
950+
local old_start, new_start = line:match('^@@ %-(%d+),?%d* %+(%d+),?%d* @@')
951+
952+
if old_start and new_start then
953+
old_line = tonumber(old_start)
954+
new_line = tonumber(new_start)
955+
elseif old_line and new_line then
956+
local first_char = line:sub(1, 1)
957+
958+
if first_char == ' ' then
959+
numbered_lines[idx] = { old = old_line, new = new_line }
960+
max_line_number = math.max(max_line_number, old_line, new_line)
961+
old_line = old_line + 1
962+
new_line = new_line + 1
963+
elseif first_char == '+' and not line:match('^%+%+%+%s') then
964+
numbered_lines[idx] = { old = nil, new = new_line }
965+
max_line_number = math.max(max_line_number, new_line)
966+
new_line = new_line + 1
967+
elseif first_char == '-' and not line:match('^%-%-%-%s') then
968+
numbered_lines[idx] = { old = old_line, new = nil }
969+
max_line_number = math.max(max_line_number, old_line)
970+
old_line = old_line + 1
971+
end
972+
end
973+
end
974+
975+
return numbered_lines, #tostring(max_line_number)
976+
end
977+
978+
local function build_diff_gutter(line_numbers, width)
979+
local line_number = line_numbers.new or line_numbers.old
980+
return string.format('%-' .. width .. 's', line_number and tostring(line_number) or '')
981+
end
982+
983+
local function add_diff_line(output, line, line_numbers, width)
984+
local first_char = line:sub(1, 1)
985+
local line_hl = first_char == '+' and 'OpencodeDiffAdd' or first_char == '-' and 'OpencodeDiffDelete' or nil
986+
local gutter_hl = first_char == '+' and 'OpencodeDiffAddGutter'
987+
or first_char == '-' and 'OpencodeDiffDeleteGutter'
988+
or 'OpencodeDiffGutter'
989+
local sign_hl = gutter_hl
990+
local gutter = build_diff_gutter(line_numbers, width)
991+
local gutter_width = #gutter + 2
992+
993+
output:add_line(string.rep(' ', gutter_width) .. line:sub(2))
994+
995+
local line_idx = output:get_line_count()
996+
local extmark = {
997+
end_col = 0,
998+
end_row = line_idx,
999+
virt_text = {
1000+
{ gutter, gutter_hl },
1001+
{ first_char, sign_hl },
1002+
{ ' ', gutter_hl },
1003+
},
1004+
priority = 5000,
1005+
right_gravity = true,
1006+
end_right_gravity = false,
1007+
virt_text_hide = false,
1008+
virt_text_pos = 'overlay',
1009+
virt_text_repeat_linebreak = false,
1010+
}
1011+
1012+
if line_hl then
1013+
extmark.hl_group = line_hl
1014+
extmark.hl_eol = true
1015+
end
1016+
1017+
output:add_extmark(line_idx - 1, extmark --[[@as OutputExtmark]])
1018+
end
1019+
9451020
function M.format_diff(output, code, file_type)
9461021
output:add_empty_line()
9471022

9481023
--- NOTE: use longer code fence because code could contain ```
9491024
output:add_line('`````' .. file_type)
950-
local lines = vim.split(code, '\n')
951-
if #lines > 5 then
952-
lines = vim.list_slice(lines, 6)
953-
end
954-
955-
for _, line in ipairs(lines) do
956-
local first_char = line:sub(1, 1)
957-
if first_char == '+' or first_char == '-' then
958-
local hl_group = first_char == '+' and 'OpencodeDiffAdd' or 'OpencodeDiffDelete'
959-
output:add_line(' ' .. line:sub(2))
960-
local line_idx = output:get_line_count()
961-
output:add_extmark(line_idx - 1, function()
962-
return {
963-
end_col = 0,
964-
end_row = line_idx,
965-
virt_text = { { first_char, hl_group } },
966-
hl_group = hl_group,
967-
hl_eol = true,
968-
priority = 5000,
969-
right_gravity = true,
970-
end_right_gravity = false,
971-
virt_text_hide = false,
972-
virt_text_pos = 'overlay',
973-
virt_text_repeat_linebreak = false,
974-
}
975-
end)
1025+
local full_lines = vim.split(code, '\n')
1026+
local numbered_lines, line_number_width = parse_diff_line_numbers(full_lines)
1027+
local first_visible_line = #full_lines > 5 and 6 or 1
1028+
local lines = first_visible_line > 1 and vim.list_slice(full_lines, first_visible_line) or full_lines
1029+
1030+
for idx, line in ipairs(lines) do
1031+
local source_idx = first_visible_line + idx - 1
1032+
if numbered_lines[source_idx] then
1033+
add_diff_line(output, line, numbered_lines[source_idx], line_number_width)
9761034
else
9771035
output:add_line(line)
9781036
end

lua/opencode/ui/highlight.lua

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ function M.setup()
1515
vim.api.nvim_set_hl(0, 'OpencodeDiffDelete', { bg = '#FFEBEE', default = true })
1616
vim.api.nvim_set_hl(0, 'OpencodeDiffAddText', { link = 'Added', default = true })
1717
vim.api.nvim_set_hl(0, 'OpencodeDiffDeleteText', { link = 'Removed', default = true })
18+
vim.api.nvim_set_hl(0, 'OpencodeDiffGutter', { fg = '#757575', bg = '#F5F5F5', default = true })
19+
vim.api.nvim_set_hl(0, 'OpencodeDiffAddGutter', { fg = '#2E7D32', bg = '#F1FAF1', default = true })
20+
vim.api.nvim_set_hl(0, 'OpencodeDiffDeleteGutter', { fg = '#C62828', bg = '#FFF1F3', default = true })
1821
vim.api.nvim_set_hl(0, 'OpencodeRevertBorder', { bg = '#FF9E3B', default = true })
1922
vim.api.nvim_set_hl(0, 'OpencodePermissionBorder', { fg = '#FF9E3B', nocombine = true, default = true })
2023
vim.api.nvim_set_hl(0, 'OpencodeAgentPlan', { bg = '#2196F3', fg = '#FFFFFF', bold = true, default = true })
@@ -58,6 +61,9 @@ function M.setup()
5861
vim.api.nvim_set_hl(0, 'OpencodeDiffDelete', { bg = '#43242B', default = true })
5962
vim.api.nvim_set_hl(0, 'OpencodeDiffAddText', { link = 'Added', default = true })
6063
vim.api.nvim_set_hl(0, 'OpencodeDiffDeleteText', { link = 'Removed', default = true })
64+
vim.api.nvim_set_hl(0, 'OpencodeDiffGutter', { fg = '#6B7280', bg = '#252631', default = true })
65+
vim.api.nvim_set_hl(0, 'OpencodeDiffAddGutter', { fg = '#A5D6A7', bg = '#344032', default = true })
66+
vim.api.nvim_set_hl(0, 'OpencodeDiffDeleteGutter', { fg = '#EF9A9A', bg = '#52303A', default = true })
6167
vim.api.nvim_set_hl(0, 'OpencodeAgentPlan', { bg = '#61AFEF', fg = '#FFFFFF', bold = true, default = true })
6268
vim.api.nvim_set_hl(0, 'OpencodeAgentBuild', { bg = '#616161', fg = '#FFFFFF', bold = true, default = true })
6369
vim.api.nvim_set_hl(0, 'OpencodeAgentCustom', { bg = '#3b4261', fg = '#FFFFFF', bold = true, default = true })
Lines changed: 216 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,216 @@
1-
{"actions":[],"lines":["----","","","----","","","** apply patch** `src/app/features/auth/__tests__/LoginForm.test.tsx` 4s","","`````tsx"," import React from 'react'"," // minimal diff for testing","","`````","",""],"timestamp":1772538345,"extmarks":[[1,1,0,{"right_gravity":true,"virt_text_hide":false,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" [msg_user001]","OpencodeHint"]],"virt_text_pos":"win_col","ns_id":3,"priority":10,"virt_text_win_col":-3,"virt_text_repeat_linebreak":false}],[2,4,0,{"right_gravity":true,"virt_text_hide":false,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],["","OpencodeHint"],[" [msg_asst001]","OpencodeHint"]],"virt_text_pos":"win_col","ns_id":3,"priority":10,"virt_text_win_col":-3,"virt_text_repeat_linebreak":false}],[3,6,0,{"right_gravity":true,"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","ns_id":3,"priority":4096,"virt_text_win_col":-1,"virt_text_repeat_linebreak":true}],[4,7,0,{"right_gravity":true,"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","ns_id":3,"priority":4096,"virt_text_win_col":-1,"virt_text_repeat_linebreak":true}],[5,8,0,{"right_gravity":true,"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","ns_id":3,"priority":4096,"virt_text_win_col":-1,"virt_text_repeat_linebreak":true}],[6,9,0,{"virt_text_pos":"overlay","end_row":10,"priority":5000,"right_gravity":true,"end_right_gravity":false,"hl_group":"OpencodeDiffAdd","end_col":0,"hl_eol":true,"ns_id":3,"virt_text_hide":false,"virt_text":[["+","OpencodeDiffAdd"]],"virt_text_repeat_linebreak":false}],[7,9,0,{"right_gravity":true,"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","ns_id":3,"priority":4096,"virt_text_win_col":-1,"virt_text_repeat_linebreak":true}],[8,10,0,{"virt_text_pos":"overlay","end_row":11,"priority":5000,"right_gravity":true,"end_right_gravity":false,"hl_group":"OpencodeDiffAdd","end_col":0,"hl_eol":true,"ns_id":3,"virt_text_hide":false,"virt_text":[["+","OpencodeDiffAdd"]],"virt_text_repeat_linebreak":false}],[9,10,0,{"right_gravity":true,"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","ns_id":3,"priority":4096,"virt_text_win_col":-1,"virt_text_repeat_linebreak":true}],[10,11,0,{"right_gravity":true,"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","ns_id":3,"priority":4096,"virt_text_win_col":-1,"virt_text_repeat_linebreak":true}],[11,12,0,{"right_gravity":true,"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","ns_id":3,"priority":4096,"virt_text_win_col":-1,"virt_text_repeat_linebreak":true}]]}
1+
{
2+
"actions": [],
3+
"extmarks": [
4+
[
5+
1,
6+
1,
7+
0,
8+
{
9+
"ns_id": 3,
10+
"virt_text_hide": false,
11+
"priority": 10,
12+
"right_gravity": true,
13+
"virt_text_pos": "win_col",
14+
"virt_text_win_col": -3,
15+
"virt_text_repeat_linebreak": false,
16+
"virt_text": [
17+
["▌󰭻 ", "OpencodeMessageRoleUser"],
18+
[" "],
19+
["USER", "OpencodeMessageRoleUser"],
20+
["", "OpencodeHint"],
21+
[" [msg_user001]", "OpencodeHint"]
22+
]
23+
}
24+
],
25+
[
26+
2,
27+
4,
28+
0,
29+
{
30+
"ns_id": 3,
31+
"virt_text_hide": false,
32+
"priority": 10,
33+
"right_gravity": true,
34+
"virt_text_pos": "win_col",
35+
"virt_text_win_col": -3,
36+
"virt_text_repeat_linebreak": false,
37+
"virt_text": [
38+
["", "OpencodeMessageRoleAssistant"],
39+
[" "],
40+
["BUILD", "OpencodeMessageRoleAssistant"],
41+
["", "OpencodeHint"],
42+
[" [msg_asst001]", "OpencodeHint"]
43+
]
44+
}
45+
],
46+
[
47+
3,
48+
6,
49+
0,
50+
{
51+
"ns_id": 3,
52+
"virt_text_hide": false,
53+
"priority": 4096,
54+
"right_gravity": true,
55+
"virt_text_pos": "win_col",
56+
"virt_text_win_col": -1,
57+
"virt_text_repeat_linebreak": true,
58+
"virt_text": [["", "OpencodeToolBorder"]]
59+
}
60+
],
61+
[
62+
4,
63+
7,
64+
0,
65+
{
66+
"ns_id": 3,
67+
"virt_text_hide": false,
68+
"priority": 4096,
69+
"right_gravity": true,
70+
"virt_text_pos": "win_col",
71+
"virt_text_win_col": -1,
72+
"virt_text_repeat_linebreak": true,
73+
"virt_text": [["", "OpencodeToolBorder"]]
74+
}
75+
],
76+
[
77+
5,
78+
8,
79+
0,
80+
{
81+
"ns_id": 3,
82+
"virt_text_hide": false,
83+
"priority": 4096,
84+
"right_gravity": true,
85+
"virt_text_pos": "win_col",
86+
"virt_text_win_col": -1,
87+
"virt_text_repeat_linebreak": true,
88+
"virt_text": [["", "OpencodeToolBorder"]]
89+
}
90+
],
91+
[
92+
6,
93+
9,
94+
0,
95+
{
96+
"end_col": 0,
97+
"hl_group": "OpencodeDiffAdd",
98+
"right_gravity": true,
99+
"virt_text_pos": "overlay",
100+
"priority": 5000,
101+
"ns_id": 3,
102+
"end_row": 10,
103+
"end_right_gravity": false,
104+
"virt_text_hide": false,
105+
"virt_text": [
106+
["1", "OpencodeDiffAddGutter"],
107+
["+", "OpencodeDiffAddGutter"],
108+
[" ", "OpencodeDiffAddGutter"]
109+
],
110+
"virt_text_repeat_linebreak": false,
111+
"hl_eol": true
112+
}
113+
],
114+
[
115+
7,
116+
9,
117+
0,
118+
{
119+
"ns_id": 3,
120+
"virt_text_hide": false,
121+
"priority": 4096,
122+
"right_gravity": true,
123+
"virt_text_pos": "win_col",
124+
"virt_text_win_col": -1,
125+
"virt_text_repeat_linebreak": true,
126+
"virt_text": [["", "OpencodeToolBorder"]]
127+
}
128+
],
129+
[
130+
8,
131+
10,
132+
0,
133+
{
134+
"end_col": 0,
135+
"hl_group": "OpencodeDiffAdd",
136+
"right_gravity": true,
137+
"virt_text_pos": "overlay",
138+
"priority": 5000,
139+
"ns_id": 3,
140+
"end_row": 11,
141+
"end_right_gravity": false,
142+
"virt_text_hide": false,
143+
"virt_text": [
144+
["2", "OpencodeDiffAddGutter"],
145+
["+", "OpencodeDiffAddGutter"],
146+
[" ", "OpencodeDiffAddGutter"]
147+
],
148+
"virt_text_repeat_linebreak": false,
149+
"hl_eol": true
150+
}
151+
],
152+
[
153+
9,
154+
10,
155+
0,
156+
{
157+
"ns_id": 3,
158+
"virt_text_hide": false,
159+
"priority": 4096,
160+
"right_gravity": true,
161+
"virt_text_pos": "win_col",
162+
"virt_text_win_col": -1,
163+
"virt_text_repeat_linebreak": true,
164+
"virt_text": [["", "OpencodeToolBorder"]]
165+
}
166+
],
167+
[
168+
10,
169+
11,
170+
0,
171+
{
172+
"ns_id": 3,
173+
"virt_text_hide": false,
174+
"priority": 4096,
175+
"right_gravity": true,
176+
"virt_text_pos": "win_col",
177+
"virt_text_win_col": -1,
178+
"virt_text_repeat_linebreak": true,
179+
"virt_text": [["", "OpencodeToolBorder"]]
180+
}
181+
],
182+
[
183+
11,
184+
12,
185+
0,
186+
{
187+
"ns_id": 3,
188+
"virt_text_hide": false,
189+
"priority": 4096,
190+
"right_gravity": true,
191+
"virt_text_pos": "win_col",
192+
"virt_text_win_col": -1,
193+
"virt_text_repeat_linebreak": true,
194+
"virt_text": [["", "OpencodeToolBorder"]]
195+
}
196+
]
197+
],
198+
"lines": [
199+
"----",
200+
"",
201+
"",
202+
"----",
203+
"",
204+
"",
205+
"** apply patch** `src/app/features/auth/__tests__/LoginForm.test.tsx` 4s",
206+
"",
207+
"`````tsx",
208+
" import React from 'react'",
209+
" // minimal diff for testing",
210+
"",
211+
"`````",
212+
"",
213+
""
214+
],
215+
"timestamp": 1772803135
216+
}

0 commit comments

Comments
 (0)