Skip to content

Commit 52003f1

Browse files
committed
feat: add line numbers to diff
1 parent e23f440 commit 52003f1

3 files changed

Lines changed: 129 additions & 29 deletions

File tree

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('^%+%+%+') 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('^%-%-%-') 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, math.max(#tostring(max_line_number), 4)
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: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ 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, 'OpencodeDiffLineNumber', { link = 'Comment', default = true })
19+
vim.api.nvim_set_hl(0, 'OpencodeDiffGutter', { fg = '#757575', bg = '#F5F5F5', default = true })
20+
vim.api.nvim_set_hl(0, 'OpencodeDiffAddGutter', { fg = '#2E7D32', bg = '#F1FAF1', default = true })
21+
vim.api.nvim_set_hl(0, 'OpencodeDiffDeleteGutter', { fg = '#C62828', bg = '#FFF1F3', default = true })
1822
vim.api.nvim_set_hl(0, 'OpencodeRevertBorder', { bg = '#FF9E3B', default = true })
1923
vim.api.nvim_set_hl(0, 'OpencodePermissionBorder', { fg = '#FF9E3B', nocombine = true, default = true })
2024
vim.api.nvim_set_hl(0, 'OpencodeAgentPlan', { bg = '#2196F3', fg = '#FFFFFF', bold = true, default = true })
@@ -58,6 +62,10 @@ function M.setup()
5862
vim.api.nvim_set_hl(0, 'OpencodeDiffDelete', { bg = '#43242B', default = true })
5963
vim.api.nvim_set_hl(0, 'OpencodeDiffAddText', { link = 'Added', default = true })
6064
vim.api.nvim_set_hl(0, 'OpencodeDiffDeleteText', { link = 'Removed', default = true })
65+
vim.api.nvim_set_hl(0, 'OpencodeDiffLineNumber', { link = 'Comment', default = true })
66+
vim.api.nvim_set_hl(0, 'OpencodeDiffGutter', { fg = '#6B7280', bg = '#252631', default = true })
67+
vim.api.nvim_set_hl(0, 'OpencodeDiffAddGutter', { fg = '#A5D6A7', bg = '#344032', default = true })
68+
vim.api.nvim_set_hl(0, 'OpencodeDiffDeleteGutter', { fg = '#EF9A9A', bg = '#52303A', default = true })
6169
vim.api.nvim_set_hl(0, 'OpencodeAgentPlan', { bg = '#61AFEF', fg = '#FFFFFF', bold = true, default = true })
6270
vim.api.nvim_set_hl(0, 'OpencodeAgentBuild', { bg = '#616161', fg = '#FFFFFF', bold = true, default = true })
6371
vim.api.nvim_set_hl(0, 'OpencodeAgentCustom', { bg = '#3b4261', fg = '#FFFFFF', bold = true, default = true })

tests/unit/formatter_spec.lua

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
local assert = require('luassert')
22
local config = require('opencode.config')
33
local formatter = require('opencode.ui.formatter')
4+
local Output = require('opencode.ui.output')
45

56
describe('formatter', function()
67
before_each(function()
@@ -91,4 +92,37 @@ describe('formatter', function()
9192
local output = formatter.format_part(part, message, true)
9293
assert.are.equal('** read** `/tmp/project/` 1s', output.lines[1])
9394
end)
95+
96+
it('renders diff line numbers as extmarks', function()
97+
local output = Output.new()
98+
99+
formatter.format_diff(output, table.concat({
100+
'diff --git a/lua/foo.lua b/lua/foo.lua',
101+
'index 1111111..2222222 100644',
102+
'--- a/lua/foo.lua',
103+
'+++ b/lua/foo.lua',
104+
'@@ -10,3 +10,3 @@',
105+
'-alpha',
106+
' gamma',
107+
'+beta',
108+
}, '\n'), 'lua')
109+
110+
assert.are.equal(' alpha', output.lines[3])
111+
assert.are.equal(' gamma', output.lines[4])
112+
assert.are.equal(' beta', output.lines[5])
113+
114+
local delete_mark = output.extmarks[2][1]
115+
assert.are.equal('10 ', delete_mark.virt_text[1][1])
116+
assert.are.equal('-', delete_mark.virt_text[2][1])
117+
assert.are.equal('OpencodeDiffDeleteGutter', delete_mark.virt_text[1][2])
118+
119+
local context_mark = output.extmarks[3][1]
120+
assert.are.equal('10 ', context_mark.virt_text[1][1])
121+
assert.are.equal('OpencodeDiffGutter', context_mark.virt_text[1][2])
122+
123+
local add_mark = output.extmarks[4][1]
124+
assert.are.equal('11 ', add_mark.virt_text[1][1])
125+
assert.are.equal('+', add_mark.virt_text[2][1])
126+
assert.are.equal('OpencodeDiffAddGutter', add_mark.virt_text[1][2])
127+
end)
94128
end)

0 commit comments

Comments
 (0)