diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 90ce3a0..f44ff38 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -1271,6 +1271,7 @@ A `diffs.ReviewFile` describes one changed file in a review: ~ {section_label} (string|nil) Display label, e.g. `Unstaged`. {added} (integer) Added line count. {removed} (integer) Removed line count. + {skipped} (boolean) True when split review navigation skips this file. review_files({bufnr}) *diffs.nvim.review_files()* List the changed files in the review split {bufnr} belongs to, as @@ -1290,7 +1291,7 @@ review_current({bufnr}) *diffs.nvim.review_current()* review_goto({target}, {bufnr}) *diffs.nvim.review_goto()* Switch the review split {bufnr} belongs to so it shows {target}, swapping both panes in place. Returns `true` on success, `false` when {bufnr} is not - a review split or no file matches. Never opens a split. + a review split, no file matches, or {target} is skipped. Never opens a split. Parameters: ~ {target} (string) A |diffs.ReviewFile| `key` (preferred) or path. @@ -1299,12 +1300,13 @@ review_goto({target}, {bufnr}) *diffs.nvim.review_goto()* review_next_file() *diffs.nvim.review_next_file()* review_prev_file() *diffs.nvim.review_prev_file()* Step the current review split to the next/previous changed file, wrapping - at the ends. Backing functions for `]f`/`[f`, + at the ends and skipping unsupported files. Backing functions for `]f`/`[f`, |(diffs-review-next-file)|, and |(diffs-review-prev-file)|. select_review_file({bufnr}) *diffs.nvim.select_review_file()* Prompt with |vim.ui.select()| over the review split's files and jump to the - chosen one. Backing function for |(diffs-review-select-file)| (`gO`). + chosen one. Skipped files are shown as `(skipped)` and do not jump. Backing + function for |(diffs-review-select-file)| (`gO`). It uses whatever |vim.ui.select()| backend you have configured, so no extra dependency is required. To build a bespoke picker, call the data and action functions directly, capturing the review buffer before the picker opens: >lua diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua index 2d2a967..b757467 100644 --- a/lua/diffs/commands.lua +++ b/lua/diffs/commands.lua @@ -1159,6 +1159,7 @@ local function review_generated_list_opts(list_opts) metadata_for_line = list_opts and list_opts.metadata_for_line, sections = list_opts and list_opts.sections, store_hunks = list_opts and list_opts.store_hunks, + is_skipped = list_opts and list_opts.is_skipped, } end @@ -1166,27 +1167,34 @@ end ---@param repo_root string ---@param review_lines string[] ---@param list_opts table? ----@return diffs.GeneratedFileSelection?, diffs.DiffSpec?, string[]?, string?, integer? +---@return diffs.GeneratedFileSelection?, diffs.DiffSpec?, string[]?, string?, integer?, diffs.GeneratedFileSelection[] local function first_renderable_review_file(review_spec, repo_root, review_lines, list_opts) local last_err local last_level + local skipped = {} for _, selected in ipairs(lists.generated_files(review_lines, review_generated_list_opts(list_opts))) do - local diff_spec, diff_lines, err, level = - render_review_file_selection(review_spec, repo_root, selected) - if diff_spec and diff_lines then - return selected, diff_spec, diff_lines, nil, nil + if selected.skipped then + skipped[#skipped + 1] = selected + else + local diff_spec, diff_lines, err, level = + render_review_file_selection(review_spec, repo_root, selected) + if diff_spec and diff_lines then + return selected, diff_spec, diff_lines, nil, nil, skipped + end + skipped[#skipped + 1] = selected + last_err = err + last_level = level end - last_err = err - last_level = level end return nil, nil, nil, last_err or 'no renderable review file selected', - last_level or vim.log.levels.INFO + last_level or vim.log.levels.INFO, + skipped end ---@param state diffs.ReviewSplitState @@ -1366,17 +1374,22 @@ local function step_review_split_file(state, delta) local skipped = {} for offset = 1, count - 1 do local target = ((current - 1 + delta * offset) % count) + 1 - local switched, unsupported = switch_review_split_file(state, files[target], { quiet = true }) - if switched then - notify_skipped_review_files(skipped) - announce_review_file(files[target], target, count) - return - end - if not unsupported then - notify_skipped_review_files(skipped) - return + local selected = files[target] + if selected.skipped then + skipped[#skipped + 1] = selected + else + local switched, unsupported = switch_review_split_file(state, selected, { quiet = true }) + if switched then + notify_skipped_review_files(skipped) + announce_review_file(selected, target, count) + return + end + if not unsupported then + notify_skipped_review_files(skipped) + return + end + skipped[#skipped + 1] = selected end - skipped[#skipped + 1] = files[target] end notify_skipped_review_files(skipped) end @@ -1404,6 +1417,7 @@ end ---@field section_label? string ---@field added integer ---@field removed integer +---@field skipped boolean ---@param selection diffs.GeneratedFileSelection ---@return diffs.ReviewFile @@ -1415,6 +1429,7 @@ local function project_review_file(selection) section_label = selection.section_label, added = selection.added or 0, removed = selection.removed or 0, + skipped = selection.skipped == true, } end @@ -1467,12 +1482,58 @@ function M.review_goto(target, bufnr) if selection.key == state.selected_key or selection.file == state.selected_file then return true end + if selection.skipped then + notify_skipped_review_files({ selection }) + return false + end return goto_selection(state, selection, i, #files) end end return false end +---@param file diffs.ReviewFile +---@return string +local function review_file_label(file) + local path = file.skipped and (file.path .. ' (skipped)') or file.path + if file.section_label and file.section_label ~= '' then + return ('[%s] %s'):format(file.section_label, path) + end + return path +end + +---@param files diffs.ReviewFile[] +---@return fun(file: diffs.ReviewFile): string +local function review_file_formatter(files) + local labels = {} + local max_label, max_add, max_del = 0, 0, 0 + for _, file in ipairs(files) do + local label = review_file_label(file) + labels[file.key] = label + max_label = math.max(max_label, #label) + if file.added > 0 then + max_add = math.max(max_add, #tostring(file.added) + 1) + end + if file.removed > 0 then + max_del = math.max(max_del, #tostring(file.removed) + 1) + end + end + return function(file) + local label = labels[file.key] or review_file_label(file) + local parts = { label .. string.rep(' ', max_label - #label) } + if max_add > 0 then + parts[#parts + 1] = file.added > 0 and string.format('%' .. max_add .. 's', '+' .. file.added) + or string.rep(' ', max_add) + end + if max_del > 0 then + parts[#parts + 1] = file.removed > 0 + and string.format('%' .. max_del .. 's', '-' .. file.removed) + or string.rep(' ', max_del) + end + return (table.concat(parts, ' '):gsub('%s+$', '')) + end +end + ---@param bufnr integer? Defaults to the current buffer. function M.select_review_file(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() @@ -1482,12 +1543,7 @@ function M.select_review_file(bufnr) end vim.ui.select(files, { prompt = 'Review file:', - format_item = function(file) - local label = (file.section_label and file.section_label ~= '') - and ('[%s] %s'):format(file.section_label, file.path) - or file.path - return ('%s +%d -%d'):format(label, file.added, file.removed) - end, + format_item = review_file_formatter(files), }, function(choice) if choice then M.review_goto(choice.key, bufnr) @@ -1535,7 +1591,12 @@ open_review_split = function(spec, opts) local review_spec = stored_review_spec(normalized) local first = opts.selection local diff_spec, diff_lines + local skipped = {} if first then + if first.skipped then + notify_skipped_review_files({ first }) + return nil + end local err, level diff_spec, diff_lines, err, level = render_review_file_selection(review_spec, normalized.repo_root, first) @@ -1545,10 +1606,13 @@ open_review_split = function(spec, opts) end else local err, level - first, diff_spec, diff_lines, err, level = + first, diff_spec, diff_lines, err, level, skipped = first_renderable_review_file(review_spec, normalized.repo_root, review_lines, list_opts) if not first or not diff_spec or not diff_lines then - notify(err or 'no review file selected', level or vim.log.levels.INFO) + notify_skipped_review_files(skipped) + if #skipped == 0 then + notify(err or 'no review file selected', level or vim.log.levels.INFO) + end return nil end end @@ -1608,6 +1672,7 @@ open_review_split = function(spec, opts) break end end + notify_skipped_review_files(skipped) announce_review_file(first, index, #files) dbg('opened review split %d/%d (%s)', opened.left_buf, opened.right_buf, normalized.display) diff --git a/lua/diffs/lists.lua b/lua/diffs/lists.lua index 431b4b3..ba58bb0 100644 --- a/lua/diffs/lists.lua +++ b/lua/diffs/lists.lua @@ -18,6 +18,7 @@ local group = vim.api.nvim_create_augroup('diffs_generated_lists', { clear = fal ---@field sections? table[] ---@field store_hunks? boolean ---@field quickfix? boolean +---@field is_skipped? fun(entry: table): boolean ---@param bufnr integer ---@param name string @@ -100,6 +101,7 @@ local function ensure_file_entry(entries, by_file, file, lnum, meta) local entry = by_file[key] if entry then entry.lnum = math.min(entry.lnum, lnum) + entry.skipped = entry.skipped or meta.skipped == true return entry end @@ -113,6 +115,7 @@ local function ensure_file_entry(entries, by_file, file, lnum, meta) section = meta.section, section_label = meta.section_label, diff_spec = meta.diff_spec, + skipped = meta.skipped == true, } by_file[key] = entry entries[#entries + 1] = entry @@ -139,10 +142,11 @@ end ---@param entry table ---@return string local function entry_display_file(entry) + local file = entry.skipped and (entry.file .. ' (skipped)') or entry.file if type(entry.section_label) == 'string' and entry.section_label ~= '' then - return ('[%s] %s'):format(entry.section_label, entry.file) + return ('[%s] %s'):format(entry.section_label, file) end - return entry.file + return file end ---@param hunk diffs.DiffHunk? @@ -186,6 +190,18 @@ local function file_entries(hunks, diff_lines, opts) return entries end +---@param entries table[] +---@param opts? table +local function apply_skipped(entries, opts) + local is_skipped = opts and opts.is_skipped + if type(is_skipped) ~= 'function' then + return + end + for _, entry in ipairs(entries) do + entry.skipped = is_skipped(entry) == true + end +end + ---@param entries table[] local function format_file_text(entries) local max_fname = 0 @@ -236,6 +252,7 @@ local function quickfix_items(bufnr, entries) section = entry.section, section_label = entry.section_label, diff_spec = entry.diff_spec, + skipped = entry.skipped == true, }, }, } @@ -399,6 +416,7 @@ local function selection_from_entry(state, entry) hunk_index = file_hunk_index(state.hunks, hunk), added = entry.adds, removed = entry.dels, + skipped = entry.skipped == true, } end @@ -443,6 +461,7 @@ local function selection_from_item(item) lnum = item.lnum, hunk = hunk, hunk_index = state and file_hunk_index(state.hunks, hunk) or nil, + skipped = data.skipped == true, }, nil end @@ -482,6 +501,7 @@ local function list_state(diff_lines, opts) local diff_spec = opts.diff_spec local hunks = hunk_model.parse(diff_lines, diff_spec) local entries = file_entries(hunks, diff_lines, opts) + apply_skipped(entries, opts) local title = opts.title or 'diffs' return { hunks = hunks, @@ -650,6 +670,7 @@ end ---@field hunk_index? integer ---@field added? integer ---@field removed? integer +---@field skipped? boolean ---@param opts? diffs.GeneratedFileSelectionOpts ---@return diffs.GeneratedFileSelection?, string? @@ -690,6 +711,7 @@ function M.selected_generated_file(opts) lnum = lnum, hunk = hunk, hunk_index = file_hunk_index(state.hunks, hunk), + skipped = entry.skipped == true, }, nil end diff --git a/lua/diffs/review.lua b/lua/diffs/review.lua index 2fea73f..d7394dc 100644 --- a/lua/diffs/review.lua +++ b/lua/diffs/review.lua @@ -11,6 +11,12 @@ local render = require('diffs.render') local dbg = log.dbg local notify = log.notify +---@param entry table +---@return boolean +local function is_skipped_entry(entry) + return type(entry.hunks) == 'table' and #entry.hunks == 0 +end + ---@class diffs.ReviewSpec ---@field base? string ---@field target? string @@ -639,6 +645,7 @@ local function run_current_state(review, deps) metadata_for_line = metadata_for_records(state.records), sections = state.sections, store_hunks = true, + is_skipped = is_skipped_entry, } end @@ -769,7 +776,7 @@ function M.render(review, deps) end local result, err = M.run(review, deps) - return result, err, nil + return result, err, { is_skipped = is_skipped_entry } end ---@param spec? diffs.ReviewSpec @@ -824,6 +831,7 @@ function M.open(spec, deps) metadata_for_line = list_opts and list_opts.metadata_for_line, sections = list_opts and list_opts.sections, store_hunks = list_opts and list_opts.store_hunks, + is_skipped = list_opts and list_opts.is_skipped, quickfix = true, }) diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua index e765e6e..dc237be 100644 --- a/spec/commands_spec.lua +++ b/spec/commands_spec.lua @@ -3397,7 +3397,7 @@ describe('commands', function() assert.is_true(right_row >= 1) end - local function create_mode_first_review_repo() + local function create_binary_first_review_repo() local repo_root = vim.fn.tempname() vim.fn.mkdir(repo_root, 'p') test_repos[#test_repos + 1] = repo_root @@ -3406,19 +3406,18 @@ describe('commands', function() assert.are.equal(0, vim.v.shell_error) git_cmd(repo_root, { 'config', 'user.email', 'test@example.com' }) git_cmd(repo_root, { 'config', 'user.name', 'Test' }) - git_cmd(repo_root, { 'config', 'core.filemode', 'true' }) - write_repo_file(repo_root, 'aaa-mode.sh', { '#!/bin/sh', 'echo mode' }) + write_binary_file(repo_root .. '/aaa-bin.dat', 'binary\\000old') write_repo_file(repo_root, 'zzz-changed.lua', { 'old' }) - git_cmd(repo_root, { 'add', 'aaa-mode.sh', 'zzz-changed.lua' }) + git_cmd(repo_root, { 'add', 'aaa-bin.dat', 'zzz-changed.lua' }) git_cmd(repo_root, { 'commit', '-qm', 'base' }) - git_cmd(repo_root, { 'branch', 'mode-base' }) + git_cmd(repo_root, { 'branch', 'binary-first-base' }) - git_cmd(repo_root, { 'checkout', '-qb', 'mode-topic' }) - git_cmd(repo_root, { 'update-index', '--chmod=+x', 'aaa-mode.sh' }) + git_cmd(repo_root, { 'checkout', '-qb', 'binary-first-topic' }) + write_binary_file(repo_root .. '/aaa-bin.dat', 'binary\\000new') write_repo_file(repo_root, 'zzz-changed.lua', { 'new' }) - git_cmd(repo_root, { 'add', 'aaa-mode.sh', 'zzz-changed.lua' }) - git_cmd(repo_root, { 'commit', '-qm', 'mode and content' }) + git_cmd(repo_root, { 'add', 'aaa-bin.dat', 'zzz-changed.lua' }) + git_cmd(repo_root, { 'commit', '-qm', 'binary and content' }) return repo_root end @@ -3633,23 +3632,49 @@ describe('commands', function() end) it('skips unsupported default review entries when opening direct split workspaces', function() - local repo_root = create_mode_first_review_repo() + local repo_root = create_binary_first_review_repo() edit_file(repo_root .. '/zzz-changed.lua') + local notifications = capture_notifications() mock_runtime_attach(function() end) - local left_buf = commands.review_command('++layout=split mode-base..mode-topic') + local left_buf = + commands.review_command('++layout=split binary-first-base..binary-first-topic') local panes = track_panes(left_buf) assert.are.same( - diffspec.rev_to_rev('mode-base', 'mode-topic', 'zzz-changed.lua'), + diffspec.rev_to_rev('binary-first-base', 'binary-first-topic', 'zzz-changed.lua'), vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_spec') ) assert.are.same( - diffspec.rev_to_rev('mode-base', 'mode-topic', 'zzz-changed.lua'), + diffspec.rev_to_rev('binary-first-base', 'binary-first-topic', 'zzz-changed.lua'), vim.api.nvim_buf_get_var(panes.right_buf, 'diffs_spec') ) assert.are.same({ 'old' }, vim.api.nvim_buf_get_lines(panes.left_buf, 0, -1, false)) assert.are.same({ 'new' }, vim.api.nvim_buf_get_lines(panes.right_buf, 0, -1, false)) + assert.are.equal('[diffs]: review skipped 1 file(s): aaa-bin.dat', notifications[1].message) + end) + + it('marks skipped review files in the quickfix file index', function() + local repo_root = create_binary_review_repo() + mock_runtime_attach(function() end) + + local bufnr = commands.review({ + base = 'binary-base', + target = 'binary-topic', + mode = 'direct', + repo = repo_root, + }) + assert.is_not_nil(bufnr) + table.insert(test_buffers, bufnr) + + local qf = quickfix_items() + assert.are.equal(3, #qf) + assert.is_true(qf[1].text:match('^aaa%-one%.lua%s+%+1%s+%-1$') ~= nil) + assert.are.equal('bbb-bin.dat (skipped)', qf[2].text) + assert.is_true(qf[3].text:match('^ccc%-two%.lua%s+%+1%s+%-1$') ~= nil) + assert.is_false(qf[1].user_data.diffs.skipped) + assert.is_true(qf[2].user_data.diffs.skipped) + assert.is_false(qf[3].user_data.diffs.skipped) end) it('routes current-state duplicate paths by section in the two-surface workspace', function() @@ -3821,6 +3846,43 @@ describe('commands', function() assert.are.equal('[diffs]: review skipped 1 file(s): bbb-bin.dat', notifications[1].message) end) + it('marks skipped review files for the API and picker', function() + local repo_root = create_binary_review_repo() + edit_file(repo_root .. '/aaa-one.lua') + local notifications = capture_notifications() + mock_runtime_attach(function() end) + + local left_buf = commands.review_command('++layout=split binary-base..binary-topic') + local panes = track_panes(left_buf) + local files = commands.review_files(panes.left_buf) + assert.are.equal(3, #files) + assert.is_false(files[1].skipped) + assert.is_true(files[2].skipped) + assert.is_false(files[3].skipped) + + local formatted = {} + local original = vim.ui.select + vim.ui.select = function(items, opts, on_choice) + formatted[1] = opts.format_item(items[1]) + formatted[2] = opts.format_item(items[2]) + formatted[3] = opts.format_item(items[3]) + on_choice(items[2]) + end + local ok, err = pcall(commands.select_review_file, panes.left_buf) + vim.ui.select = original + assert.is_true(ok, err) + + assert.is_true(formatted[1]:match('^aaa%-one%.lua%s+%+1%s+%-1$') ~= nil) + assert.are.equal('bbb-bin.dat (skipped)', formatted[2]) + assert.is_true(formatted[3]:match('^ccc%-two%.lua%s+%+1%s+%-1$') ~= nil) + assert.are.same( + diffspec.rev_to_rev('binary-base', 'binary-topic', 'aaa-one.lua'), + vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_spec') + ) + assert.are.equal(1, #notifications) + assert.are.equal('[diffs]: review skipped 1 file(s): bbb-bin.dat', notifications[1].message) + end) + it('exposes review_files/current/goto, the gO map, and the b:diffs_review marker', function() local repo = create_review_repo() edit_file(repo.repo_root .. '/lua/one.lua') @@ -4038,7 +4100,7 @@ describe('commands', function() ) end) - it('reports unsupported selected review files without opening a workspace', function() + it('reports skipped selected review files without opening a workspace', function() local repo = create_mode_only_review_repo() local notifications = capture_notifications() mock_runtime_attach(function() end) @@ -4059,8 +4121,9 @@ describe('commands', function() local opened = commands.review_split({ bufnr = bufnr }) assert.is_nil(opened) - assert.is_true( - notifications[#notifications].message:find('mode-only changes', 1, true) ~= nil + assert.are.equal( + '[diffs]: review skipped 1 file(s): scripts/tool.sh', + notifications[1].message ) assert.is_nil(commands._test.review_split_state(bufnr)) end) @@ -4106,10 +4169,12 @@ describe('commands', function() assert.are.equal(2, #qf) assert.are.equal(bufnr, qf[1].bufnr) assert.are.equal(1, qf[1].lnum) - assert.is_true(qf[1].text:find('lua/mode.lua', 1, true) ~= nil) + assert.is_true(qf[1].text:find('lua/mode.lua (skipped)', 1, true) ~= nil) + assert.is_true(qf[1].user_data.diffs.skipped) assert.are.equal(bufnr, qf[2].bufnr) assert.are.equal(4, qf[2].lnum) assert.is_true(qf[2].text:find('lua/changed.lua', 1, true) ~= nil) + assert.is_false(qf[2].user_data.diffs.skipped) assert.are.equal(0, #loclist_items())