Skip to content

Commit 283261c

Browse files
authored
feat(review): toggle review layouts (#421)
1 parent c5383f8 commit 283261c

8 files changed

Lines changed: 396 additions & 20 deletions

File tree

doc/diffs.nvim.txt

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,15 @@ Paired-window behavior: ~ *diffs.nvim-paired-windows*
585585
intra-line word-diff highlighting as generated buffers
586586
(see |diffs.nvim-paired-windows|).
587587

588+
In generated review maps and split panes, `gs` toggles between the full
589+
review map and the side-by-side split for the selected file. From a generated
590+
map it uses the file under the cursor (or current quickfix item); from a
591+
split it closes the pair and restores the generated layout it came from
592+
(`unified` or `stacked`, defaulting to `unified` for direct split opens).
593+
This is a buffer-local default backed by
594+
|<Plug>(diffs-review-toggle-layout)|, set only when you have not already
595+
mapped `gs` in that buffer.
596+
588597
In split layout, `]f`/`[f` step to the next/previous changed file in the
589598
review (wrapping at the ends), swapping the displayed pair in place and
590599
reusing the two windows. Each step (and the initial open) echoes the file's
@@ -749,6 +758,14 @@ MAPPINGS *diffs.nvim-mappings*
749758
paired side-by-side diff: the old/removed endpoint on
750759
the left, the new/added endpoint on the right.
751760

761+
*<Plug>(diffs-review-toggle-layout)*
762+
<Plug>(diffs-review-toggle-layout)
763+
Toggle the current review buffer between the generated
764+
review map and split layout. Mapped to `gs` by default
765+
in generated review maps and review split panes, unless
766+
`gs` is already mapped in that buffer. See
767+
|diffs.nvim.review_toggle_layout()|.
768+
752769
*<Plug>(diffs-review-next-file)*
753770
<Plug>(diffs-review-next-file)
754771
In a |:Diff-review| split, swap the displayed pair to
@@ -1254,11 +1271,12 @@ refresh({bufnr}) *diffs.nvim.refresh()*
12541271
Parameters: ~
12551272
{bufnr} (integer, optional) Buffer number. Defaults to current buffer.
12561273

1257-
The review-split navigation API below is buffer-centric and split-scoped: each
1258-
function resolves the review split that {bufnr} (default: current buffer)
1259-
belongs to, and is a no-op (`nil`/`false`) elsewhere. The stacked review map is
1260-
self-sufficient through the |quickfix| list and is not covered here. Navigation
1261-
is only meaningful inside an open review; nothing here opens one.
1274+
The review API below is buffer-centric. Navigation functions resolve the review
1275+
split that {bufnr} (default: current buffer) belongs to, and are no-ops
1276+
(`nil`/`false`) elsewhere. |diffs.nvim.review_toggle_layout()| also accepts a
1277+
generated review map buffer and can move between map and split surfaces. These
1278+
helpers are only meaningful inside an open review; nothing here opens a new
1279+
review spec.
12621280

12631281
*diffs.ReviewFile*
12641282
A `diffs.ReviewFile` describes one changed file in a review: ~
@@ -1303,6 +1321,17 @@ review_prev_file() *diffs.nvim.review_prev_file()*
13031321
at the ends and skipping unsupported files. Backing functions for `]f`/`[f`,
13041322
|<Plug>(diffs-review-next-file)|, and |<Plug>(diffs-review-prev-file)|.
13051323

1324+
review_toggle_layout({opts}) *diffs.nvim.review_toggle_layout()*
1325+
Toggle the current review surface between the generated review map and split
1326+
layout. From a generated review map, opens the selected file in split layout.
1327+
From a review split, closes the pair and restores the generated map layout
1328+
recorded on the split (`unified` or `stacked`). Returns the opened buffer
1329+
number or `nil` if the current buffer is not a review surface.
1330+
1331+
Parameters: ~
1332+
{opts} (table, optional) Supports `{ bufnr = <integer> }`. Defaults to
1333+
the current buffer.
1334+
13061335
select_review_file({bufnr}) *diffs.nvim.select_review_file()*
13071336
Prompt with |vim.ui.select()| over the review split's files and jump to the
13081337
chosen one. Skipped files are shown as `(skipped)` and do not jump. Backing
@@ -1352,14 +1381,15 @@ Configuration: >lua
13521381
<
13531382

13541383
`b:diffs_repo_root` is the only supported `b:diffs_*` variable to set on
1355-
external buffers. On its own review-split panes |diffs.nvim| also exposes a
1356-
read-only `b:diffs_review` table — `{ display = <review>, layout = "split" }` —
1357-
so statuslines and other plugins can detect and identify a review buffer
1358-
without calling the API. Treat it as identity only; live position and the file
1359-
list come from |diffs.nvim.review_current()| and |diffs.nvim.review_files()|.
1360-
Other `b:diffs_*` variables, generated `diffs://` buffer names,
1361-
hunk/spec/source metadata, quickfix/location-list `user_data`, and non-root
1362-
`lua/diffs/` modules are internal unless documented here.
1384+
external buffers. On its own review maps and review-split panes |diffs.nvim|
1385+
also exposes a read-only `b:diffs_review` table with `display` and `layout`
1386+
fields (`layout` is `"unified"`, `"stacked"`, or `"split"`), so statuslines and
1387+
other plugins can detect and identify a review buffer without calling the API.
1388+
Treat it as identity only; live position and the file list come from
1389+
|diffs.nvim.review_current()| and |diffs.nvim.review_files()|. Other
1390+
`b:diffs_*` variables, generated `diffs://` buffer names, hunk/spec/source
1391+
metadata, quickfix/location-list `user_data`, and non-root `lua/diffs/` modules
1392+
are internal unless documented here.
13631393

13641394
==============================================================================
13651395
KNOWN LIMITATIONS *diffs.nvim-limitations*

lua/diffs/commands.lua

Lines changed: 167 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ local hunk_keymaps = {}
7070
---@type table<integer, integer>
7171
local hunk_keymap_autocmds = {}
7272

73+
---@alias diffs.ReviewMapLayout "unified"|"stacked"
74+
7375
---@class diffs.ReviewSplitState
7476
---@field left_buf integer
7577
---@field right_buf integer
@@ -80,6 +82,7 @@ local hunk_keymap_autocmds = {}
8082
---@field display string
8183
---@field review_lines string[]
8284
---@field list_opts table?
85+
---@field map_layout diffs.ReviewMapLayout
8386
---@field selected_file string
8487
---@field selected_key string?
8588
---@field selected_diff_spec diffs.DiffSpec
@@ -424,8 +427,16 @@ end
424427

425428
---@param bufnr integer
426429
---@param vertical? boolean
427-
local function show_generated_diff_buffer(bufnr, vertical)
428-
local existing_win = M.find_diffs_window()
430+
---@param replace_win? integer
431+
---@return boolean
432+
local function show_generated_diff_buffer(bufnr, vertical, replace_win)
433+
local replace_requested = type(replace_win) == 'number'
434+
if replace_requested and not vim.api.nvim_win_is_valid(replace_win) then
435+
notify('replacement diff window is no longer valid', vim.log.levels.WARN)
436+
return false
437+
end
438+
439+
local existing_win = replace_requested and replace_win or M.find_diffs_window()
429440
if existing_win then
430441
vim.api.nvim_set_current_win(existing_win)
431442
split.release_pair_window_options(existing_win)
@@ -436,6 +447,7 @@ local function show_generated_diff_buffer(bufnr, vertical)
436447
clear_generated_diff_window_bindings(vim.api.nvim_get_current_win())
437448
vim.api.nvim_win_set_buf(0, bufnr)
438449
end
450+
return true
439451
end
440452

441453
---@param bufnr integer
@@ -502,17 +514,50 @@ end
502514

503515
---@class diffs.ReviewDepsOpts
504516
---@field rail_style? diffs.RailStyle
517+
---@field review_layout? diffs.ReviewMapLayout
518+
---@field replace_win? integer
519+
520+
---@param layout? string
521+
---@return diffs.ReviewMapLayout
522+
local function normalize_review_map_layout(layout)
523+
return layout == 'stacked' and 'stacked' or 'unified'
524+
end
525+
526+
---@param rail_style? diffs.RailStyle
527+
---@return diffs.ReviewMapLayout
528+
local function review_layout_for_rail_style(rail_style)
529+
return rail_style == 'single' and 'stacked' or 'unified'
530+
end
531+
532+
---@param bufnr integer
533+
---@param display string
534+
---@param layout diffs.ReviewMapLayout
535+
local function setup_review_map_buffer(bufnr, display, layout)
536+
vim.b[bufnr].diffs_review = { display = display, layout = layout }
537+
if not get_buffer_keymap(bufnr, 'n', 'gs') then
538+
vim.keymap.set(
539+
'n',
540+
'gs',
541+
'<Plug>(diffs-review-toggle-layout)',
542+
{ buffer = bufnr, remap = true }
543+
)
544+
end
545+
end
505546

506547
---@param opts? diffs.ReviewDepsOpts
507548
---@return diffs.ReviewDeps
508549
local function review_deps(opts)
509550
opts = opts or {}
510551
return {
511552
create_generated_diff_buffer = create_generated_diff_buffer,
512-
show_generated_diff_buffer = show_generated_diff_buffer,
553+
show_generated_diff_buffer = function(bufnr, vertical)
554+
return show_generated_diff_buffer(bufnr, vertical, opts.replace_win)
555+
end,
513556
attach_generated_diff_buffer = attach_generated_diff_buffer,
514557
replace_combined_diffs = replace_combined_diffs,
515558
rail_style = opts.rail_style,
559+
review_layout = opts.review_layout or review_layout_for_rail_style(opts.rail_style),
560+
setup_review_map_buffer = setup_review_map_buffer,
516561
}
517562
end
518563

@@ -864,7 +909,12 @@ local function complete_diff_command(arglead, cmdline, cursorpos)
864909
return matches
865910
end
866911

867-
---@type fun(spec?: diffs.ReviewSpec, opts?: { selection?: diffs.GeneratedFileSelection, replace_win?: integer }): integer?
912+
---@class diffs.OpenReviewSplitOpts
913+
---@field selection? diffs.GeneratedFileSelection
914+
---@field replace_win? integer
915+
---@field map_layout? diffs.ReviewMapLayout
916+
917+
---@type fun(spec?: diffs.ReviewSpec, opts?: diffs.OpenReviewSplitOpts): integer?
868918
local open_review_split
869919

870920
---@param args? string
@@ -889,6 +939,7 @@ function M.review_command(args, vertical, opts)
889939
parsed.spec.vertical = vertical or false
890940
local bufnr = M.review(parsed.spec, {
891941
rail_style = rail_style_for_layout(parsed.layout),
942+
review_layout = normalize_review_map_layout(parsed.layout),
892943
})
893944
return bufnr
894945
end
@@ -1061,6 +1112,7 @@ end
10611112
---@field replace_win integer
10621113
---@field selected diffs.GeneratedFileSelection
10631114
---@field repo_root string
1115+
---@field map_layout diffs.ReviewMapLayout
10641116

10651117
---@param opts? diffs.ReviewSplitOpts
10661118
---@return diffs.ReviewSplitContext?, string?, integer?, integer?
@@ -1093,6 +1145,8 @@ local function review_split_context(opts)
10931145
if not source or source.kind ~= 'review' then
10941146
return nil, 'selected file is not from a review buffer', vim.log.levels.WARN, nil
10951147
end
1148+
local marker = vim.b[review_buf].diffs_review
1149+
local map_layout = normalize_review_map_layout(type(marker) == 'table' and marker.layout or nil)
10961150

10971151
local review_win = first_window_for_buffer(review_buf)
10981152
if not review_win then
@@ -1107,6 +1161,7 @@ local function review_split_context(opts)
11071161
replace_win = review_win,
11081162
selected = selected,
11091163
repo_root = source.repo_root,
1164+
map_layout = map_layout,
11101165
},
11111166
nil,
11121167
nil,
@@ -1231,6 +1286,14 @@ local function setup_review_split_panes(state)
12311286
{ buffer = buf, remap = true }
12321287
)
12331288
end
1289+
if not get_buffer_keymap(buf, 'n', 'gs') then
1290+
vim.keymap.set(
1291+
'n',
1292+
'gs',
1293+
'<Plug>(diffs-review-toggle-layout)',
1294+
{ buffer = buf, remap = true }
1295+
)
1296+
end
12341297
end
12351298
end
12361299
end
@@ -1597,7 +1660,7 @@ local function close_existing_review_splits()
15971660
end
15981661

15991662
---@param spec diffs.ReviewSpec?
1600-
---@param opts? { selection?: diffs.GeneratedFileSelection, replace_win?: integer }
1663+
---@param opts? diffs.OpenReviewSplitOpts
16011664
---@return integer?
16021665
open_review_split = function(spec, opts)
16031666
opts = opts or {}
@@ -1684,6 +1747,7 @@ open_review_split = function(spec, opts)
16841747
display = normalized.display,
16851748
review_lines = review_lines,
16861749
list_opts = list_opts,
1750+
map_layout = normalize_review_map_layout(opts.map_layout),
16871751
selected_file = first.file,
16881752
selected_key = first.key or first.file,
16891753
selected_diff_spec = diff_spec,
@@ -1734,9 +1798,107 @@ function M.review_split(opts)
17341798
return open_review_split(review_spec, {
17351799
selection = context.selected,
17361800
replace_win = context.replace_win,
1801+
map_layout = context.map_layout,
17371802
})
17381803
end
17391804

1805+
---@param state diffs.ReviewSplitState
1806+
---@return integer?
1807+
local function review_split_keep_window(state)
1808+
local current_win = vim.api.nvim_get_current_win()
1809+
local current_buf = vim.api.nvim_get_current_buf()
1810+
if
1811+
(current_buf == state.left_buf or current_buf == state.right_buf)
1812+
and vim.api.nvim_win_is_valid(current_win)
1813+
then
1814+
return current_win
1815+
end
1816+
for _, win in ipairs({ state.left_win, state.right_win }) do
1817+
if vim.api.nvim_win_is_valid(win) then
1818+
local buf = vim.api.nvim_win_get_buf(win)
1819+
if buf == state.left_buf or buf == state.right_buf then
1820+
return win
1821+
end
1822+
end
1823+
end
1824+
return first_window_for_buffer(state.left_buf) or first_window_for_buffer(state.right_buf)
1825+
end
1826+
1827+
---@param bufnr integer
1828+
---@param selected_file string
1829+
---@param selected_key string?
1830+
---@return boolean
1831+
local function goto_review_map_selection(bufnr, selected_file, selected_key)
1832+
local win = first_window_for_buffer(bufnr)
1833+
if not win then
1834+
return false
1835+
end
1836+
for _, item in ipairs(vim.fn.getqflist({ items = 0 }).items) do
1837+
local data = item.user_data and item.user_data.diffs
1838+
if item.bufnr == bufnr and type(data) == 'table' then
1839+
local matches = selected_key and data.key == selected_key
1840+
or (not selected_key and data.file == selected_file)
1841+
if matches then
1842+
vim.api.nvim_set_current_win(win)
1843+
vim.api.nvim_win_set_cursor(win, { math.max(1, item.lnum or 1), 0 })
1844+
vim.api.nvim_exec_autocmds('CursorMoved', { buffer = bufnr })
1845+
return true
1846+
end
1847+
end
1848+
end
1849+
return false
1850+
end
1851+
1852+
---@param state diffs.ReviewSplitState
1853+
---@param layout diffs.ReviewMapLayout
1854+
---@return integer?
1855+
local function open_review_map_from_split(state, layout)
1856+
local keep_win = review_split_keep_window(state)
1857+
if not keep_win then
1858+
notify('review split window is no longer visible', vim.log.levels.WARN)
1859+
return nil
1860+
end
1861+
local source_buf = vim.api.nvim_win_get_buf(keep_win)
1862+
local selected_file = state.selected_file
1863+
local selected_key = state.selected_key
1864+
local review_spec = vim.deepcopy(state.review)
1865+
review_spec.repo = state.repo_root
1866+
forget_review_split(state)
1867+
local split_buffers = split.close_pair_into_window(source_buf, keep_win)
1868+
if not split_buffers then
1869+
review_split_states[state.left_buf] = state
1870+
review_split_states[state.right_buf] = state
1871+
attach_review_split_autocmds(state)
1872+
notify('cannot close review split before opening review map', vim.log.levels.WARN)
1873+
return nil
1874+
end
1875+
local bufnr = M.review(review_spec, {
1876+
rail_style = rail_style_for_layout(layout),
1877+
review_layout = layout,
1878+
replace_win = keep_win,
1879+
})
1880+
split.delete_pair_buffers(split_buffers)
1881+
if bufnr then
1882+
goto_review_map_selection(bufnr, selected_file, selected_key)
1883+
end
1884+
return bufnr
1885+
end
1886+
1887+
---@class diffs.ReviewToggleLayoutOpts
1888+
---@field bufnr? integer
1889+
1890+
---@param opts? diffs.ReviewToggleLayoutOpts
1891+
---@return integer?
1892+
function M.review_toggle_layout(opts)
1893+
opts = opts or {}
1894+
local bufnr = opts.bufnr or vim.api.nvim_get_current_buf()
1895+
local state = resolve_review_split(bufnr)
1896+
if state then
1897+
return open_review_map_from_split(state, state.map_layout)
1898+
end
1899+
return M.review_split({ bufnr = bufnr })
1900+
end
1901+
17401902
---@param args? string
17411903
---@param vertical? boolean
17421904
---@param opts? { warn_vertical_split?: boolean }

lua/diffs/init.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
---@field review_goto fun(target: string, bufnr?: integer): boolean
77
---@field review_next_file fun()
88
---@field review_prev_file fun()
9+
---@field review_toggle_layout fun(opts?: diffs.ReviewToggleLayoutOpts): integer?
910
---@field select_review_file fun(bufnr?: integer)
1011

1112
local commands = require('diffs.commands')
@@ -20,5 +21,6 @@ return {
2021
review_goto = commands.review_goto,
2122
review_next_file = commands.review_next_file,
2223
review_prev_file = commands.review_prev_file,
24+
review_toggle_layout = commands.review_toggle_layout,
2325
select_review_file = commands.select_review_file,
2426
}

0 commit comments

Comments
 (0)