Skip to content

Commit 46a1b8e

Browse files
authored
feat: toggle layout, welcome page improvements, stale buffer fixes (#307)
## Summary ### Toggle between inline and side-by-side layout (`t` key) Press `t` in any diff view to toggle between side-by-side and inline layout. The current file is re-rendered in the new layout automatically. Works in explorer, history, and standalone modes. - `toggle.lua`: Orchestrates layout switch (normalize windows → rerender current file) - Uses existing `single_pane` mechanism and `rerender_current()` to replay file selection - Conflict mode blocks toggle (not applicable) - Configurable: `keymaps.view.toggle_layout = "t"` Closes #292 Closes #302 Closes the runtime toggle aspect of #146. ### Welcome page improvements - Fixed flicker: guard against re-creating welcome buffer on every refresh cycle - Fixed stale selection highlight persisting after welcome page shown - Fixed missing `welcome` module require (regression from toggle refactoring) ### Stale buffer fix improvements - Cross-platform git helpers in all test files (fixes Windows CI) - Double-quoted commit messages for Windows cmd.exe compatibility ### Code cleanup - Removed `display_state` / `diff_config` abstractions (toggle works purely through existing session fields) - Removed dead code: `reset_diff_panes`, `show_single_file_preview`, `current_layout` wrapper - Renamed for consistency: `rerender_current_source` → `rerender_current_file` - Toggle always commits layout change (no rollback desync) ### Documentation - README: toggle feature in features list + keymaps config - vimdoc: new `codediff-toggle-layout` section - Tags regenerated ### Testing - Layout toggle test suite (`layout_toggle_spec.lua`) - All existing tests pass (Linux + Windows CI)
2 parents 00649b3 + 01c92c7 commit 46a1b8e

File tree

22 files changed

+1272
-82
lines changed

22 files changed

+1272
-82
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ https://github.com/user-attachments/assets/64c41f01-dffe-4318-bce4-16eec8de356e
2525
- Deep/dark character-level highlights showing exact changes within lines
2626
- **Side-by-side diff view** in a new tab with synchronized scrolling
2727
- **Inline (unified) diff view** — single-window layout with deleted lines as virtual overlays, with treesitter syntax highlighting
28+
- **Toggle layout** — switch between side-by-side and inline layout at runtime with `t`
2829
- **Git integration**: Compare between any git revision (HEAD, commits, branches, tags)
2930
- **Same implementation as VSCode's diff engine**, providing identical visual highlighting for most scenarios
3031
- **Fast C-based diff computation** using FFI with **multi-core parallelization** (OpenMP)
@@ -155,6 +156,7 @@ https://github.com/user-attachments/assets/64c41f01-dffe-4318-bce4-16eec8de356e
155156
hunk_textobject = "ih", -- Textobject for hunk (vih to select, yih to yank, etc.)
156157
show_help = "g?", -- Show floating window with available keymaps
157158
align_move = "gm", -- Temporarily align moved code blocks across panes
159+
toggle_layout = "t", -- Toggle between side-by-side and inline layout
158160
},
159161
explorer = {
160162
select = "<CR>", -- Open diff for selected file

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2.42.0
1+
2.43.0

doc/codediff.txt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,23 @@ The `gm` keymap disables scrollbind and positions both panes so the moved
153153
block appears at the same visual row. Moving the cursor outside the block
154154
or switching windows restores normal scrollbind.
155155

156+
TOGGLING LAYOUT *codediff-toggle-layout*
157+
158+
Press `t` in a diff view to toggle between side-by-side and inline layout.
159+
The current file is re-rendered in the new layout automatically. This works
160+
in all modes (explorer, history, standalone).
161+
162+
The toggle keymap can be customized:
163+
>lua
164+
require("codediff").setup({
165+
keymaps = {
166+
view = {
167+
toggle_layout = "t", -- or false to disable
168+
},
169+
},
170+
})
171+
<
172+
156173
Setup entry point:
157174
>lua
158175
require("codediff").setup({
@@ -216,6 +233,7 @@ Setup entry point:
216233
hunk_textobject = "ih",
217234
show_help = "g?",
218235
align_move = "gm",
236+
toggle_layout = "t",
219237
},
220238
explorer = {
221239
select = "<CR>",

doc/tags

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ codediff-lua-api codediff.txt /*codediff-lua-api*
1313
codediff-moved-code codediff.txt /*codediff-moved-code*
1414
codediff-quickstart codediff.txt /*codediff-quickstart*
1515
codediff-see-also codediff.txt /*codediff-see-also*
16+
codediff-toggle-layout codediff.txt /*codediff-toggle-layout*
1617
codediff-usage codediff.txt /*codediff-usage*
1718
codediff.nvim codediff.txt /*codediff.nvim*
1819
codediff.txt codediff.txt /*codediff.txt*

lua/codediff/config.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ M.defaults = {
9999
discard_hunk = "<leader>hr", -- Discard the hunk under cursor (working tree only)
100100
hunk_textobject = "ih", -- Textobject for hunk (vih to select, yih to yank, etc.)
101101
align_move = "gm", -- Temporarily align other pane to show paired moved code
102+
toggle_layout = "t", -- Toggle diff layout for the current codediff session
102103
show_help = "g?", -- Show floating window with available keymaps
103104
},
104105
explorer = {

lua/codediff/ui/explorer/init.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ local actions = require("codediff.ui.explorer.actions")
99

1010
-- Delegate to render module
1111
M.create = render.create
12+
M.rerender_current = render.rerender_current
13+
M.show_welcome_page = render.show_welcome_page
1214

1315
-- Delegate to refresh module
1416
M.setup_auto_refresh = refresh.setup_auto_refresh

lua/codediff/ui/explorer/refresh.lua

Lines changed: 13 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ local M = {}
44
local config = require("codediff.config")
55
local tree_module = require("codediff.ui.explorer.tree")
66
local welcome = require("codediff.ui.welcome")
7-
87
-- Setup auto-refresh triggers for explorer
98
-- Returns a cleanup function that should be called when the explorer is destroyed
109
function M.setup_auto_refresh(explorer, tabpage)
@@ -256,38 +255,24 @@ function M.refresh(explorer)
256255
local function clear_current_file()
257256
explorer.current_file_path = nil
258257
explorer.current_file_group = nil
259-
end
260-
261-
-- Helper: show the welcome page in the diff panes
262-
local function show_welcome_page()
263-
local lifecycle = require("codediff.ui.lifecycle")
264-
local session = lifecycle.get_session(explorer.tabpage)
265-
if session and not welcome.is_welcome_buffer(session.modified_bufnr) then
266-
local mod_win = session.modified_win
267-
if mod_win and vim.api.nvim_win_is_valid(mod_win) then
268-
if session.layout == "inline" then
269-
local w = vim.api.nvim_win_get_width(mod_win)
270-
local h = vim.api.nvim_win_get_height(mod_win)
271-
local welcome_buf = welcome.create_buffer(w, h)
272-
require("codediff.ui.view.inline_view").show_welcome(explorer.tabpage, welcome_buf)
273-
else
274-
local orig_win = session.original_win
275-
if orig_win and vim.api.nvim_win_is_valid(orig_win) then
276-
local w = vim.api.nvim_win_get_width(orig_win) + vim.api.nvim_win_get_width(mod_win) + 1
277-
local h = vim.api.nvim_win_get_height(orig_win)
278-
local welcome_buf = welcome.create_buffer(w, h)
279-
require("codediff.ui.view.side_by_side").show_welcome(explorer.tabpage, welcome_buf)
280-
end
281-
end
282-
end
258+
explorer.current_selection = nil
259+
if explorer.clear_selection then
260+
explorer.clear_selection()
283261
end
284262
end
285263

286-
-- Show welcome page when all files are clean
264+
local show_welcome_page = require("codediff.ui.explorer.render").show_welcome_page
265+
266+
-- Show welcome page when all files are clean (skip if already showing)
287267
local total_files = #(status_result.unstaged or {}) + #(status_result.staged or {}) + #(status_result.conflicts or {})
288268
if total_files == 0 then
269+
local lifecycle = require("codediff.ui.lifecycle")
270+
local session = lifecycle.get_session(explorer.tabpage)
271+
local already_welcome = session and welcome.is_welcome_buffer(session.modified_bufnr)
289272
clear_current_file()
290-
show_welcome_page()
273+
if not already_welcome then
274+
show_welcome_page(explorer)
275+
end
291276
end
292277

293278
-- Re-select the currently viewed file after refresh.
@@ -339,7 +324,7 @@ function M.refresh(explorer)
339324
else
340325
-- File was committed/removed — show welcome
341326
clear_current_file()
342-
show_welcome_page()
327+
show_welcome_page(explorer)
343328
end
344329
end
345330
end)

lua/codediff/ui/explorer/render.lua

Lines changed: 112 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,48 @@ local nodes_module = require("codediff.ui.explorer.nodes")
88
local tree_module = require("codediff.ui.explorer.tree")
99
local keymaps_module = require("codediff.ui.explorer.keymaps")
1010
local refresh_module = require("codediff.ui.explorer.refresh")
11+
local welcome = require("codediff.ui.welcome")
12+
13+
local function should_show_welcome(explorer)
14+
if not explorer or not explorer.git_root or explorer.dir1 or explorer.dir2 then
15+
return false
16+
end
17+
18+
local status = explorer.status_result or {}
19+
local total_files = #(status.unstaged or {}) + #(status.staged or {}) + #(status.conflicts or {})
20+
return total_files == 0
21+
end
22+
23+
local function show_welcome_page(explorer)
24+
local lifecycle = require("codediff.ui.lifecycle")
25+
local session = lifecycle.get_session(explorer.tabpage)
26+
if not session then
27+
return false
28+
end
29+
30+
local mod_win = session.modified_win
31+
if not mod_win or not vim.api.nvim_win_is_valid(mod_win) then
32+
return false
33+
end
34+
35+
if session.layout == "inline" then
36+
local welcome_buf = welcome.create_buffer(vim.api.nvim_win_get_width(mod_win), vim.api.nvim_win_get_height(mod_win))
37+
require("codediff.ui.view.inline_view").show_welcome(explorer.tabpage, welcome_buf)
38+
return true
39+
end
40+
41+
local orig_win = session.original_win
42+
local width = vim.api.nvim_win_get_width(mod_win)
43+
local height = vim.api.nvim_win_get_height(mod_win)
44+
if orig_win and vim.api.nvim_win_is_valid(orig_win) then
45+
width = vim.api.nvim_win_get_width(orig_win) + width + 1
46+
height = vim.api.nvim_win_get_height(orig_win)
47+
end
48+
49+
local welcome_buf = welcome.create_buffer(width, height)
50+
require("codediff.ui.view.side_by_side").show_welcome(explorer.tabpage, welcome_buf)
51+
return true
52+
end
1153

1254
function M.create(status_result, git_root, tabpage, width, base_revision, target_revision, opts)
1355
opts = opts or {}
@@ -132,12 +174,14 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target
132174
on_file_select = nil, -- Will be set below
133175
current_file_path = nil, -- Track currently selected file
134176
current_file_group = nil, -- Track currently selected file's group (staged/unstaged)
177+
current_selection = nil, -- Full file selection used to replay current state
135178
is_hidden = false, -- Track visibility state
136179
visible_groups = vim.deepcopy(explorer_config.visible_groups or { staged = true, unstaged = true, conflicts = true }),
137180
}
138181

139182
-- File selection callback - manages its own lifecycle
140-
local function on_file_select(file_data)
183+
local function on_file_select(file_data, opts)
184+
opts = opts or {}
141185
local git = require("codediff.core.git")
142186
local view = require("codediff.ui.view")
143187
local lifecycle = require("codediff.ui.lifecycle")
@@ -164,7 +208,7 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target
164208

165209
-- Check if already displaying same file
166210
local session = lifecycle.get_session(tabpage)
167-
if session and session.original_path == original_path and session.modified_path == modified_path then
211+
if not opts.force and session and session.original_path == original_path and session.modified_path == modified_path then
168212
return
169213
end
170214

@@ -190,7 +234,9 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target
190234
vim.schedule(function()
191235
local sess = lifecycle.get_session(tabpage)
192236
if sess and sess.layout == "inline" then
193-
require("codediff.ui.view.inline_view").show_single_file(tabpage, abs_path)
237+
require("codediff.ui.view.inline_view").show_single_file(tabpage, abs_path, {
238+
side = "modified",
239+
})
194240
else
195241
require("codediff.ui.view.side_by_side").show_untracked_file(tabpage, abs_path)
196242
end
@@ -206,19 +252,31 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target
206252

207253
if base_revision and target_revision and target_revision ~= "WORKING" then
208254
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 })
255+
require("codediff.ui.view.inline_view").show_single_file(tabpage, file_path, {
256+
revision = target_revision,
257+
git_root = git_root,
258+
rel_path = file_path,
259+
side = "modified",
260+
})
210261
else
211262
require("codediff.ui.view.side_by_side").show_added_virtual_file(tabpage, git_root, file_path, target_revision)
212263
end
213264
elseif group == "staged" then
214265
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 })
266+
require("codediff.ui.view.inline_view").show_single_file(tabpage, file_path, {
267+
revision = ":0",
268+
git_root = git_root,
269+
rel_path = file_path,
270+
side = "modified",
271+
})
216272
else
217273
require("codediff.ui.view.side_by_side").show_added_virtual_file(tabpage, git_root, file_path, ":0")
218274
end
219275
else
220276
if is_inline then
221-
require("codediff.ui.view.inline_view").show_single_file(tabpage, abs_path)
277+
require("codediff.ui.view.inline_view").show_single_file(tabpage, abs_path, {
278+
side = "modified",
279+
})
222280
else
223281
require("codediff.ui.view.side_by_side").show_untracked_file(tabpage, abs_path)
224282
end
@@ -235,14 +293,24 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target
235293

236294
if base_revision and target_revision and target_revision ~= "WORKING" then
237295
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 })
296+
require("codediff.ui.view.inline_view").show_single_file(tabpage, file_path, {
297+
revision = base_revision,
298+
git_root = git_root,
299+
rel_path = file_path,
300+
side = "original",
301+
})
239302
else
240303
require("codediff.ui.view.side_by_side").show_deleted_virtual_file(tabpage, git_root, file_path, base_revision)
241304
end
242305
else
243306
if is_inline then
244307
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 })
308+
require("codediff.ui.view.inline_view").show_single_file(tabpage, file_path, {
309+
revision = revision,
310+
git_root = git_root,
311+
rel_path = file_path,
312+
side = "original",
313+
})
246314
else
247315
require("codediff.ui.view.side_by_side").show_deleted_file(tabpage, git_root, file_path, abs_path, group)
248316
end
@@ -257,7 +325,7 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target
257325
if session then
258326
local is_same_file = (session.modified_path == abs_path or (session.git_root and session.original_path == file_path))
259327

260-
if is_same_file then
328+
if is_same_file and not opts.force then
261329
-- Check if it's the same diff comparison
262330
local is_staged_diff = group == "staged"
263331
local current_is_staged = session.modified_revision == ":0"
@@ -391,13 +459,21 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target
391459
end
392460

393461
-- Wrap on_file_select to track current file and group
394-
explorer.on_file_select = function(file_data)
462+
explorer.on_file_select = function(file_data, opts)
395463
explorer.current_file_path = file_data.path
396464
explorer.current_file_group = file_data.group
465+
explorer.current_selection = vim.deepcopy(file_data)
397466
selected_path = file_data.path
398467
selected_group = file_data.group
399468
tree:render()
400-
on_file_select(file_data)
469+
on_file_select(file_data, opts)
470+
end
471+
472+
-- Clear selection highlight (used when showing welcome page)
473+
explorer.clear_selection = function()
474+
selected_path = nil
475+
selected_group = nil
476+
tree:render()
401477
end
402478

403479
-- Setup keymaps (delegated to keymaps module)
@@ -485,6 +561,31 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target
485561
return explorer
486562
end
487563

564+
function M.rerender_current(explorer)
565+
if not explorer then
566+
return false
567+
end
568+
569+
if explorer.current_selection then
570+
explorer.on_file_select(vim.deepcopy(explorer.current_selection), { force = true })
571+
return true
572+
end
573+
574+
local lifecycle = require("codediff.ui.lifecycle")
575+
local session = lifecycle.get_session(explorer.tabpage)
576+
if not session then
577+
return false
578+
end
579+
580+
if should_show_welcome(explorer) and show_welcome_page(explorer) then
581+
return true
582+
end
583+
584+
return false
585+
end
586+
587+
M.show_welcome_page = show_welcome_page
588+
488589
-- Setup auto-refresh on file save and focus
489590

490591
return M

lua/codediff/ui/history/init.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ local render = require("codediff.ui.history.render")
1111
-- width: optional width override
1212
-- opts: { range, path, ... } original options
1313
M.create = render.create
14+
M.rerender_current = render.rerender_current
1415

1516
-- Navigation (files within expanded commits)
1617
M.navigate_next = render.navigate_next

0 commit comments

Comments
 (0)