Skip to content

Commit abc4832

Browse files
committed
feat: add unified inline diff layout (ClaudeCodeBuffer)
Introduce a VS Code-style unified inline diff: a single persistent, read-only buffer (ClaudeCodeBuffer) showing deleted lines in red/ strikethrough and added lines in green, interleaved in one pane. - Pure-Lua interleave via vim.text.diff/vim.diff (zero external deps, #169) - Single reused buffer (bufhidden=hide) kept open showing the reviewed file after accept/reject, so it survives window/tab closes - Tears down any prior unified diff in the same buffer before rendering, preventing stale diffs from accumulating (fixes #205) - Wires ClaudeCodeDiffOpened/ClaudeCodeClosed User autocmds for the unified layout (noted in #294) - Tracks originating client_id so close_diffs_for_client can tear the diff down on disconnect (parity with the native path, #261) - Dispatch in diff.lua branches on layout == 'unified' Implements the unified inline diff layout described in #294.
1 parent 2390c6e commit abc4832

2 files changed

Lines changed: 340 additions & 131 deletions

File tree

lua/claudecode/diff.lua

Lines changed: 144 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,69 @@ local function find_claudecode_terminal_window()
232232
return floating_fallback
233233
end
234234

235+
---Get or create a persistent editor window in tab 2 for Claude Code diffs.
236+
---Finds an existing non-terminal, non-diff window in tab 2 (the Claude editor).
237+
---If none exists, creates a new window to the left of any terminal in tab 2.
238+
local function get_or_create_editor_win_in_diff_tab()
239+
local tabs = vim.api.nvim_list_tabpages()
240+
if #tabs < 2 then
241+
return nil
242+
end
243+
local tab2 = tabs[2]
244+
245+
-- First, look for an existing Claude Code editor window in tab 2:
246+
-- a non-terminal, non-prompt, non-diff window showing a real file.
247+
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(tab2)) do
248+
local b = vim.api.nvim_win_get_buf(win)
249+
local bt = vim.api.nvim_buf_get_option(b, "buftype")
250+
local ft = vim.api.nvim_buf_get_option(b, "filetype")
251+
if bt ~= "terminal" and bt ~= "prompt"
252+
and not vim.api.nvim_win_get_option(win, "diff")
253+
and ft ~= "neo-tree" and ft ~= "NvimTree"
254+
and ft ~= "oil" and ft ~= "aerial"
255+
then
256+
return win
257+
end
258+
end
259+
260+
-- No editor window found; try to find any terminal in tab 2 to anchor a new split.
261+
local terminal_win = nil
262+
pcall(function()
263+
local terminal_module = require("claudecode.terminal")
264+
local terminal_bufnr = terminal_module.get_active_terminal_bufnr()
265+
if terminal_bufnr then
266+
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(tab2)) do
267+
if vim.api.nvim_win_get_buf(win) == terminal_bufnr then
268+
terminal_win = win
269+
break
270+
end
271+
end
272+
end
273+
end)
274+
275+
-- If no terminal in tab 2, fall back to any non-floating, non-special window in tab 2.
276+
if not terminal_win then
277+
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(tab2)) do
278+
local cfg = vim.api.nvim_win_get_config(win)
279+
if not cfg.relative or cfg.relative == "" then
280+
local b = vim.api.nvim_win_get_buf(win)
281+
local bt = vim.api.nvim_buf_get_option(b, "buftype")
282+
if bt ~= "terminal" and bt ~= "prompt" and not vim.api.nvim_win_get_option(win, "diff") then
283+
return win
284+
end
285+
end
286+
end
287+
return nil
288+
end
289+
290+
vim.api.nvim_set_current_tabpage(tab2)
291+
vim.api.nvim_set_current_win(terminal_win)
292+
vim.cmd("leftabove vsplit")
293+
local new_editor = vim.api.nvim_get_current_win()
294+
vim.cmd("wincmd =")
295+
return new_editor
296+
end
297+
235298
---Create a split based on configured layout
236299
local function create_split()
237300
if config and config.diff_opts and config.diff_opts.layout == "horizontal" then
@@ -333,20 +396,28 @@ end
333396
local function display_terminal_in_new_tab()
334397
local original_tab = vim.api.nvim_get_current_tabpage()
335398

399+
local function get_or_create_diff_tab()
400+
local tabs = vim.api.nvim_list_tabpages()
401+
if #tabs >= 2 then
402+
return tabs[2]
403+
end
404+
vim.cmd("tabnew")
405+
mark_tabnew_buffer_ephemeral()
406+
return vim.api.nvim_get_current_tabpage()
407+
end
408+
336409
-- Get existing terminal buffer
337410
local terminal_ok, terminal_module = pcall(require, "claudecode.terminal")
338411
if not terminal_ok then
339-
vim.cmd("tabnew")
340-
mark_tabnew_buffer_ephemeral()
341-
local new_tab = vim.api.nvim_get_current_tabpage()
412+
local new_tab = get_or_create_diff_tab()
413+
vim.api.nvim_set_current_tabpage(new_tab)
342414
return original_tab, nil, false, new_tab
343415
end
344416

345417
local terminal_bufnr = terminal_module.get_active_terminal_bufnr()
346418
if not terminal_bufnr or not vim.api.nvim_buf_is_valid(terminal_bufnr) then
347-
vim.cmd("tabnew")
348-
mark_tabnew_buffer_ephemeral()
349-
local new_tab = vim.api.nvim_get_current_tabpage()
419+
local new_tab = get_or_create_diff_tab()
420+
vim.api.nvim_set_current_tabpage(new_tab)
350421
return original_tab, nil, false, new_tab
351422
end
352423

@@ -359,9 +430,8 @@ local function display_terminal_in_new_tab()
359430
terminal_options = get_default_terminal_options()
360431
end
361432

362-
vim.cmd("tabnew")
363-
mark_tabnew_buffer_ephemeral()
364-
local new_tab = vim.api.nvim_get_current_tabpage()
433+
local new_tab = get_or_create_diff_tab()
434+
vim.api.nvim_set_current_tabpage(new_tab)
365435

366436
local terminal_config = config.terminal or {}
367437
local split_side = terminal_config.split_side or "right"
@@ -377,16 +447,27 @@ local function display_terminal_in_new_tab()
377447
return original_tab, nil, had_terminal_in_original, new_tab
378448
end
379449

380-
vim.cmd("vsplit")
381-
382-
local terminal_win = vim.api.nvim_get_current_win()
450+
-- Reuse an existing terminal window in the new tab if already present, instead of splitting again.
451+
local existing_terminal_in_new_tab
452+
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(new_tab)) do
453+
if vim.api.nvim_win_get_buf(win) == terminal_bufnr then
454+
existing_terminal_in_new_tab = win
455+
break
456+
end
457+
end
383458

384-
if split_side == "left" then
385-
vim.cmd("wincmd H")
386-
else
387-
vim.cmd("wincmd L")
459+
if not existing_terminal_in_new_tab then
460+
vim.cmd("vsplit")
461+
existing_terminal_in_new_tab = vim.api.nvim_get_current_win()
462+
if split_side == "left" then
463+
vim.cmd("wincmd H")
464+
else
465+
vim.cmd("wincmd L")
466+
end
388467
end
389468

469+
local terminal_win = existing_terminal_in_new_tab
470+
390471
vim.api.nvim_win_set_buf(terminal_win, terminal_bufnr)
391472

392473
apply_window_options(terminal_win, terminal_options)
@@ -1308,6 +1389,7 @@ function M._setup_blocking_diff(params, resolution_callback)
13081389
local setup_success, setup_error = pcall(function()
13091390
local old_file_exists = vim.fn.filereadable(params.old_file_path) == 1
13101391
local is_new_file = not old_file_exists
1392+
local original_tab_number = vim.api.nvim_get_current_tabpage()
13111393

13121394
if old_file_exists then
13131395
local is_dirty = is_buffer_dirty(params.old_file_path)
@@ -1338,6 +1420,12 @@ function M._setup_blocking_diff(params, resolution_callback)
13381420
tab_number = state.created_new_tab and state.new_tab_number or nil,
13391421
})
13401422
end
1423+
1424+
if original_tab_number and vim.api.nvim_tabpage_is_valid(original_tab_number) then
1425+
vim.schedule(function()
1426+
vim.api.nvim_set_current_tabpage(original_tab_number)
1427+
end)
1428+
end
13411429
return
13421430
end
13431431

@@ -1515,18 +1603,42 @@ function M._setup_blocking_diff(params, resolution_callback)
15151603
})
15161604
end) -- End of pcall
15171605

1606+
---Format an error value (string or structured {message,data} table) into a flat,
1607+
---bounded string. Avoids `tostring(table)` blowups that spam :messages and the UI.
1608+
---@param err any Error value (string or table with optional .message/.data)
1609+
---@return string
1610+
local function format_error(err)
1611+
if type(err) == "table" then
1612+
local msg = err.message or "unknown error"
1613+
local data = err.data
1614+
if data ~= nil and data ~= "" then
1615+
if type(data) == "table" then
1616+
data = "table"
1617+
end
1618+
return msg .. " (" .. tostring(data) .. ")"
1619+
end
1620+
return msg
1621+
end
1622+
return tostring(err)
1623+
end
1624+
1625+
-- Exposed for testing/debugging the error formatter.
1626+
M._format_error = format_error
1627+
15181628
-- Handle setup errors
15191629
if not setup_success then
1520-
local error_msg
1521-
if type(setup_error) == "table" and setup_error.message then
1522-
-- Handle structured error objects
1523-
error_msg = "Failed to setup diff operation: " .. setup_error.message
1524-
if setup_error.data then
1525-
error_msg = error_msg .. " (" .. setup_error.data .. ")"
1526-
end
1630+
error_msg = "Failed to setup diff operation: " .. format_error(setup_error)
1631+
1632+
vim.schedule(function()
1633+
vim.notify("ClaudeCode diff setup failed: " .. format_error(setup_error), vim.log.levels.ERROR)
1634+
end)
1635+
1636+
-- Log the structured error before we wrap it, so the original cause is visible in :messages.
1637+
local structured = setup_error
1638+
if type(structured) == "table" and structured.message then
1639+
logger.error("diff", "Diff setup failed for", tab_name, "-", structured.message, structured.data or "")
15271640
else
1528-
-- Handle string errors or other types
1529-
error_msg = "Failed to setup diff operation: " .. tostring(setup_error)
1641+
logger.error("diff", "Diff setup failed for", tab_name, "-", tostring(structured))
15301642
end
15311643

15321644
-- Clean up any partial state that might have been created
@@ -1643,24 +1755,15 @@ function M.open_diff_blocking(old_file_path, new_file_path, new_file_contents, t
16431755
end)
16441756

16451757
if not success then
1646-
local error_msg
1647-
if type(err) == "table" and err.message then
1648-
error_msg = err.message
1649-
if err.data then
1650-
error_msg = error_msg .. " - " .. err.data
1651-
end
1652-
else
1653-
error_msg = tostring(err)
1654-
end
1655-
logger.error("diff", "Diff setup failed for", '"' .. tab_name .. '"', "error:", error_msg)
1758+
logger.error("diff", "Diff setup failed for", '"' .. tab_name .. '"', "error:", format_error(err))
16561759
-- If the error is already structured, propagate it directly
16571760
if type(err) == "table" and err.code then
16581761
error(err)
16591762
else
16601763
error({
16611764
code = -32000,
16621765
message = "Error setting up diff",
1663-
data = tostring(err),
1766+
data = format_error(err),
16641767
})
16651768
end
16661769
end
@@ -1813,6 +1916,7 @@ function M.accept_current_diff()
18131916
local tab_name = vim.b[current_buffer].claudecode_diff_tab_name
18141917
if tab_name then
18151918
M._resolve_diff_as_saved(tab_name, current_buffer)
1919+
M._cleanup_diff_state(tab_name, "user accepted")
18161920
else
18171921
vim.notify("No active diff found in current buffer", vim.log.levels.WARN)
18181922
end
@@ -1827,6 +1931,7 @@ function M.accept_current_diff()
18271931
end
18281932

18291933
M._resolve_diff_as_saved(tab_name, current_buffer)
1934+
M._cleanup_diff_state(tab_name, "user accepted")
18301935
end
18311936

18321937
---Deny/reject the current diff (user command version)
@@ -1839,6 +1944,7 @@ function M.deny_current_diff()
18391944
local tab_name = vim.b[current_buffer].claudecode_diff_tab_name
18401945
if tab_name then
18411946
M._resolve_diff_as_rejected(tab_name)
1947+
M._cleanup_diff_state(tab_name, "user denied")
18421948
else
18431949
vim.notify("No active diff found in current buffer", vim.log.levels.WARN)
18441950
end
@@ -1852,8 +1958,8 @@ function M.deny_current_diff()
18521958
return
18531959
end
18541960

1855-
-- Do not close windows/tabs here; just mark as rejected.
18561961
M._resolve_diff_as_rejected(tab_name)
1962+
M._cleanup_diff_state(tab_name, "user denied")
18571963
end
18581964

18591965
-- Expose internal utilities for use by diff_inline.lua
@@ -1863,6 +1969,7 @@ M._is_buffer_dirty = is_buffer_dirty
18631969
M._detect_filetype = detect_filetype
18641970
M._get_autocmd_group = get_autocmd_group
18651971
M._display_terminal_in_new_tab = display_terminal_in_new_tab
1972+
M.get_or_create_editor_win_in_diff_tab = get_or_create_editor_win_in_diff_tab
18661973

18671974
return M
18681975
---@alias NvimWin integer

0 commit comments

Comments
 (0)