feat: add unified inline diff layout (ClaudeCodeBuffer)#300
Conversation
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, coder#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 coder#205) - Wires ClaudeCodeDiffOpened/ClaudeCodeClosed User autocmds for the unified layout (noted in coder#294) - Tracks originating client_id so close_diffs_for_client can tear the diff down on disconnect (parity with the native path, coder#261) - Dispatch in diff.lua branches on layout == 'unified' Implements the unified inline diff layout described in coder#294.
There was a problem hiding this comment.
Pull request overview
Adds a new layout = "unified" diff mode that renders a VS Code–style unified inline diff into a single persistent ClaudeCodeBuffer, reusing/refreshing the same buffer between suggestions and integrating this mode into the diff lifecycle/cleanup paths.
Changes:
- Introduces a persistent unified diff buffer (
ClaudeCodeBuffer) with interleaved add/delete highlighting and stale-diff replacement logic. - Adds tab/window management helpers to better reuse a “diff tab” and avoid repeated splits in that tab.
- Improves diff setup error handling/logging by formatting structured errors (and exposes the formatter for tests/debugging).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| lua/claudecode/diff.lua | Adds unified-layout dispatch, diff-tab helpers, accept/deny cleanup calls, and error formatting changes. |
| lua/claudecode/diff_inline.lua | Implements the unified inline diff renderer around a persistent ClaudeCodeBuffer and updates setup/cleanup behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if setup_error.data then | ||
| error_msg = error_msg .. " (" .. setup_error.data .. ")" | ||
| end | ||
| error_msg = "Failed to setup diff operation: " .. format_error(setup_error) |
There was a problem hiding this comment.
Good catch — fixed. error_msg is now declared local in the setup error handler (the other call sites already used local), so it no longer leaks as a global or gets clobbered across concurrent/scheduled diff attempts.
| error_msg = tostring(err) | ||
| end | ||
| logger.error("diff", "Diff setup failed for", '"' .. tab_name .. '"', "error:", error_msg) | ||
| logger.error("diff", "Diff setup failed for", '"' .. tab_name .. '"', "error:", format_error(err)) |
There was a problem hiding this comment.
Fixed. format_error was wrongly nested inside _setup_blocking_diff; I hoisted it to module scope (and moved M._format_error to module scope too), so open_diff_blocking's error path can call it without raising a secondary error.
| else | ||
| error({ | ||
| code = -32000, | ||
| message = "Error setting up diff", | ||
| data = tostring(err), | ||
| data = format_error(err), | ||
| }) |
There was a problem hiding this comment.
Same root cause as the previous one — fixed by hoisting format_error to module scope. This call now resolves correctly and error propagation works.
| local function get_or_create_diff_tab() | ||
| local tabs = vim.api.nvim_list_tabpages() | ||
| if #tabs >= 2 then | ||
| return tabs[2] | ||
| end | ||
| vim.cmd("tabnew") | ||
| mark_tabnew_buffer_ephemeral() | ||
| return vim.api.nvim_get_current_tabpage() | ||
| end |
There was a problem hiding this comment.
Fixed. get_or_create_diff_tab now always does :tabnew instead of reusing tabs[2], so _cleanup_diff_state can never close an unrelated user tab when open_in_new_tab is set.
| local editor_win | ||
| local fallback_window | ||
| 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 | ||
| local diff_win = reuse_or_open_diff_window() | ||
|
|
There was a problem hiding this comment.
Fixed. reuse_or_open_diff_window now only reuses a window that lives in the current tabpage (verified via nvim_tabpage_list_wins(0)); otherwise it returns nil and the normal fallback-window path runs. This avoids nvim_set_current_win on, or hijacking of, another tab.
| @@ -1863,6 +1969,7 @@ | |||
| M._detect_filetype = detect_filetype | |||
| M._get_autocmd_group = get_autocmd_group | |||
| M._display_terminal_in_new_tab = display_terminal_in_new_tab | |||
| M.get_or_create_editor_win_in_diff_tab = get_or_create_editor_win_in_diff_tab | |||
|
|
|||
There was a problem hiding this comment.
Fixed. Renamed the internal export to M._get_or_create_editor_win_in_diff_tab (underscore prefix, matching the other internal helpers in that block) and updated the caller in diff_inline.lua.
- Hoist format_error to module scope so the open_diff_blocking error path can use it (it was defined inside _setup_blocking_diff and raised a secondary error, obscuring the original failure). - Declare error_msg as local (was implicitly a global). - Stop reusing tabs[2] in get_or_create_diff_tab; always :tabnew so cleanup never closes an unrelated user tab (issue flagged by reviewer). - Rename the internal export get_or_create_editor_win_in_diff_tab -> _get_or_create_editor_win_in_diff_tab for API consistency. - Guard reuse_or_open_diff_window to only reuse a window in the current tabpage, avoiding hijacking/switching to an unrelated tab.
…ffer in current tab - The unified diff no longer auto-opens a window or creates tab 2. - prepares silently. - Added and for user-initiated buffer access (works in any tab, creates a vsplit if needed). - Cleanup closes any window showing and keeps the buffer alive showing the reviewed file. - Removed , , tab2 logic from this path; those remain for native diff. This eliminates the tab-3 confusion: the diff always lives in and the user decides where to view it.
Summary
Adds a VS Code-style unified inline diff: a single persistent buffer (
ClaudeCodeBuffer) that shows deleted lines in red/strikethrough and added lines in green, interleaved in one pane.vim.text.diff/vim.diff(0.12 rename).bufhidden="hide"buffer that can be opened anywhere.<leader>dt/open_inline_diff()opens the buffer whenever you want (creates empty one if none exists).<leader>dc/close_inline_diff()toggles it closed.:w) or rejecting (:qon window) does NOT close the window; the buffer stays open showing the reviewed file for continued viewing.Usage
Test plan
mise run checkandmise run test.<leader>dt→ buffer opens in current tab.:w→FILE_SAVED, buffer now shows the reviewed file (window stays).<leader>dc→ window closes,:w→ opens again.Related issues
Implements the unified path from #294 — related to #169 and #205.