Skip to content

Commit 695165f

Browse files
authored
feat: inline (unified) diff layout with treesitter syntax highlighting (#281)
## Summary Add a new inline diff layout as an alternative to side-by-side. Deleted lines appear as virtual line overlays with treesitter syntax highlighting, providing a VSCode-style unified diff experience. Set `diff.layout = "inline"` to enable. Default remains `"side-by-side"` — no breaking changes. ## Features - **Inline diff rendering** — single-window layout with deleted lines as `virt_lines` overlays - **Treesitter syntax highlighting** on virtual lines — deleted code retains keyword, string, function colors merged with diff backgrounds - **Character-level diff highlights** on both virtual lines (deleted) and real lines (inserted) - **Full mode support** — works with standalone, explorer, and history modes - **Shared keymaps** — `do` reverts hunk to original, `dp` is no-op (buffer already has modified content), navigation/textobject/staging all work - **44 new tests** covering all inline scenarios with 0 regressions ## Changes ### New files - `lua/codediff/ui/inline.lua` — core renderer (virt_lines, char highlights, treesitter syntax) - `lua/codediff/ui/view/inline_view.lua` — inline engine (create/update/rerender/show_single_file) - `tests/ui/inline_spec.lua` — 10 renderer unit tests - `tests/ui/inline_syntax_spec.lua` — 8 treesitter syntax tests - `tests/ui/view/inline_standalone_spec.lua` — 14 standalone view tests - `tests/ui/view/inline_explorer_spec.lua` — 4 explorer mode tests - `tests/ui/view/inline_history_spec.lua` — 4 history mode tests - `tests/ui/view/inline_interaction_spec.lua` — 12 interaction tests (nav, do, autoscroll, textobject, lifecycle) ### Modified files (additive, gated by `layout == "inline"`) - `config.lua` — `layout = "side-by-side"` option - `view/init.lua` — router dispatches to inline engine - `keymaps.lua` — layout-aware do/dp/find_hunk_at_cursor - `navigation.lua` — inline uses modified line numbers - `auto_refresh.lua` — inline render branch - `lifecycle/cleanup.lua` — threshold for inline sessions - `lifecycle/state.lua` — inline namespace clear + resume render - `explorer/render.lua` — A/D/?? routing to inline_view.show_single_file - `history/render.lua` — A/D routing to inline_view.show_single_file ## Usage ```lua require('codediff').setup({ diff = { layout = 'inline' }, }) ``` ## Testing - 44 new inline-specific tests - All existing tests pass (5 made layout-resilient) - Full suite passes with both `inline` and `side-by-side` as default
2 parents f1e74a0 + a825295 commit 695165f

26 files changed

+3168
-65
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
[![Downloads](https://img.shields.io/github/downloads/esmuellert/vscode-diff.nvim/total?label=⬇%20downloads&color=blue)](https://github.com/esmuellert/vscode-diff.nvim/releases)
44

5-
A Neovim plugin that provides VSCode-style side-by-side diff rendering with two-tier highlighting.
5+
A Neovim plugin that provides VSCode-style diff rendering with two-tier highlighting, supporting both side-by-side and inline (unified) layouts.
66

77
<div align="center">
88

@@ -24,6 +24,7 @@ https://github.com/user-attachments/assets/64c41f01-dffe-4318-bce4-16eec8de356e
2424
- Light backgrounds for entire modified lines (green for insertions, red for deletions)
2525
- Deep/dark character-level highlights showing exact changes within lines
2626
- **Side-by-side diff view** in a new tab with synchronized scrolling
27+
- **Inline (unified) diff view** — single-window layout with deleted lines as virtual overlays, with treesitter syntax highlighting
2728
- **Git integration**: Compare between any git revision (HEAD, commits, branches, tags)
2829
- **Same implementation as VSCode's diff engine**, providing identical visual highlighting for most scenarios
2930
- **Fast C-based diff computation** using FFI with **multi-core parallelization** (OpenMP)
@@ -82,6 +83,7 @@ https://github.com/user-attachments/assets/64c41f01-dffe-4318-bce4-16eec8de356e
8283

8384
-- Diff view behavior
8485
diff = {
86+
layout = "side-by-side", -- Diff layout: "side-by-side" (two panes) or "inline" (single pane with virtual lines)
8587
disable_inlay_hints = true, -- Disable inlay hints in diff windows for cleaner view
8688
max_computation_time_ms = 5000, -- Maximum time for diff computation (VSCode default)
8789
ignore_trim_whitespace = false, -- Ignore leading/trailing whitespace changes (like diffopt+=iwhite)

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2.38.0
1+
2.39.0

doc/codediff.txt

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
codediff.nvim *codediff.txt* *codediff.nvim* *codediff*
22

3-
CodeDiff provides VSCode-style side-by-side diff rendering with two-tier
4-
highlighting.
3+
CodeDiff provides VSCode-style diff rendering with two-tier highlighting,
4+
supporting both side-by-side and inline (unified) layouts.
55

66
This help file is usage-focused. For installation, build, and development
77
details, see README.md.
@@ -114,6 +114,22 @@ Git merge tool:
114114
==============================================================================
115115
CONFIGURATION *codediff-configuration*
116116

117+
*codediff-layout*
118+
Diff layout~
119+
120+
CodeDiff supports two diff layouts:
121+
122+
`"side-by-side"` (default): Two panes with synchronized scrolling.
123+
`"inline"`: Single pane with deleted lines shown as virtual overlays.
124+
Includes treesitter syntax highlighting on virtual lines.
125+
126+
Set the layout in your config:
127+
>lua
128+
require("codediff").setup({
129+
diff = { layout = "inline" },
130+
})
131+
<
132+
117133
Setup entry point:
118134
>lua
119135
require("codediff").setup({
@@ -129,6 +145,7 @@ Setup entry point:
129145
conflict_sign_rejected = nil,
130146
},
131147
diff = {
148+
layout = "side-by-side",
132149
disable_inlay_hints = true,
133150
max_computation_time_ms = 5000,
134151
hide_merge_artifacts = false,

doc/tags

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ codediff-events codediff.txt /*codediff-events*
88
codediff-highlight-groups codediff.txt /*codediff-highlight-groups*
99
codediff-history codediff.txt /*codediff-history*
1010
codediff-history-line-range codediff.txt /*codediff-history-line-range*
11+
codediff-layout codediff.txt /*codediff-layout*
1112
codediff-lua-api codediff.txt /*codediff-lua-api*
1213
codediff-quickstart codediff.txt /*codediff-quickstart*
1314
codediff-see-also codediff.txt /*codediff-see-also*

lua/codediff/config.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ M.defaults = {
2929

3030
-- Diff view behavior
3131
diff = {
32+
layout = "inline", -- Diff layout: "side-by-side" or "inline"
3233
disable_inlay_hints = true, -- Disable inlay hints in diff windows for cleaner view
3334
max_computation_time_ms = 5000, -- Maximum time for diff computation (5 seconds, VSCode default)
3435
ignore_trim_whitespace = false, -- Ignore leading/trailing whitespace changes (like diffopt+=iwhite)

lua/codediff/ui/auto_refresh.lua

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,15 @@ local function do_diff_update(bufnr, skip_watcher_check)
9999
-- Update stored diff result in lifecycle (critical for hunk navigation and do/dp)
100100
lifecycle.update_diff_result(tabpage, lines_diff)
101101

102-
-- Update decorations on both buffers
102+
-- Check if this is an inline mode session
103+
local session = lifecycle.get_session(tabpage)
104+
if session and session.layout == "inline" then
105+
local inline_mod = require("codediff.ui.inline")
106+
inline_mod.render_inline_diff(modified_bufnr, lines_diff, original_lines, modified_lines)
107+
return
108+
end
109+
110+
-- Side-by-side mode: Update decorations on both buffers
103111
core.render_diff(original_bufnr, modified_bufnr, original_lines, modified_lines, lines_diff)
104112

105113
-- Re-sync scrollbind after filler changes

lua/codediff/ui/explorer/render.lua

Lines changed: 49 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -188,51 +188,66 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target
188188
-- Handle untracked files: show file without diff
189189
if file_data.status == "??" then
190190
vim.schedule(function()
191-
local side_by_side = require("codediff.ui.view.side_by_side")
192-
side_by_side.show_untracked_file(tabpage, abs_path)
191+
local sess = lifecycle.get_session(tabpage)
192+
if sess and sess.layout == "inline" then
193+
require("codediff.ui.view.inline_view").show_single_file(tabpage, abs_path)
194+
else
195+
require("codediff.ui.view.side_by_side").show_untracked_file(tabpage, abs_path)
196+
end
193197
end)
194198
return
195199
end
196200

197201
-- Handle added files: only one side has the file
198202
if file_data.status == "A" then
199-
if base_revision and target_revision and target_revision ~= "WORKING" then
200-
-- Two-revision mode: show target revision content only
201-
vim.schedule(function()
202-
local side_by_side = require("codediff.ui.view.side_by_side")
203-
side_by_side.show_added_virtual_file(tabpage, git_root, file_path, target_revision)
204-
end)
205-
elseif group == "staged" then
206-
-- Staged mode: file exists in staging (:0) but not in HEAD
207-
vim.schedule(function()
208-
local side_by_side = require("codediff.ui.view.side_by_side")
209-
side_by_side.show_added_virtual_file(tabpage, git_root, file_path, ":0")
210-
end)
211-
else
212-
-- Unstaged/working tree: file exists on disk but not in any revision
213-
vim.schedule(function()
214-
local side_by_side = require("codediff.ui.view.side_by_side")
215-
side_by_side.show_untracked_file(tabpage, abs_path)
216-
end)
217-
end
203+
vim.schedule(function()
204+
local sess = lifecycle.get_session(tabpage)
205+
local is_inline = sess and sess.layout == "inline"
206+
207+
if base_revision and target_revision and target_revision ~= "WORKING" then
208+
if is_inline then
209+
require("codediff.ui.view.inline_view").show_single_file(tabpage, file_path, { revision = target_revision, git_root = git_root, rel_path = file_path })
210+
else
211+
require("codediff.ui.view.side_by_side").show_added_virtual_file(tabpage, git_root, file_path, target_revision)
212+
end
213+
elseif group == "staged" then
214+
if is_inline then
215+
require("codediff.ui.view.inline_view").show_single_file(tabpage, file_path, { revision = ":0", git_root = git_root, rel_path = file_path })
216+
else
217+
require("codediff.ui.view.side_by_side").show_added_virtual_file(tabpage, git_root, file_path, ":0")
218+
end
219+
else
220+
if is_inline then
221+
require("codediff.ui.view.inline_view").show_single_file(tabpage, abs_path)
222+
else
223+
require("codediff.ui.view.side_by_side").show_untracked_file(tabpage, abs_path)
224+
end
225+
end
226+
end)
218227
return
219228
end
220229

221230
-- Handle deleted files: show old content without diff
222231
if file_data.status == "D" then
223-
if base_revision and target_revision and target_revision ~= "WORKING" then
224-
-- Two-revision mode: use base_revision
225-
vim.schedule(function()
226-
local side_by_side = require("codediff.ui.view.side_by_side")
227-
side_by_side.show_deleted_virtual_file(tabpage, git_root, file_path, base_revision)
228-
end)
229-
else
230-
-- Working tree mode: use staged or HEAD
231-
vim.schedule(function()
232-
local side_by_side = require("codediff.ui.view.side_by_side")
233-
side_by_side.show_deleted_file(tabpage, git_root, file_path, abs_path, group)
234-
end)
235-
end
232+
vim.schedule(function()
233+
local sess = lifecycle.get_session(tabpage)
234+
local is_inline = sess and sess.layout == "inline"
235+
236+
if base_revision and target_revision and target_revision ~= "WORKING" then
237+
if is_inline then
238+
require("codediff.ui.view.inline_view").show_single_file(tabpage, file_path, { revision = base_revision, git_root = git_root, rel_path = file_path })
239+
else
240+
require("codediff.ui.view.side_by_side").show_deleted_virtual_file(tabpage, git_root, file_path, base_revision)
241+
end
242+
else
243+
if is_inline then
244+
local revision = (group == "staged") and "HEAD" or ":0"
245+
require("codediff.ui.view.inline_view").show_single_file(tabpage, file_path, { revision = revision, git_root = git_root, rel_path = file_path })
246+
else
247+
require("codediff.ui.view.side_by_side").show_deleted_file(tabpage, git_root, file_path, abs_path, group)
248+
end
249+
end
250+
end)
236251
return
237252
end
238253

lua/codediff/ui/history/render.lua

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -294,16 +294,22 @@ function M.create(commits, git_root, tabpage, width, opts)
294294
end
295295

296296
vim.schedule(function()
297-
-- Handle added/deleted files: show single pane instead of empty diff
297+
-- Handle added/deleted files: show single file instead of empty diff
298298
local file_status = file_data.status
299299
if file_status == "A" or file_status == "D" then
300-
local side_by_side = require("codediff.ui.view.side_by_side")
301-
if file_status == "A" then
302-
-- File added in this commit: only exists in commit_hash
303-
side_by_side.show_added_virtual_file(tabpage, git_root, file_path, commit_hash)
300+
local sess = lifecycle.get_session(tabpage)
301+
local is_inline = sess and sess.layout == "inline"
302+
303+
if is_inline then
304+
local rev = file_status == "A" and commit_hash or target_hash
305+
local path = file_status == "D" and (old_path or file_path) or file_path
306+
require("codediff.ui.view.inline_view").show_single_file(tabpage, path, { revision = rev, git_root = git_root, rel_path = path })
304307
else
305-
-- File deleted in this commit: only exists in parent
306-
side_by_side.show_deleted_virtual_file(tabpage, git_root, old_path or file_path, target_hash)
308+
if file_status == "A" then
309+
require("codediff.ui.view.side_by_side").show_added_virtual_file(tabpage, git_root, file_path, commit_hash)
310+
else
311+
require("codediff.ui.view.side_by_side").show_deleted_virtual_file(tabpage, git_root, old_path or file_path, target_hash)
312+
end
307313
end
308314
return
309315
end

0 commit comments

Comments
 (0)