-
Notifications
You must be signed in to change notification settings - Fork 200
Expand file tree
/
Copy pathdiff_inline.lua
More file actions
479 lines (411 loc) Β· 16.7 KB
/
Copy pathdiff_inline.lua
File metadata and controls
479 lines (411 loc) Β· 16.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
--- Inline diff module for Claude Code Neovim integration.
-- Provides a VS Code-style unified inline diff view with deleted (red/strikethrough)
-- and added (green) lines interleaved in a single read-only buffer.
local M = {}
local logger = require("claudecode.logger")
local ns = vim.api.nvim_create_namespace("claudecode_inline_diff")
--- Resolve the diff function. Neovim 0.12 renamed `vim.diff` to `vim.text.diff`
--- (keeping `vim.diff` as a deprecated alias); prefer the new name and fall back to
--- the old one so inline diff works across the supported range and stays forward-compatible.
---@return function|nil
local function get_diff_fn()
return (vim.text and vim.text.diff) or vim.diff
end
-- ββ Highlight groups ββββββββββββββββββββββββββββββββββββββββββββββ
local function setup_highlights()
vim.api.nvim_set_hl(0, "ClaudeCodeInlineDiffAdd", { bg = "#2a4a2a", default = true })
vim.api.nvim_set_hl(0, "ClaudeCodeInlineDiffDelete", { bg = "#4a2a2a", strikethrough = true, default = true })
vim.api.nvim_set_hl(0, "ClaudeCodeInlineDiffAddSign", { fg = "#98c379", default = true })
vim.api.nvim_set_hl(0, "ClaudeCodeInlineDiffDeleteSign", { fg = "#e06c75", default = true })
end
-- ββ Pure functions (testable in isolation) ββββββββββββββββββββββββ
--- Split a string into lines, removing a trailing empty line from a final newline.
---@param text string
---@return string[]
local function split_lines(text)
if text == "" then
return {}
end
local lines = vim.split(text, "\n", { plain = true })
if #lines > 0 and lines[#lines] == "" then
table.remove(lines)
end
return lines
end
--- Compute an interleaved inline diff from two strings.
--- Returns parallel arrays: lines[] (buffer content) and line_types[] ("unchanged"|"added"|"deleted").
---@param old_text string Original file content
---@param new_text string Proposed file content
---@return string[] lines Buffer lines for display
---@return string[] line_types Parallel array of "unchanged"|"added"|"deleted"
function M.compute_inline_diff(old_text, new_text)
old_text = old_text or ""
new_text = new_text or ""
local hunks = get_diff_fn()(old_text, new_text, { result_type = "indices" }) or {}
local old_lines = split_lines(old_text)
local new_lines = split_lines(new_text)
local result_lines = {}
local result_types = {}
local old_pos = 1
local new_pos = 1
for _, hunk in ipairs(hunks) do
local start_a, count_a, _, count_b = hunk[1], hunk[2], hunk[3], hunk[4]
-- Unchanged lines before this hunk
local unchanged_count
if count_a > 0 then
unchanged_count = start_a - old_pos
else
-- Pure insertion: start_a is the last unchanged line before the insertion
unchanged_count = start_a - old_pos + 1
end
for _ = 1, unchanged_count do
result_lines[#result_lines + 1] = new_lines[new_pos]
result_types[#result_types + 1] = "unchanged"
old_pos = old_pos + 1
new_pos = new_pos + 1
end
-- Deleted lines from old
for _ = 1, count_a do
result_lines[#result_lines + 1] = old_lines[old_pos]
result_types[#result_types + 1] = "deleted"
old_pos = old_pos + 1
end
-- Added lines from new
for _ = 1, count_b do
result_lines[#result_lines + 1] = new_lines[new_pos]
result_types[#result_types + 1] = "added"
new_pos = new_pos + 1
end
end
-- Remaining unchanged lines after the last hunk
while new_pos <= #new_lines do
result_lines[#result_lines + 1] = new_lines[new_pos]
result_types[#result_types + 1] = "unchanged"
new_pos = new_pos + 1
end
return result_lines, result_types
end
--- Collect only "unchanged" + "added" lines (the accepted new content).
---@param lines string[] Buffer lines
---@param line_types string[] Parallel type array
---@return string content The accepted content joined with newlines
function M.extract_new_content(lines, line_types)
local out = {}
for i, lt in ipairs(line_types) do
if lt ~= "deleted" then
out[#out + 1] = lines[i]
end
end
return table.concat(out, "\n")
end
--- Apply line highlights and sign-column markers via extmarks.
---@param buf number Buffer handle
---@param lines string[] Lines to set
---@param line_types string[] Parallel type array
function M.render_diff_buffer(buf, lines, line_types)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
for i, lt in ipairs(line_types) do
if lt == "added" then
vim.api.nvim_buf_set_extmark(buf, ns, i - 1, 0, {
line_hl_group = "ClaudeCodeInlineDiffAdd",
sign_text = "+",
sign_hl_group = "ClaudeCodeInlineDiffAddSign",
})
elseif lt == "deleted" then
vim.api.nvim_buf_set_extmark(buf, ns, i - 1, 0, {
line_hl_group = "ClaudeCodeInlineDiffDelete",
sign_text = "-",
sign_hl_group = "ClaudeCodeInlineDiffDeleteSign",
})
end
end
end
-- ββ Setup βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
--- Set up an inline diff view for the given parameters.
---@param params table Diff parameters (old_file_path, new_file_path, new_file_contents, tab_name)
---@param resolution_callback function Callback to call when diff resolves
---@param config table Plugin configuration
function M.setup_inline_diff(params, resolution_callback, config)
local diff = require("claudecode.diff")
-- Version check: the diff function (vim.text.diff / vim.diff) requires Neovim >= 0.9.0
if not get_diff_fn() then
error({
code = -32000,
message = "Inline diff requires Neovim >= 0.9.0",
data = "vim.text.diff()/vim.diff() is not available. Please use layout = 'vertical' or 'horizontal', or upgrade Neovim.",
})
end
setup_highlights()
local tab_name = params.tab_name
local old_file_exists = vim.fn.filereadable(params.old_file_path) == 1
local is_new_file = not old_file_exists
-- Dirty buffer check
if old_file_exists then
local is_dirty = diff._is_buffer_dirty(params.old_file_path)
if is_dirty then
error({
code = -32000,
message = "Cannot create diff: file has unsaved changes",
data = "Please save (:w) or discard (:e!) changes to " .. params.old_file_path .. " before creating diff",
})
end
end
-- Read old file content
local old_text = ""
if not is_new_file then
local f = io.open(params.old_file_path, "r")
if f then
old_text = f:read("*a") or ""
f:close()
end
end
-- Compute diff
local lines, line_types = M.compute_inline_diff(old_text, params.new_file_contents)
-- Create scratch buffer
local buf = vim.api.nvim_create_buf(false, true)
if buf == 0 then
error({ code = -32000, message = "Buffer creation failed", data = "Could not create inline diff buffer" })
end
pcall(vim.api.nvim_buf_set_name, buf, tab_name .. " (unified diff)")
vim.api.nvim_buf_set_option(buf, "buftype", "acwrite")
vim.api.nvim_buf_set_option(buf, "bufhidden", "wipe")
-- Render content + highlights
M.render_diff_buffer(buf, lines, line_types)
vim.api.nvim_buf_set_option(buf, "modifiable", false)
-- Buffer metadata
vim.b[buf].claudecode_diff_tab_name = tab_name
vim.b[buf].claudecode_inline_diff = true
-- Syntax highlighting via filetype
local ft = diff._detect_filetype(params.new_file_path)
if ft and ft ~= "" then
vim.api.nvim_set_option_value("filetype", ft, { buf = buf })
end
-- Handle new-tab mode
local original_tab_number = vim.api.nvim_get_current_tabpage()
local created_new_tab = false
local terminal_win_in_new_tab = nil
local new_tab_handle = nil
local had_terminal_in_original = false
if config and config.diff_opts and config.diff_opts.open_in_new_tab then
original_tab_number, terminal_win_in_new_tab, had_terminal_in_original, new_tab_handle =
diff._display_terminal_in_new_tab()
created_new_tab = true
end
-- Save terminal window width so we can restore it after the diff closes
local term_win = diff._find_claudecode_terminal_window()
local term_width = nil
if term_win and vim.api.nvim_win_is_valid(term_win) then
term_width = vim.api.nvim_win_get_width(term_win)
end
-- Open a vsplit for the inline diff buffer
-- When in a new tab, use a window from the current tab rather than the global
-- search which could return a window from the original tab
local editor_win
if created_new_tab then
local tab_wins = vim.api.nvim_tabpage_list_wins(0)
for _, w in ipairs(tab_wins) do
if w ~= terminal_win_in_new_tab then
editor_win = w
break
end
end
-- Fallback to first window in the new tab
if not editor_win and #tab_wins > 0 then
editor_win = tab_wins[1]
end
else
editor_win = diff._find_main_editor_window()
end
if editor_win then
vim.api.nvim_set_current_win(editor_win)
end
vim.cmd("rightbelow vsplit")
local diff_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(diff_win, buf)
-- Configure window for sign column display
pcall(vim.api.nvim_set_option_value, "signcolumn", "yes", { win = diff_win })
-- Equalize window widths
vim.cmd("wincmd =")
-- Scroll to first change
for i, lt in ipairs(line_types) do
if lt ~= "unchanged" then
pcall(vim.api.nvim_win_set_cursor, diff_win, { i, 0 })
break
end
end
-- Handle terminal focus
if config and config.diff_opts and config.diff_opts.keep_terminal_focus then
vim.schedule(function()
if terminal_win_in_new_tab and vim.api.nvim_win_is_valid(terminal_win_in_new_tab) then
vim.api.nvim_set_current_win(terminal_win_in_new_tab)
vim.cmd("startinsert")
return
end
local terminal_win = diff._find_claudecode_terminal_window()
if terminal_win then
vim.api.nvim_set_current_win(terminal_win)
vim.cmd("startinsert")
end
end)
end
-- Restore terminal width after opening the split
if terminal_win_in_new_tab and vim.api.nvim_win_is_valid(terminal_win_in_new_tab) then
local terminal_config = config.terminal or {}
local split_width = terminal_config.split_width_percentage or 0.30
local total_width = vim.o.columns
local terminal_width = math.floor(total_width * split_width)
vim.api.nvim_win_set_width(terminal_win_in_new_tab, terminal_width)
elseif term_win and vim.api.nvim_win_is_valid(term_win) then
local win_config = vim.api.nvim_win_get_config(term_win)
local is_floating = win_config.relative and win_config.relative ~= ""
if not is_floating and term_width then
pcall(vim.api.nvim_win_set_width, term_win, term_width)
end
end
-- Register autocmds
local aug = diff._get_autocmd_group()
local autocmd_ids = {}
autocmd_ids[#autocmd_ids + 1] = vim.api.nvim_create_autocmd("BufWriteCmd", {
group = aug,
buffer = buf,
callback = function()
diff._resolve_diff_as_saved(tab_name, buf)
return true -- prevent actual write
end,
})
for _, ev in ipairs({ "BufDelete", "BufUnload", "BufWipeout" }) do
autocmd_ids[#autocmd_ids + 1] = vim.api.nvim_create_autocmd(ev, {
group = aug,
buffer = buf,
callback = function()
diff._resolve_diff_as_rejected(tab_name)
end,
})
end
-- Register state with layout = "unified"
diff._register_diff_state(tab_name, {
old_file_path = params.old_file_path,
new_file_path = params.new_file_path,
new_file_contents = params.new_file_contents,
new_buffer = buf,
new_window = diff_win,
lines = lines,
line_types = line_types,
is_new_file = is_new_file,
autocmd_ids = autocmd_ids,
created_at = vim.fn.localtime(),
status = "pending",
resolution_callback = resolution_callback,
result_content = nil,
layout = "unified",
-- Track the originating MCP client so close_diffs_for_client can tear this
-- diff down if that client disconnects (parity with the native path, #261).
client_id = params.client_id,
-- Tab/window tracking
original_tab_number = original_tab_number,
created_new_tab = created_new_tab,
new_tab_number = new_tab_handle,
had_terminal_in_original = had_terminal_in_original,
terminal_win_in_new_tab = terminal_win_in_new_tab,
term_win = term_win,
term_width = term_width,
})
end
-- ββ Resolution functions ββββββββββββββββββββββββββββββββββββββββββ
--- Resolve an inline diff as saved (user accepted changes).
---@param tab_name string The diff identifier
---@param diff_data table The diff state data
function M.resolve_inline_as_saved(tab_name, diff_data)
logger.debug("diff", "Accepting inline diff for", tab_name)
local content = M.extract_new_content(diff_data.lines, diff_data.line_types)
-- Preserve trailing newline when original new_file_contents had one
if diff_data.new_file_contents:sub(-1) == "\n" and content:sub(-1) ~= "\n" then
content = content .. "\n"
end
local result = {
content = {
{ type = "text", text = "FILE_SAVED" },
{ type = "text", text = content },
},
}
diff_data.status = "saved"
diff_data.result_content = result
if diff_data.resolution_callback then
diff_data.resolution_callback(result)
else
logger.debug("diff", "No resolution callback found for saved inline diff", tab_name)
end
logger.debug("diff", "Inline diff saved; awaiting close_tab for cleanup")
end
--- Resolve an inline diff as rejected (user closed/rejected).
---@param tab_name string The diff identifier
---@param diff_data table The diff state data
function M.resolve_inline_as_rejected(tab_name, diff_data)
local result = {
content = {
{ type = "text", text = "DIFF_REJECTED" },
{ type = "text", text = tab_name },
},
}
diff_data.status = "rejected"
diff_data.result_content = result
if diff_data.resolution_callback then
diff_data.resolution_callback(result)
end
end
-- ββ Cleanup βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
--- Clean up an inline diff's state and UI.
---@param tab_name string The diff identifier
---@param diff_data table The diff state data
function M.cleanup_inline_diff(tab_name, diff_data)
local diff = require("claudecode.diff")
-- Clean up autocmds
for _, autocmd_id in ipairs(diff_data.autocmd_ids or {}) do
pcall(vim.api.nvim_del_autocmd, autocmd_id)
end
-- Handle new-tab cleanup
if diff_data.created_new_tab then
if diff_data.original_tab_number and vim.api.nvim_tabpage_is_valid(diff_data.original_tab_number) then
pcall(vim.api.nvim_set_current_tabpage, diff_data.original_tab_number)
end
if diff_data.new_tab_number and vim.api.nvim_tabpage_is_valid(diff_data.new_tab_number) then
pcall(vim.api.nvim_set_current_tabpage, diff_data.new_tab_number)
pcall(vim.cmd, "tabclose")
if diff_data.original_tab_number and vim.api.nvim_tabpage_is_valid(diff_data.original_tab_number) then
pcall(vim.api.nvim_set_current_tabpage, diff_data.original_tab_number)
end
end
-- Ensure terminal remains visible in the original tab
local terminal_ok, terminal_module = pcall(require, "claudecode.terminal")
if terminal_ok and diff_data.had_terminal_in_original then
pcall(terminal_module.ensure_visible)
local terminal_win = diff._find_claudecode_terminal_window()
if terminal_win and vim.api.nvim_win_is_valid(terminal_win) then
local win_config = vim.api.nvim_win_get_config(terminal_win)
local is_floating = win_config.relative and win_config.relative ~= ""
if not is_floating and diff_data.term_width then
pcall(vim.api.nvim_win_set_width, terminal_win, diff_data.term_width)
end
end
end
else
-- Close the diff split window
if diff_data.new_window and vim.api.nvim_win_is_valid(diff_data.new_window) then
pcall(vim.api.nvim_win_close, diff_data.new_window, true)
end
-- Restore terminal width
if diff_data.term_win and vim.api.nvim_win_is_valid(diff_data.term_win) then
local win_config = vim.api.nvim_win_get_config(diff_data.term_win)
local is_floating = win_config.relative and win_config.relative ~= ""
if not is_floating and diff_data.term_width then
pcall(vim.api.nvim_win_set_width, diff_data.term_win, diff_data.term_width)
end
end
end
-- Buffer might already be wiped by bufhidden=wipe when its window closed
if diff_data.new_buffer and vim.api.nvim_buf_is_valid(diff_data.new_buffer) then
pcall(vim.api.nvim_buf_delete, diff_data.new_buffer, { force = true })
end
logger.debug("diff", "Cleaned up inline diff for", tab_name)
end
return M