-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsplit.lua
More file actions
573 lines (478 loc) · 18.2 KB
/
Copy pathsplit.lua
File metadata and controls
573 lines (478 loc) · 18.2 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
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
-- Enhanced split display for diffusion.nvim diff system
-- Side-by-side comparison with winbar support and improved layout
-- Enhanced with vgit-style word-level highlighting using pure Lua diff engine
--
-- ============================================================================
-- PROVIDER-AGNOSTIC DESIGN - COMPLIANT
-- ============================================================================
--
-- This file appears to be provider-agnostic and does not contain protocol-specific
-- conditionals. This is the correct approach for display modules.
--
-- GOOD PRACTICES OBSERVED:
-- 1. No protocol/provider checks found
-- 2. Display logic driven by diff_entry configuration
-- 3. No hardcoded provider-specific behavior
--
-- RECOMMENDATIONS FOR CONSISTENCY:
-- 1. Ensure all display configuration comes from diff_entry
-- 2. Any future provider-specific display needs should use configuration:
-- diff_entry.display_config = {
-- layout = "split",
-- show_stats = true,
-- highlight_changes = true
-- }
--
-- 3. Keep this file as a reference for how display modules should be structured
--
-- TARGET STATE: This file demonstrates the desired provider-agnostic approach
-- ============================================================================
local SplitDisplay = {}
local DiffStats = require('diffusion.utils.diff_stats')
local DiffEngine = require('diffusion.diff.engine')
-- Module-level caches for performance (avoid per-call allocation)
local _cached_engine = nil
local _cached_logger = nil
local function get_engine()
if not _cached_engine then
_cached_engine = DiffEngine:new()
end
return _cached_engine
end
local function get_logger()
if not _cached_logger then
_cached_logger = require('diffusion.utils.logger'):child("SplitDisplay")
end
return _cached_logger
end
function SplitDisplay:create(diff_entry, events)
local logger = get_logger()
logger:debug("Creating split diff view", {
id = diff_entry.id,
file = diff_entry.file_path
})
-- Create buffers for old and new content
local old_buf = self:_create_content_buffer(diff_entry.old_content, {
name = diff_entry.file_path .. " [Original]",
filetype = self:_detect_filetype(diff_entry.file_path),
readonly = true,
diff_role = "old"
})
if not old_buf then
logger:error("Failed to create old content buffer")
return false
end
local new_buf = self:_create_content_buffer(diff_entry.new_content, {
name = diff_entry.file_path .. " [Proposed]",
filetype = self:_detect_filetype(diff_entry.file_path),
readonly = true,
diff_role = "new"
})
if not new_buf then
logger:error("Failed to create new content buffer")
pcall(vim.api.nvim_buf_delete, old_buf, { force = true })
return false
end
-- Store buffer references
diff_entry.buffers = {
old_bufnr = old_buf,
new_bufnr = new_buf,
left_bufnr = old_buf,
right_bufnr = new_buf
}
-- Create split layout
local success = self:_create_split_layout(diff_entry)
if not success then
self:cleanup(diff_entry)
return false
end
-- Setup diff mode
self:_setup_diff_mode(diff_entry)
-- Apply enhanced highlighting with word-level support
self:_apply_split_highlighting(diff_entry)
-- Setup winbar
self:_setup_winbar(diff_entry)
-- Setup interactive keybindings when provider uses deferred response mode
local behavior = diff_entry.behavior or {}
if behavior.response_mode == "deferred" then
self:_setup_interactive_keybindings(diff_entry, events)
end
logger:info("Split diff view created", {
id = diff_entry.id,
old_buf = old_buf,
new_buf = new_buf
})
return true
end
function SplitDisplay:update(diff_entry, update_params)
local logger = get_logger()
if not diff_entry.buffers then
return false
end
-- Update new content buffer
if update_params.new_content and diff_entry.buffers.new_bufnr then
local success = self:_update_buffer_content(diff_entry.buffers.new_bufnr, update_params.new_content)
if success then
diff_entry.new_content = update_params.new_content
-- Reapply enhanced highlighting after content update
self:_apply_split_highlighting(diff_entry)
logger:debug("Updated split display content with enhanced highlighting", { id = diff_entry.id })
end
return success
end
return false
end
function SplitDisplay:cleanup(diff_entry)
local logger = get_logger()
logger:debug("Cleaning up split display", { id = diff_entry.id })
-- Note: Buffer and window cleanup is now handled by DiffManager:_cleanup_diff_resources
-- This function should only clean up display-specific resources
-- Clear any display-specific state or decorations
if diff_entry.buffers then
for side, buf in pairs(diff_entry.buffers) do
if buf and pcall(vim.api.nvim_buf_is_valid, buf) then
-- Clear any diff-specific highlighting or variables
pcall(vim.api.nvim_buf_clear_namespace, buf, -1, 0, -1)
pcall(function()
vim.api.nvim_buf_del_var(buf, 'diffusion_diff_role')
vim.api.nvim_buf_del_var(buf, 'diffusion_diff_id')
end)
end
end
end
logger:debug("Split display cleanup completed", { id = diff_entry.id })
end
-- Private methods
function SplitDisplay:_create_content_buffer(content, options)
options = options or {}
-- Create new buffer
local bufnr = vim.api.nvim_create_buf(false, true)
if bufnr == 0 then
return nil
end
-- Set buffer name if provided
if options.name then
pcall(vim.api.nvim_buf_set_name, bufnr, options.name)
end
-- Set content
local lines = vim.split(content or "", '\n')
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
-- Set buffer options efficiently using vim.bo
local bo = vim.bo[bufnr]
bo.modifiable = not options.readonly
bo.readonly = options.readonly or false
bo.buftype = 'nofile'
bo.bufhidden = 'wipe'
bo.swapfile = false
if options.filetype then
bo.filetype = options.filetype
end
-- Set diff-specific variables
if options.diff_role then
vim.api.nvim_buf_set_var(bufnr, 'diffusion_diff_role', options.diff_role)
end
return bufnr
end
function SplitDisplay:_create_split_layout(diff_entry)
-- Save current window
local original_win = vim.api.nvim_get_current_win()
local success = pcall(function()
-- Create vertical split
vim.cmd("vsplit")
local left_win = vim.api.nvim_get_current_win()
-- Set up left window (old content)
vim.api.nvim_win_set_buf(left_win, diff_entry.buffers.old_bufnr)
-- Move to right window
vim.cmd("wincmd l")
local right_win = vim.api.nvim_get_current_win()
-- Set up right window (new content)
vim.api.nvim_win_set_buf(right_win, diff_entry.buffers.new_bufnr)
-- Store window references
diff_entry.windows = {
left = left_win,
right = right_win,
left_win = left_win,
right_win = right_win,
old_win = left_win,
new_win = right_win
}
-- The user's original window is whichever of left_win/right_win
-- equals original_win; the other was freshly created by :vsplit.
-- Cannot assume by name — 'splitright' flips which side is new.
diff_entry.windows.user_win = original_win
diff_entry.windows.new_win_created =
(left_win == original_win) and right_win or left_win
-- Configure window options
self:_setup_window_options(left_win, right_win, diff_entry)
end)
return success
end
function SplitDisplay:_setup_diff_mode(diff_entry)
if not diff_entry.windows then
return
end
local function do_setup()
-- Enable diff mode for both windows
if diff_entry.windows.left and vim.api.nvim_win_is_valid(diff_entry.windows.left) then
vim.api.nvim_win_call(diff_entry.windows.left, function()
vim.cmd("diffthis")
end)
end
if diff_entry.windows.right and vim.api.nvim_win_is_valid(diff_entry.windows.right) then
vim.api.nvim_win_call(diff_entry.windows.right, function()
vim.cmd("diffthis")
end)
end
-- Balance windows
vim.cmd("wincmd =")
end
-- Only schedule if in fast event context, otherwise execute immediately
if vim.in_fast_event() then
vim.schedule(do_setup)
else
do_setup()
end
end
function SplitDisplay:_setup_window_options(left_win, right_win, diff_entry)
local old_buf = diff_entry.buffers.old_bufnr
local new_buf = diff_entry.buffers.new_bufnr
-- Only configure the window we freshly created via :vsplit. The other
-- is the user's original — mutating number/relativenumber/signcolumn
-- there permanently taints it (those opts are not diff-managed and
-- aren't restored by :diffoff). :diffthis handles cursorbind/
-- scrollbind/foldcolumn/wrap/foldmethod on both windows with auto-
-- revert on diffoff, so we don't need to set them here.
local target = diff_entry.windows and diff_entry.windows.new_win_created
if target and vim.api.nvim_win_is_valid(target) then
pcall(function()
local wo = vim.wo[target]
wo.number = true
wo.relativenumber = false
wo.signcolumn = 'auto'
wo.foldcolumn = 'auto'
wo.wrap = true
end)
end
-- Set window-specific variables
pcall(vim.api.nvim_win_set_var, left_win, 'diffusion_diff_side', 'left')
pcall(vim.api.nvim_win_set_var, right_win, 'diffusion_diff_side', 'right')
pcall(vim.api.nvim_win_set_var, left_win, 'diffusion_diff_id', diff_entry.id)
pcall(vim.api.nvim_win_set_var, right_win, 'diffusion_diff_id', diff_entry.id)
-- Also set buffer variables for toggle functionality
pcall(vim.api.nvim_buf_set_var, old_buf, 'diffusion_diff_id', diff_entry.id)
pcall(vim.api.nvim_buf_set_var, new_buf, 'diffusion_diff_id', diff_entry.id)
-- Set protocol indicator
if diff_entry.protocol then
local protocol_label = diff_entry.protocol:upper()
pcall(vim.api.nvim_win_set_var, left_win, 'diffusion_protocol', protocol_label)
pcall(vim.api.nvim_win_set_var, right_win, 'diffusion_protocol', protocol_label)
end
end
function SplitDisplay:_setup_winbar(diff_entry)
if not diff_entry.windows then
return
end
local protocol_icon = self:_get_protocol_icon(diff_entry.protocol)
local filename = vim.fn.fnamemodify(diff_entry.file_path, ":t")
-- Calculate diff stats
local stats = DiffStats.calculate_diff_stats(diff_entry)
-- Setup winbar for left window (original)
if diff_entry.windows.left and vim.api.nvim_win_is_valid(diff_entry.windows.left) then
local left_content = string.format(
"%%#%s#%s%%* → %%#Normal#%s [Original]%%*",
self:_get_protocol_highlight(diff_entry.protocol),
protocol_icon,
filename
)
pcall(vim.api.nvim_set_option_value, "winbar", left_content, { win = diff_entry.windows.left })
end
-- Setup winbar for right window (proposed)
if diff_entry.windows.right and vim.api.nvim_win_is_valid(diff_entry.windows.right) then
local right_content = string.format(
"%%#%s#%s%%* → %%#Normal#%s [Proposed]%%* | %%#DiffAdd#+%d%%* %%#DiffDelete#-%d%%* lines",
self:_get_protocol_highlight(diff_entry.protocol),
protocol_icon,
filename,
stats.added,
stats.removed
)
pcall(vim.api.nvim_set_option_value, "winbar", right_content, { win = diff_entry.windows.right })
end
end
function SplitDisplay:_detect_filetype(file_path)
if not file_path then
return 'text'
end
-- Use Neovim's built-in filetype detection
local filetype = vim.filetype.match({ filename = file_path })
return filetype or 'text'
end
function SplitDisplay:_update_buffer_content(bufnr, new_content)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
return false
end
local lines = vim.split(new_content or "", '\n')
vim.schedule(function()
-- Temporarily make buffer modifiable
local was_readonly = vim.bo[bufnr].readonly
local was_modifiable = vim.bo[bufnr].modifiable
vim.bo[bufnr].readonly = false
vim.bo[bufnr].modifiable = true
-- Update content
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
-- Restore original settings
vim.bo[bufnr].readonly = was_readonly
vim.bo[bufnr].modifiable = was_modifiable
end)
return true
end
function SplitDisplay:_get_protocol_icon(protocol)
local icons = {
claude = "🤖",
opencode = "⚡",
codex = "🔧",
unknown = "📝"
}
return icons[protocol] or icons.unknown
end
function SplitDisplay:_get_protocol_highlight(protocol)
local highlights = {
claude = "DiagnosticSignWarn",
opencode = "DiagnosticSignInfo",
codex = "DiagnosticSignHint",
unknown = "Normal"
}
return highlights[protocol] or highlights.unknown
end
-- Apply enhanced highlighting with vgit-style word-level highlighting for split view
function SplitDisplay:_apply_split_highlighting(diff_entry)
-- Use cached engine to avoid per-diff allocation overhead
local diff_engine = get_engine()
-- Check if we already have cached split_diff data from manager
local split_diff = diff_entry.split_diff
if not split_diff then
-- Generate split diff with word-level highlighting (fallback)
split_diff = diff_engine:generate_split(diff_entry.old_content or "", diff_entry.new_content or "")
-- Cache it for potential reuse
diff_entry.split_diff = split_diff
end
-- Setup word-level diff highlight groups
self:_setup_word_diff_highlights()
local left_ns_id = vim.api.nvim_create_namespace("diffusion_split_left_diff")
local right_ns_id = vim.api.nvim_create_namespace("diffusion_split_right_diff")
local old_buf = diff_entry.buffers.old_bufnr
local new_buf = diff_entry.buffers.new_bufnr
-- Clear existing highlighting
vim.api.nvim_buf_clear_namespace(old_buf, left_ns_id, 0, -1)
vim.api.nvim_buf_clear_namespace(new_buf, right_ns_id, 0, -1)
-- Apply word-level highlighting to both buffers
for _, lnum_change in ipairs(split_diff.lnum_changes or {}) do
local line_num = lnum_change.lnum
local change_type = lnum_change.type
local buftype = lnum_change.buftype
local target_buf = (buftype == 'previous') and old_buf or new_buf
local target_ns = (buftype == 'previous') and left_ns_id or right_ns_id
if change_type == 'add' or change_type == 'remove' then
-- Apply line-level highlighting first
local line_hl = (change_type == 'add') and 'DiffAdd' or 'DiffDelete'
local line_idx = line_num - 1 -- Convert to 0-indexed
vim.api.nvim_buf_set_extmark(target_buf, target_ns, line_idx, 0, {
line_hl_group = line_hl,
})
-- Apply word-level highlighting if available
if lnum_change.word_diff then
self:_apply_word_diff_highlighting_split(target_buf, target_ns, line_idx, lnum_change.word_diff, change_type)
end
elseif change_type == 'void' then
-- Handle void lines (empty space on one side)
local line_idx = line_num - 1 -- Convert to 0-indexed
vim.api.nvim_buf_set_extmark(target_buf, target_ns, line_idx, 0, {
line_hl_group = 'DiffText', -- Subtle highlight for void lines
})
end
end
end
-- Setup word-level diff highlight groups (vgit-style) for split view
function SplitDisplay:_setup_word_diff_highlights()
-- Set up GitWordAdd and GitWordDelete highlight groups like vgit
vim.api.nvim_set_hl(0, 'GitWordAdd', {
fg = nil,
bg = '#5f875f', -- Green background for added words
bold = true
})
vim.api.nvim_set_hl(0, 'GitWordDelete', {
fg = nil,
bg = '#875f5f', -- Red background for deleted words
bold = true
})
end
-- Apply word-level highlighting to split view buffers
function SplitDisplay:_apply_word_diff_highlighting_split(bufnr, ns_id, line_idx, word_diff, change_type)
local col = 0
for _, segment in ipairs(word_diff) do
local operation, fragment = segment[1], segment[2]
if operation == -1 then
-- Deleted word (highlighted in red)
vim.api.nvim_buf_set_extmark(bufnr, ns_id, line_idx, col, {
end_col = col + #fragment,
hl_group = 'GitWordDelete',
})
elseif operation == 1 then
-- Added word (highlighted in green)
vim.api.nvim_buf_set_extmark(bufnr, ns_id, line_idx, col, {
end_col = col + #fragment,
hl_group = 'GitWordAdd',
})
end
-- operation == 0 is unchanged text, no highlighting needed
col = col + #fragment
end
end
-- Deferred-response providers (e.g. Claude) need a way for the user to
-- accept/reject from inside Neovim. Bind accept/reject on both the old
-- and new buffers so cursor location does not matter. The accepted
-- content is the authoritative diff_entry.new_content — never the
-- buffer contents — so split and unified modes behave identically.
function SplitDisplay:_setup_interactive_keybindings(diff_entry, events)
local config = require('diffusion.config'):get()
local accept_key = (config.keymaps and config.keymaps.accept_diff) or '<CR>'
local reject_key = (config.keymaps and config.keymaps.reject_diff) or 'r'
local targets = {}
if diff_entry.buffers then
if diff_entry.buffers.new_bufnr then table.insert(targets, diff_entry.buffers.new_bufnr) end
if diff_entry.buffers.old_bufnr then table.insert(targets, diff_entry.buffers.old_bufnr) end
end
local function emit(response)
if events and not diff_entry.responded then
diff_entry.responded = true
events:emit("diff:user_response", {
diff_id = diff_entry.id,
response = response,
protocol = diff_entry.protocol,
buffer_content = response == "accepted" and diff_entry.new_content or nil,
})
end
end
for _, bufnr in ipairs(targets) do
if vim.api.nvim_buf_is_valid(bufnr) then
vim.api.nvim_buf_set_keymap(bufnr, 'n', accept_key, '', {
noremap = true, silent = true,
callback = function() emit("accepted") end,
})
vim.api.nvim_buf_set_keymap(bufnr, 'n', reject_key, '', {
noremap = true, silent = true,
callback = function() emit("rejected") end,
})
-- :w on either side accepts, matches unified mode behavior
vim.api.nvim_create_autocmd("BufWriteCmd", {
buffer = bufnr,
callback = function()
emit("accepted")
return true
end,
})
end
end
end
return SplitDisplay -- Test comment to demonstrate vgit-style word-level highlighting
-- Additional test comment to show enhanced diff capabilities