Skip to content

feat: add unified inline diff layout (ClaudeCodeBuffer)#300

Open
spoloxs wants to merge 3 commits into
coder:mainfrom
spoloxs:feat/unified-inline-diff
Open

feat: add unified inline diff layout (ClaudeCodeBuffer)#300
spoloxs wants to merge 3 commits into
coder:mainfrom
spoloxs:feat/unified-inline-diff

Conversation

@spoloxs

@spoloxs spoloxs commented Jul 6, 2026

Copy link
Copy Markdown

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.

  • Zero-dependency engine: pure-Lua interleave on vim.text.diff/vim.diff (0.12 rename).
  • Persistent buffer: bufhidden="hide" buffer that can be opened anywhere.
  • Always toggleable: <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.
  • Keep-open on accept/reject: Accepting (:w) or rejecting (:q on window) does NOT close the window; the buffer stays open showing the reviewed file for continued viewing.

Usage

-- Add to your keymaps
vim.keymap.set('n', '<leader>dt',
  function() require('claudecode.diff_inline').open_inline_diff() end,
  { desc = 'Toggle open ClaudeCode diff buffer', silent = true })
vim.keymap.set('n', '<leader>dc',
  function() require('claudecode.diff_inline').close_inline_diff() end,
  { desc = 'Toggle close ClaudeCode diff buffer', silent = true })

Test plan

  • mise run check and mise run test.
  • Trigger a Claude Code edit → buffer is prepared silently.
  • Press <leader>dt → buffer opens in current tab.
  • :wFILE_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.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lua/claudecode/diff.lua Outdated
if setup_error.data then
error_msg = error_msg .. " (" .. setup_error.data .. ")"
end
error_msg = "Failed to setup diff operation: " .. format_error(setup_error)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lua/claudecode/diff.lua
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))

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lua/claudecode/diff.lua
Comment on lines 1762 to 1767
else
error({
code = -32000,
message = "Error setting up diff",
data = tostring(err),
data = format_error(err),
})

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same root cause as the previous one — fixed by hoisting format_error to module scope. This call now resolves correctly and error propagation works.

Comment thread lua/claudecode/diff.lua
Comment on lines +399 to +407
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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lua/claudecode/diff_inline.lua Outdated
Comment on lines +414 to +417
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()

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lua/claudecode/diff.lua
Comment on lines 1965 to 1973
@@ -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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

spoloxs added 2 commits July 7, 2026 03:26
- 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants