From b0fa58ba89f8e8bc1649fab94ec631b59e9db9d9 Mon Sep 17 00:00:00 2001 From: Rohan Bansal Date: Sat, 25 Apr 2026 21:06:18 -0400 Subject: [PATCH 1/5] add merge completion actions --- lua/glance/config.lua | 15 ++ lua/glance/filetree.lua | 68 +++++++ lua/glance/git.lua | 51 +++++- lua/glance/merge/actions.lua | 4 + lua/glance/merge/init.lua | 264 +++++++++++++++++++++++++++- lua/glance/merge/model.lua | 71 +++++++- tests/helpers/repo.lua | 25 +++ tests/integration/diffview_spec.lua | 125 +++++++++++++ tests/integration/filetree_spec.lua | 21 +++ tests/unit/config_spec.lua | 9 + tests/unit/git_spec.lua | 84 +++++++++ tests/unit/merge_model_spec.lua | 60 +++++++ 12 files changed, 787 insertions(+), 10 deletions(-) diff --git a/lua/glance/config.lua b/lua/glance/config.lua index 583a72d..960e2bb 100644 --- a/lua/glance/config.lua +++ b/lua/glance/config.lua @@ -96,9 +96,14 @@ local ALLOWED_MERGE_KEYMAPS = { accept_theirs = true, accept_both_ours_then_theirs = true, accept_both_theirs_then_ours = true, + accept_all_ours = true, + accept_all_theirs = true, keep_base = true, reset_conflict = true, + reset_result = true, mark_resolved = true, + complete_merge = true, + continue_operation = true, } local MERGE_KEYMAP_ORDER = { @@ -106,9 +111,14 @@ local MERGE_KEYMAP_ORDER = { 'accept_theirs', 'accept_both_ours_then_theirs', 'accept_both_theirs_then_ours', + 'accept_all_ours', + 'accept_all_theirs', 'keep_base', 'reset_conflict', + 'reset_result', 'mark_resolved', + 'complete_merge', + 'continue_operation', } local ALLOWED_FILETREE_WINDOW = { @@ -254,9 +264,14 @@ local BASE_DEFAULTS = { accept_theirs = 't', accept_both_ours_then_theirs = 'O', accept_both_theirs_then_ours = 'T', + accept_all_ours = 'ao', + accept_all_theirs = 'at', keep_base = 'b', reset_conflict = 'r', + reset_result = 'R', mark_resolved = 'm', + complete_merge = 'c', + continue_operation = 'C', }, }, keymaps = { diff --git a/lua/glance/filetree.lua b/lua/glance/filetree.lua index 10c30b5..41b5c42 100644 --- a/lua/glance/filetree.lua +++ b/lua/glance/filetree.lua @@ -731,6 +731,23 @@ local function refresh_after_discard(active_file) end end +local function operation_label(context) + local labels = { + merge = 'merge', + rebase = 'rebase', + cherry_pick = 'cherry-pick', + revert = 'revert', + } + return labels[context and context.kind] or 'operation' +end + +local function notify_operation_continue_error(context, err) + vim.notify( + 'glance: failed to continue ' .. operation_label(context) .. ': ' .. tostring(err), + vim.log.levels.ERROR + ) +end + function M.discard_selected_file() local file = M.get_selected_file() if not file then @@ -956,6 +973,55 @@ function M.open_commit_editor() }) end +function M.continue_operation() + local git = require('glance.git') + local context = git.get_operation_context() + + if not context.kind then + vim.notify('glance: no merge operation is ready to continue', vim.log.levels.WARN) + return false + end + + local snapshot = git.get_status_snapshot() + if #((snapshot.files or {}).conflicts or {}) > 0 then + vim.notify('glance: resolve all conflicts before continuing ' .. operation_label(context), vim.log.levels.WARN) + return false + end + + if context.kind == 'merge' then + M.open_commit_editor() + return true + end + + if not confirm_action('Continue ' .. operation_label(context) .. '?', 'Continue') then + return false + end + + local ok, err = git.continue_operation(context) + if not ok then + notify_operation_continue_error(context, err) + M.refresh() + return false + end + + M.refresh() + + if #(M.files.conflicts or {}) > 0 then + vim.notify('glance: ' .. operation_label(context) .. ' stopped at new conflicts', vim.log.levels.WARN) + require('glance.ui').open_file(M.files.conflicts[1]) + return true + end + + local after_context = git.get_operation_context() + if after_context.kind then + vim.notify('glance: ' .. operation_label(context) .. ' is still in progress', vim.log.levels.INFO) + else + vim.notify('glance: ' .. operation_label(context) .. ' complete', vim.log.levels.INFO) + end + + return true +end + function M.open_log_view() local log_view = require('glance.log_view') if log_view.is_open() then @@ -970,6 +1036,7 @@ end function M.setup_keymaps() local opts = { noremap = true, silent = true, buffer = M.buf } local km = config.options.keymaps + local merge_km = config.options.merge.keymaps or {} vim.keymap.set('n', 'j', function() for _ = 1, vim.v.count1 do M.move_down() end end, opts) vim.keymap.set('n', 'k', function() for _ = 1, vim.v.count1 do M.move_up() end end, opts) @@ -988,6 +1055,7 @@ function M.setup_keymaps() vim.keymap.set('n', km.unstage_all, function() M.unstage_all() end, opts) vim.keymap.set('n', km.discard_file, function() M.discard_selected_file() end, opts) vim.keymap.set('n', km.discard_all, function() M.discard_all() end, opts) + vim.keymap.set('n', merge_km.continue_operation, function() M.continue_operation() end, opts) vim.keymap.set('n', km.open_file, function() local file = M.get_selected_file() if file then diff --git a/lua/glance/git.lua b/lua/glance/git.lua index bd1d560..dc7d139 100644 --- a/lua/glance/git.lua +++ b/lua/glance/git.lua @@ -147,8 +147,12 @@ local function run_git_capture_at_root_async(root, args, opts, callback) local cmd = { 'git', '-C', root } vim.list_extend(cmd, args) local schedule_callback = opts == nil or opts.schedule_callback ~= false + local system_opts = { text = true } + if opts and opts.env then + system_opts.env = opts.env + end - vim.system(cmd, { text = true }, function(result) + vim.system(cmd, system_opts, function(result) local output = result.stdout or '' local allowed_codes = (opts and opts.allowed_codes) or { 0 } local function deliver(...) @@ -179,7 +183,12 @@ local function run_git_capture_at_root(root, args, opts) local cmd = { 'git', '-C', root } vim.list_extend(cmd, args) - local result = vim.system(cmd, { text = true }):wait() + local system_opts = { text = true } + if opts and opts.env then + system_opts.env = opts.env + end + + local result = vim.system(cmd, system_opts):wait() local allowed_codes = (opts and opts.allowed_codes) or { 0 } local stdout = result.stdout or '' local stderr = result.stderr or '' @@ -1293,6 +1302,11 @@ function M.can_commit(files) return false, M.CONFLICT_COMMIT_MESSAGE end + local context = M.get_operation_context() + if context.kind == 'merge' then + return true + end + if #(files.staged or {}) == 0 then return false, M.NO_STAGED_COMMIT_MESSAGE end @@ -1423,6 +1437,19 @@ function M.stage_file(file) return run_git(args) end +--- Stage a completed merge result for a conflicted path. +--- This intentionally bypasses ordinary stage safety, because resolving an +--- unmerged index entry is exactly the operation merge completion needs. +--- @param file { path: string }|nil +--- @return boolean, string|nil +function M.stage_merge_result(file) + if type(file) ~= 'table' or type(file.path) ~= 'string' or file.path == '' then + return false, 'invalid file target' + end + + return run_git({ 'add', '--', file.path }) +end + --- Unstage all git-visible changes for a single file path set. --- @param file { path: string, old_path: string|nil }|nil --- @return boolean, string|nil @@ -1498,6 +1525,26 @@ function M.commit(message, files) return true end +--- Continue the active sequencer-style Git operation. +--- @param context table|nil +--- @return boolean, string|nil +function M.continue_operation(context) + context = context or M.get_operation_context() + local kind = context and context.kind or nil + + if kind == 'rebase' then + return run_git({ '-c', 'core.editor=true', 'rebase', '--continue' }) + end + if kind == 'cherry_pick' then + return run_git({ '-c', 'core.editor=true', 'cherry-pick', '--continue' }) + end + if kind == 'revert' then + return run_git({ '-c', 'core.editor=true', 'revert', '--continue' }) + end + + return false, 'no continuable git operation is active' +end + --- Discard all git-visible changes for a single file path. --- This restores tracked files to HEAD and removes new files not present in HEAD. --- @param file { path: string, old_path: string|nil }|nil diff --git a/lua/glance/merge/actions.lua b/lua/glance/merge/actions.lua index 956c0ad..47003a9 100644 --- a/lua/glance/merge/actions.lua +++ b/lua/glance/merge/actions.lua @@ -59,4 +59,8 @@ function M.apply(merge_model, index, action) return model.apply_action(merge_model, index, action) end +function M.apply_all(merge_model, action) + return model.apply_all(merge_model, action) +end + return M diff --git a/lua/glance/merge/init.lua b/lua/glance/merge/init.lua index d1936b3..d4171e1 100644 --- a/lua/glance/merge/init.lua +++ b/lua/glance/merge/init.lua @@ -93,6 +93,21 @@ local function first_unresolved_index() return unresolved[1] end +local function manual_unresolved_count() + local count = 0 + if not state.model then + return count + end + + for _, conflict in ipairs(state.model.conflicts) do + if conflict.state == 'manual_unresolved' then + count = count + 1 + end + end + + return count +end + local function conflict_index_for_result_line(line) if not state.model then return nil @@ -152,6 +167,90 @@ local function write_text(path, text) file:close() end +local function confirm_action(message, accept_label) + return vim.fn.confirm(message, '&' .. accept_label .. '\n&Cancel', 2) == 1 +end + +local function display_key(lhs) + if type(lhs) ~= 'string' then + return '' + end + + return lhs:gsub('', '\\'):gsub('', '\\') +end + +local function operation_label(context) + local labels = { + merge = 'merge', + rebase = 'rebase', + cherry_pick = 'cherry-pick', + revert = 'revert', + } + return labels[context and context.kind] or 'operation' +end + +local function notify_post_complete(context, files) + files = files or {} + local conflicts = files.conflicts or {} + if #conflicts > 0 then + local noun = #conflicts == 1 and 'file' or 'files' + local verb = #conflicts == 1 and 'remains' or 'remain' + vim.notify( + string.format('glance: merge result staged; %d conflicted %s %s', #conflicts, noun, verb), + vim.log.levels.INFO + ) + return + end + + if context and context.kind == 'merge' then + vim.notify('glance: all merge conflicts are resolved; press c to commit staged changes', vim.log.levels.INFO) + return + end + + if context and context.kind then + local key = display_key(config.options.merge.keymaps.continue_operation) + if key ~= '' then + vim.notify( + string.format('glance: all %s conflicts are resolved; press %s to continue', operation_label(context), key), + vim.log.levels.INFO + ) + else + vim.notify( + string.format('glance: all %s conflicts are resolved; continue the Git operation from the filetree', operation_label(context)), + vim.log.levels.INFO + ) + end + return + end + + vim.notify('glance: merge result staged', vim.log.levels.INFO) +end + +local function set_result_from_model(diffview, merge_model, opts) + opts = opts or {} + local buf = result_buf(diffview) + if not buf then + return false + end + + state.sync_in_progress = true + vim.api.nvim_buf_set_lines(buf, 0, -1, false, merge_model.result_lines or {}) + vim.api.nvim_set_option_value('endofline', merge_model.result_ends_with_newline ~= false, { buf = buf }) + if opts.modified ~= nil then + vim.api.nvim_set_option_value('modified', opts.modified, { buf = buf }) + end + state.sync_in_progress = false + + state.model = merge_model + render.apply(diffview, panes(diffview), merge_model, state.file, { + refresh_sources = false, + refresh_result = false, + active_conflict_index = state.active_conflict_index, + }) + refresh_decorations(diffview) + return true +end + local function edit_result_buffer(diffview, file) local root = git.repo_root() if not root then @@ -291,6 +390,113 @@ local function apply_action(diffview, action) return true end +local function apply_all(diffview, action) + if not state.model then + return false + end + + local applied, err = actions.apply_all(state.model, action) + if not applied then + vim.notify('glance: failed to apply merge action: ' .. tostring(err), vim.log.levels.WARN) + return false + end + + if not set_result_from_model(diffview, applied.model, { modified = true }) then + return false + end + + vim.notify( + string.format('glance: applied %d conflict%s, skipped %d', applied.applied, applied.applied == 1 and '' or 's', applied.skipped), + vim.log.levels.INFO + ) + + local first = first_unresolved_index() + if first then + M.jump_to_conflict(diffview, first) + else + focus_result(diffview) + end + return true +end + +local function reset_result(diffview) + if not state.file then + return false + end + + if not confirm_action('Reset merge result for ' .. state.file.path .. '?', 'Reset') then + return false + end + + local reset_model, err = model.reset_result(state.file) + if not reset_model then + vim.notify('glance: failed to reset merge result: ' .. tostring(err), vim.log.levels.WARN) + return false + end + + state.active_conflict_index = nil + if not set_result_from_model(diffview, reset_model, { modified = true }) then + return false + end + + local first = first_unresolved_index() or 1 + if not M.jump_to_conflict(diffview, first) then + focus_result(diffview) + end + return true +end + +local function write_result_if_modified(diffview) + local buf = result_buf(diffview) + if not buf then + return false, 'merge result buffer is not available' + end + + if not result_modified(diffview) then + return true + end + + local ok, err = xpcall(function() + vim.api.nvim_buf_call(buf, function() + vim.cmd('write') + end) + end, debug.traceback) + if not ok then + return false, tostring(err) + end + + if vim.api.nvim_get_option_value('modified', { buf = buf }) then + return false, 'merge result still has unsaved changes' + end + + return true +end + +local function handle_action_keymap(diffview, action) + if action == 'accept_all_ours' then + apply_all(diffview, 'accept_ours') + return + end + if action == 'accept_all_theirs' then + apply_all(diffview, 'accept_theirs') + return + end + if action == 'reset_result' then + reset_result(diffview) + return + end + if action == 'complete_merge' then + M.complete(diffview) + return + end + if action == 'continue_operation' then + filetree.continue_operation() + return + end + + apply_action(diffview, action) +end + local function bind_navigation_keymaps(diffview) for _, role in ipairs({ layout.THEIRS_ROLE, layout.OURS_ROLE, layout.RESULT_ROLE }) do local buf = workspace.get_buf(diffview.workspace, role) @@ -318,7 +524,7 @@ local function bind_action_keymaps(diffview) if buf and vim.api.nvim_buf_is_valid(buf) then for action, lhs in pairs(keymaps) do vim.keymap.set('n', lhs, function() - apply_action(diffview, action) + handle_action_keymap(diffview, action) end, { buffer = buf, silent = true, @@ -550,6 +756,62 @@ function M.refresh(diffview, file) return true end +function M.complete(diffview) + if not state.active or not state.file then + return false + end + + local wrote, write_err = write_result_if_modified(diffview) + if not wrote then + local manual_count = manual_unresolved_count() + if manual_count > 0 then + vim.notify( + string.format('glance: cannot complete merge; mark %d manual conflict%s resolved first', manual_count, manual_count == 1 and '' or 's'), + vim.log.levels.WARN + ) + else + vim.notify('glance: failed to complete merge: ' .. tostring(write_err), vim.log.levels.WARN) + end + return false + end + + local merge_model, sync_err = sync_from_result_buffer(diffview, state.file, state.model) + if not merge_model then + vim.notify('glance: failed to complete merge: ' .. tostring(sync_err), vim.log.levels.WARN) + return false + end + + if merge_model.unresolved_count > 0 then + local manual_count = manual_unresolved_count() + if manual_count > 0 then + vim.notify( + string.format('glance: cannot complete merge; mark %d manual conflict%s resolved first', manual_count, manual_count == 1 and '' or 's'), + vim.log.levels.WARN + ) + else + vim.notify( + string.format('glance: cannot complete merge; %d unresolved conflict%s remain', merge_model.unresolved_count, merge_model.unresolved_count == 1 and '' or 's'), + vim.log.levels.WARN + ) + end + return false + end + + local completed_file = state.file + local context = git.get_operation_context() + local ok, err = git.stage_merge_result(completed_file) + if not ok then + vim.notify('glance: failed to stage merge result: ' .. tostring(err), vim.log.levels.ERROR) + return false + end + + local snapshot = git.get_status_snapshot() + filetree.note_repo_activity() + diffview.close(false) + notify_post_complete(context, snapshot.files) + return true +end + function M.reset() state.active = false state.file = nil diff --git a/lua/glance/merge/model.lua b/lua/glance/merge/model.lua index e4c4142..cc1478b 100644 --- a/lua/glance/merge/model.lua +++ b/lua/glance/merge/model.lua @@ -927,12 +927,15 @@ local function build_model(file, opts) conflict.theirs_ends_with_newline = theirs_ends_with_newline end local resolved_stable_segments = stable_segments - local outcomes = infer_conflict_states_strict(stable_segments, conflicts, current_lines, opts) - if not outcomes then - resolved_stable_segments, outcomes = infer_conflict_states_relaxed(stable_segments, conflicts, current_lines, opts) - if stable_segments_contain_conflict_markers(resolved_stable_segments) then - resolved_stable_segments = stable_segments - outcomes = nil + local outcomes = nil + if not opts.force_unresolved then + outcomes = infer_conflict_states_strict(stable_segments, conflicts, current_lines, opts) + if not outcomes then + resolved_stable_segments, outcomes = infer_conflict_states_relaxed(stable_segments, conflicts, current_lines, opts) + if stable_segments_contain_conflict_markers(resolved_stable_segments) then + resolved_stable_segments = stable_segments + outcomes = nil + end end end @@ -987,7 +990,7 @@ local function build_model(file, opts) result_lines = result_lines, result_ends_with_newline = result_ends_with_newline, unresolved_count = unresolved_count, - inference_failed = outcomes == nil, + inference_failed = outcomes == nil and not opts.force_unresolved, } end @@ -1057,6 +1060,19 @@ local function build_persisted_lines(merge_model) return lines end +local function refresh_result_projection(merge_model, current_ends_with_newline) + local result_lines, unresolved_count, result_ends_with_newline = build_result_projection( + merge_model.stable_segments or merge_model.canonical_stable_segments or {}, + merge_model.conflicts or {}, + current_ends_with_newline + ) + + merge_model.result_lines = result_lines + merge_model.unresolved_count = unresolved_count + merge_model.result_ends_with_newline = result_ends_with_newline + return merge_model +end + local function finalize_conflict_action(conflict) if conflict.state == 'manual_resolved' then conflict.ours_handled = true @@ -1084,6 +1100,12 @@ function M.build(file, opts) return build_model(file, opts) end +function M.reset_result(file) + return build_model(file, { + force_unresolved = true, + }) +end + function M.apply_action(merge_model, index, action) if type(merge_model) ~= 'table' or type(merge_model.conflicts) ~= 'table' then return nil, 'merge model is not active' @@ -1147,6 +1169,41 @@ function M.apply_action(merge_model, index, action) return nil, 'unknown merge action' end +function M.apply_all(merge_model, action) + if action ~= 'accept_ours' and action ~= 'accept_theirs' then + return nil, 'unsupported accept-all action' + end + if type(merge_model) ~= 'table' or type(merge_model.conflicts) ~= 'table' then + return nil, 'merge model is not active' + end + if merge_model.inference_failed then + return nil, 'cannot safely apply bulk merge actions because result mapping is uncertain' + end + + local next_model = vim.deepcopy(merge_model) + local applied = 0 + local skipped = 0 + + for index, conflict in ipairs(next_model.conflicts) do + if conflict.state == 'unresolved' and not conflict.handled then + local _, err = M.apply_action(next_model, index, action) + if err then + return nil, err + end + applied = applied + 1 + else + skipped = skipped + 1 + end + end + + refresh_result_projection(next_model, next_model.current_ends_with_newline) + return { + model = next_model, + applied = applied, + skipped = skipped, + } +end + function M.prepare_write(file, current_lines, opts) opts = opts or {} local merge_model, err = build_model(file, { diff --git a/tests/helpers/repo.lua b/tests/helpers/repo.lua index e594ac4..767f510 100644 --- a/tests/helpers/repo.lua +++ b/tests/helpers/repo.lua @@ -275,6 +275,31 @@ function scenarios.repo_conflict_multi(fixture) assert(not ok, 'expected multi-conflict fixture') end +function scenarios.repo_conflict_two_files(fixture) + seed_committed_file(fixture, 'first.txt', 'first base\n', 'first') + fixture.files.second = 'second.txt' + fixture:write(fixture.files.second, 'second base\n') + fixture:stage(fixture.files.second) + fixture:git({ 'commit', '-m', 'Seed second conflict file' }) + fixture.files.tracked = fixture.files.first + local main_branch = vim.trim(fixture:git({ 'rev-parse', '--abbrev-ref', 'HEAD' })) + + fixture:git({ 'checkout', '-b', 'feature' }) + fixture:write(fixture.files.first, 'first feature\n') + fixture:write(fixture.files.second, 'second feature\n') + fixture:commit_all('Feature changes two files') + + fixture:git({ 'checkout', main_branch }) + fixture:write(fixture.files.first, 'first main\n') + fixture:write(fixture.files.second, 'second main\n') + fixture:commit_all('Main changes two files') + + local ok = pcall(function() + fixture:git({ 'merge', 'feature' }) + end) + assert(not ok, 'expected two-file merge conflict fixture') +end + function scenarios.repo_conflict_noeol(fixture) seed_committed_file(fixture, 'tracked.txt', 'base') local main_branch = vim.trim(fixture:git({ 'rev-parse', '--abbrev-ref', 'HEAD' })) diff --git a/tests/integration/diffview_spec.lua b/tests/integration/diffview_spec.lua index 35dc69f..c10704e 100644 --- a/tests/integration/diffview_spec.lua +++ b/tests/integration/diffview_spec.lua @@ -700,6 +700,131 @@ return { end) end, }, + { + name = 'merge accept-all and reset-result operate on the whole result buffer', + run = function() + N.with_repo('repo_conflict_multi', function() + require('glance').start() + local ui = require('glance.ui') + local filetree = require('glance.filetree') + local diffview = require('glance.diffview') + local workspace = require('glance.workspace') + + ui.open_file(filetree.files.conflicts[1]) + + local result_buf = workspace.get_buf(diffview.workspace, 'merge_result') + local result_win = workspace.get_win(diffview.workspace, 'merge_result') + + N.press('\\ao') + + A.same(vim.api.nvim_buf_get_lines(result_buf, 0, -1, false), { + 'intro', + 'first main', + 'gap one', + 'gap two', + 'gap three', + 'second main', + 'outro', + }) + A.contains(vim.api.nvim_get_option_value('winbar', { win = result_win }), '0 unresolved') + A.equal(vim.api.nvim_get_option_value('modified', { buf = result_buf }), true) + + N.with_confirm(1, function() + N.press('\\R') + end) + + A.same(vim.api.nvim_buf_get_lines(result_buf, 0, -1, false), { + 'intro', + 'first base', + 'gap one', + 'gap two', + 'gap three', + 'second base', + 'outro', + }) + A.contains(vim.api.nvim_get_option_value('winbar', { win = result_win }), '2 unresolved') + A.equal(vim.api.nvim_get_option_value('modified', { buf = result_buf }), true) + end) + end, + }, + { + name = 'complete merge stages the resolved file and returns to the filetree', + run = function() + N.with_repo('repo_conflict_two_files', function(repo) + require('glance').start() + local git = require('glance.git') + local ui = require('glance.ui') + local filetree = require('glance.filetree') + + A.equal(#filetree.files.conflicts, 2) + local first = filetree.files.conflicts[1] + ui.open_file(first) + + N.press('\\t') + N.press('\\c') + + local changed = git.get_changed_files() + A.falsy(ui.diff_open) + A.equal(vim.api.nvim_get_current_win(), filetree.win) + A.equal(#changed.conflicts, 1) + A.equal(changed.conflicts[1].path, repo.files.second) + A.equal(#changed.staged, 1) + A.equal(changed.staged[1].path, first.path) + A.equal(repo:read(first.path), 'first feature\n') + end) + end, + }, + { + name = 'complete merge refuses unresolved conflicts and manual edits that are not marked resolved', + run = function() + N.with_repo('repo_conflict', function() + require('glance').start() + local git = require('glance.git') + local ui = require('glance.ui') + local filetree = require('glance.filetree') + local diffview = require('glance.diffview') + local workspace = require('glance.workspace') + local messages, restore_notify = N.capture_notifications() + + ui.open_file(filetree.files.conflicts[1]) + N.press('\\c') + + local warned_unresolved = false + for _, entry in ipairs(messages) do + if entry.msg:find('unresolved conflict', 1, true) then + warned_unresolved = true + break + end + end + + A.truthy(warned_unresolved) + A.truthy(ui.diff_open) + A.equal(#git.get_changed_files().conflicts, 1) + + local result_buf = workspace.get_buf(diffview.workspace, 'merge_result') + vim.api.nvim_buf_set_lines(result_buf, 0, -1, false, { 'manual draft' }) + vim.api.nvim_exec_autocmds('TextChanged', { + buffer = result_buf, + modeline = false, + }) + N.press('\\c') + + restore_notify() + + local warned_manual = false + for _, entry in ipairs(messages) do + if entry.msg:find('mark 1 manual conflict resolved first', 1, true) then + warned_manual = true + break + end + end + + A.truthy(warned_manual) + A.truthy(ui.diff_open) + A.equal(#git.get_changed_files().conflicts, 1) + end) + end, + }, { name = 'type-changed files open a metadata panel instead of a diff', run = function() diff --git a/tests/integration/filetree_spec.lua b/tests/integration/filetree_spec.lua index 1e4203e..81c12fc 100644 --- a/tests/integration/filetree_spec.lua +++ b/tests/integration/filetree_spec.lua @@ -384,6 +384,27 @@ return { end) end, }, + { + name = 'continue operation key hands a resolved merge off to the commit editor', + run = function() + N.with_repo('repo_conflict', function(repo) + local git = require('glance.git') + local file = git.get_changed_files().conflicts[1] + + repo:write(repo.files.tracked, 'feature\n') + assert(git.stage_merge_result(file)) + + require('glance').start() + local commit_editor = require('glance.commit_editor') + local filetree = require('glance.filetree') + + vim.api.nvim_set_current_win(filetree.win) + N.press('\\C') + + A.truthy(commit_editor.is_open()) + end) + end, + }, { name = 'x submits the commit buffer without closing Glance', run = function() diff --git a/tests/unit/config_spec.lua b/tests/unit/config_spec.lua index 3de63ff..08d8daa 100644 --- a/tests/unit/config_spec.lua +++ b/tests/unit/config_spec.lua @@ -83,9 +83,14 @@ return { accept_theirs = 't', accept_both_ours_then_theirs = 'O', accept_both_theirs_then_ours = 'T', + accept_all_ours = 'ao', + accept_all_theirs = 'at', keep_base = 'b', reset_conflict = 'r', + reset_result = 'R', mark_resolved = 'm', + complete_merge = 'c', + continue_operation = 'C', }, }, keymaps = { @@ -192,6 +197,10 @@ return { A.equal(config.options.log.max_commits, 75) A.equal(config.options.merge.keymaps.accept_ours, 'go') A.equal(config.options.merge.keymaps.accept_theirs, 't') + A.equal(config.options.merge.keymaps.accept_all_ours, 'ao') + A.equal(config.options.merge.keymaps.reset_result, 'R') + A.equal(config.options.merge.keymaps.complete_merge, 'c') + A.equal(config.options.merge.keymaps.continue_operation, 'C') A.equal(config.options.windows.diff.relativenumber, false) A.equal(config.options.theme.preset, 'one_light') A.equal(config.options.theme.palette.logo, '#ffffff') diff --git a/tests/unit/git_spec.lua b/tests/unit/git_spec.lua index 54fdc4c..05cc39c 100644 --- a/tests/unit/git_spec.lua +++ b/tests/unit/git_spec.lua @@ -1259,6 +1259,90 @@ return { end) end, }, + { + name = 'stage_merge_result resolves an unmerged path into the index', + run = function() + N.with_repo('repo_conflict', function(repo) + local git = require('glance.git') + local file = git.get_changed_files().conflicts[1] + + repo:write(repo.files.tracked, 'feature\n') + local ok, err = git.stage_merge_result(file) + + A.truthy(ok, err) + local changed = git.get_changed_files() + A.same(changed.conflicts, {}) + A.equal(#changed.staged, 1) + A.equal(changed.staged[1].path, repo.files.tracked) + end) + end, + }, + { + name = 'commit supports resolved merge states even when the tree matches ours', + run = function() + N.with_repo('repo_conflict', function(repo) + local git = require('glance.git') + local file = git.get_changed_files().conflicts[1] + + repo:write(repo.files.tracked, 'main\n') + assert(git.stage_merge_result(file)) + + local changed = git.get_changed_files() + A.same(changed.conflicts, {}) + A.same(changed.staged, {}) + + local ok, err = git.can_commit(changed) + A.truthy(ok, err) + + ok, err = git.commit('Merge while keeping ours', changed) + A.truthy(ok, err) + A.equal(git.get_operation_context().kind, nil) + A.equal(vim.trim(repo:git({ 'log', '-1', '--pretty=%s' })), 'Merge while keeping ours') + end) + end, + }, + { + name = 'continue_operation completes a resolved rebase conflict without opening an editor', + run = function() + N.with_repo('repo_no_changes', function(repo) + local git = require('glance.git') + local main_branch = vim.trim(repo:git({ 'rev-parse', '--abbrev-ref', 'HEAD' })) + + repo:write(repo.files.tracked, 'base\n') + repo:commit_all('Normalize fixture') + + repo:git({ 'checkout', '-b', 'topic' }) + repo:write(repo.files.tracked, 'topic change\n') + repo:commit_all('Topic change') + + repo:git({ 'checkout', main_branch }) + repo:write(repo.files.tracked, 'main change\n') + repo:commit_all('Main change') + + repo:git({ 'checkout', 'topic' }) + local rebase_ok = pcall(function() + repo:git({ 'rebase', main_branch }) + end) + A.falsy(rebase_ok) + + local context = git.get_operation_context() + A.equal(context.kind, 'rebase') + + local file = git.get_changed_files().conflicts[1] + repo:write(repo.files.tracked, 'topic change\n') + local ok, err = git.stage_merge_result(file) + A.truthy(ok, err) + + ok, err = git.continue_operation(context) + A.truthy(ok, err) + + local changed = git.get_changed_files() + A.same(changed.conflicts, {}) + A.equal(git.get_operation_context().kind, nil) + A.equal(repo:read(repo.files.tracked), 'topic change\n') + end) + end, + }, { name = 'get_operation_context reports rebase conflict metadata from git sentinels', run = function() diff --git a/tests/unit/merge_model_spec.lua b/tests/unit/merge_model_spec.lua index 4ced79f..e35f103 100644 --- a/tests/unit/merge_model_spec.lua +++ b/tests/unit/merge_model_spec.lua @@ -680,5 +680,65 @@ return { end) end, }, + { + name = 'apply_all accepts only default unresolved conflicts and skips handled or manual conflicts', + run = function() + N.with_repo('repo_conflict_multi', function() + local git = require('glance.git') + local merge_model = require('glance.merge.model') + local file = git.get_changed_files().conflicts[1] + local built = assert(merge_model.build(file)) + + assert(merge_model.apply_action(built, 1, 'accept_theirs')) + built.conflicts[2].state = 'manual_unresolved' + built.conflicts[2].current_result_lines = { 'manual second' } + built.conflicts[2].current_lines = built.conflicts[2].current_result_lines + built.conflicts[2].current_kind = 'manual' + built.conflicts[2].handled = false + + local result = assert(merge_model.apply_all(built, 'accept_ours')) + + A.equal(result.applied, 0) + A.equal(result.skipped, 2) + A.equal(result.model.conflicts[1].state, 'theirs') + A.equal(result.model.conflicts[2].state, 'manual_unresolved') + A.same(result.model.result_lines, { + 'intro', + 'first feature', + 'gap one', + 'gap two', + 'gap three', + 'manual second', + 'outro', + }) + end) + end, + }, + { + name = 'reset_result rebuilds the default unresolved projection from stages', + run = function() + N.with_repo('repo_conflict_multi', function() + local git = require('glance.git') + local merge_model = require('glance.merge.model') + local file = git.get_changed_files().conflicts[1] + + local reset = assert(merge_model.reset_result(file)) + + A.equal(reset.unresolved_count, 2) + A.equal(reset.conflicts[1].state, 'unresolved') + A.equal(reset.conflicts[2].state, 'unresolved') + A.same(reset.result_lines, { + 'intro', + 'first base', + 'gap one', + 'gap two', + 'gap three', + 'second base', + 'outro', + }) + A.falsy(reset.inference_failed) + end) + end, + }, }, } From 1c0f50af2a8cf2c3074e41d47b1cf9474140d923 Mon Sep 17 00:00:00 2001 From: Rohan Bansal Date: Sat, 25 Apr 2026 22:28:05 -0400 Subject: [PATCH 2/5] latency improvements on file load --- lua/glance/merge/init.lua | 12 +++-- lua/glance/ui.lua | 71 ++++++++++++++++++----------- tests/integration/diffview_spec.lua | 28 ++++++++++++ tests/integration/ui_spec.lua | 31 +++++++++++++ 4 files changed, 111 insertions(+), 31 deletions(-) diff --git a/lua/glance/merge/init.lua b/lua/glance/merge/init.lua index d4171e1..b465d89 100644 --- a/lua/glance/merge/init.lua +++ b/lua/glance/merge/init.lua @@ -659,10 +659,14 @@ function M.hoverable_separator_wins(diffview) return layout.hoverable_separator_wins(diffview) end -local function rebuild(diffview, file) - local merge_model, err = model.build(file) +local function rebuild(diffview, file, existing_model) + local merge_model = existing_model if not merge_model then - return nil, err + local err + merge_model, err = model.build(file) + if not merge_model then + return nil, err + end end state.file = file @@ -697,7 +701,7 @@ function M.open(diffview, file) return end - rebuild(diffview, file) + rebuild(diffview, file, merge_model) local win = result_win(diffview) if win then diff --git a/lua/glance/ui.lua b/lua/glance/ui.lua index 6ec56f2..edf45c6 100644 --- a/lua/glance/ui.lua +++ b/lua/glance/ui.lua @@ -635,11 +635,24 @@ local function infer_kind(file) return 'unsupported' end +local function with_redraw_suppressed(fn) + local old_lazyredraw = vim.o.lazyredraw + local ok, err = xpcall(function() + vim.o.lazyredraw = true + fn() + end, debug.traceback) + + vim.o.lazyredraw = old_lazyredraw + if not ok then + error(err) + end + vim.cmd('redraw') +end + --- Open a file based on its classified git state. function M.open_file(file) local diffview = require('glance.diffview') local git = require('glance.git') - local is_binary = git.ensure_file_binary(file) local kind = infer_kind(file) -- Close any existing diff first @@ -647,33 +660,37 @@ function M.open_file(file) diffview.close() end - -- Close welcome pane to make room for diff - M.close_welcome() - - if is_binary then - diffview.open_binary(file) - elseif kind == 'deleted' then - diffview.open_deleted(file) - elseif kind == 'untracked' then - diffview.open_untracked(file) - elseif kind == 'added' and file.section ~= 'staged' then - diffview.open_untracked(file) - elseif kind == 'conflicted' then - diffview.open_conflict(file) - elseif kind == 'copied' then - diffview.open_copied(file) - elseif kind == 'type_changed' then - diffview.open_type_changed(file) - elseif kind == 'unsupported' then - diffview.open_placeholder(file) - else - diffview.open(file) - end + with_redraw_suppressed(function() + local is_binary = git.ensure_file_binary(file) + + -- Close welcome pane to make room for diff + M.close_welcome() + + if is_binary then + diffview.open_binary(file) + elseif kind == 'deleted' then + diffview.open_deleted(file) + elseif kind == 'untracked' then + diffview.open_untracked(file) + elseif kind == 'added' and file.section ~= 'staged' then + diffview.open_untracked(file) + elseif kind == 'conflicted' then + diffview.open_conflict(file) + elseif kind == 'copied' then + diffview.open_copied(file) + elseif kind == 'type_changed' then + diffview.open_type_changed(file) + elseif kind == 'unsupported' then + diffview.open_placeholder(file) + else + diffview.open(file) + end - M.diff_open = true - filetree.highlight_active(file) - filetree.note_repo_activity() - M.update_separator_hover() + M.diff_open = true + filetree.highlight_active(file) + filetree.note_repo_activity() + M.update_separator_hover() + end) end --- Close diff panes and restore the file tree + welcome pane. diff --git a/tests/integration/diffview_spec.lua b/tests/integration/diffview_spec.lua index c10704e..69c7e38 100644 --- a/tests/integration/diffview_spec.lua +++ b/tests/integration/diffview_spec.lua @@ -178,6 +178,34 @@ return { end) end, }, + { + name = 'conflicted file open builds the merge model once', + run = function() + N.with_repo('repo_conflict', function() + require('glance').start() + local ui = require('glance.ui') + local filetree = require('glance.filetree') + local merge_model = require('glance.merge.model') + local original_build = merge_model.build + local calls = 0 + + merge_model.build = function(...) + calls = calls + 1 + return original_build(...) + end + + local ok, err = pcall(function() + ui.open_file(filetree.files.conflicts[1]) + end) + merge_model.build = original_build + if not ok then + error(err) + end + + A.equal(calls, 1) + end) + end, + }, { name = 'merge refresh preserves unsaved result edits', run = function() diff --git a/tests/integration/ui_spec.lua b/tests/integration/ui_spec.lua index a77c703..41d9d2b 100644 --- a/tests/integration/ui_spec.lua +++ b/tests/integration/ui_spec.lua @@ -241,6 +241,37 @@ return { end) end, }, + { + name = 'opening a file suppresses redraw while invoking the diff opener', + run = function() + N.with_repo('repo_modified', function() + require('glance').start() + local ui = require('glance.ui') + local filetree = require('glance.filetree') + local diffview = require('glance.diffview') + local original_open = diffview.open + local original_lazyredraw = vim.o.lazyredraw + local seen_lazyredraw = nil + + diffview.open = function() + seen_lazyredraw = vim.o.lazyredraw + end + + local ok, err = pcall(function() + vim.o.lazyredraw = false + ui.open_file(filetree.files.changes[1]) + A.equal(seen_lazyredraw, true) + A.equal(vim.o.lazyredraw, false) + end) + + diffview.open = original_open + vim.o.lazyredraw = original_lazyredraw + if not ok then + error(err) + end + end) + end, + }, { name = 'close diff restores width welcome and refreshes the tree', run = function() From 463da5a39a0f7be6c34021046c049c5d60b87905 Mon Sep 17 00:00:00 2001 From: Rohan Bansal Date: Sat, 25 Apr 2026 22:58:15 -0400 Subject: [PATCH 3/5] add merge ready to complete state --- lua/glance/filetree.lua | 101 +++++++++++++++++++++++----- lua/glance/merge/init.lua | 2 +- tests/helpers/state.lua | 1 + tests/integration/diffview_spec.lua | 36 ++++++++++ tests/unit/filetree_spec.lua | 28 ++++++++ 5 files changed, 150 insertions(+), 18 deletions(-) diff --git a/lua/glance/filetree.lua b/lua/glance/filetree.lua index 41b5c42..977569e 100644 --- a/lua/glance/filetree.lua +++ b/lua/glance/filetree.lua @@ -16,6 +16,7 @@ M.last_cursor_line = nil -- Tracks the previous cursor line for arrow-key snappi M.repo_head_oid = nil M.repo_snapshot_key = '' M.repo_status_output = '' +M.operation_context = nil local function filetree_options() return config.options.windows.filetree @@ -212,10 +213,42 @@ local function snap_to_nearest_file(line, prefer_up) scan(line - 1, 1, -1) end -local function add_legend(lines, highlights) +local function operation_label(context) + local labels = { + merge = 'merge', + rebase = 'rebase', + cherry_pick = 'cherry-pick', + revert = 'revert', + } + return labels[context and context.kind] or 'operation' +end + +local function operation_title(context) + local labels = { + merge = 'Merge', + rebase = 'Rebase', + cherry_pick = 'Cherry-pick', + revert = 'Revert', + } + return labels[context and context.kind] or 'Operation' +end + +local function display_key(lhs) + if type(lhs) ~= 'string' then + return '' + end + + return lhs:gsub('', '\\'):gsub('', '\\') +end + +local function add_legend(lines, highlights, operation_context) local km = config.options.keymaps local title = ' actions' - local commit_line = ' [' .. km.commit .. '] commit staged changes' + local commit_action = 'commit staged changes' + if operation_context and operation_context.kind == 'merge' then + commit_action = 'commit merge' + end + local commit_line = ' [' .. km.commit .. '] ' .. commit_action local log_line = ' [' .. km.log .. '] browse commit history' local stage_line = ' [' .. km.stage_file .. '] stage [' .. km.stage_all .. '] stage all' local unstage_line = ' [' .. km.unstage_file .. '] unstage [' .. km.unstage_all .. '] unstage all' @@ -242,6 +275,37 @@ local function add_legend(lines, highlights) add_legend_key_highlights(highlights, 6, log_line) end +local function merge_ready_message() + local km = config.options.keymaps + return { + title = ' Merge ready to complete', + detail = ' Press ' .. display_key(km.commit) .. ' to commit the merge', + } +end + +local function operation_ready_message(context) + if not context or not context.kind then + return nil + end + + if context.kind == 'merge' then + return merge_ready_message() + end + + local key = display_key(config.options.merge.keymaps.continue_operation) + local detail + if key ~= '' then + detail = ' Press ' .. key .. ' to continue' + else + detail = ' Continue the ' .. operation_label(context) .. ' from the filetree' + end + + return { + title = ' ' .. operation_title(context) .. ' ready to continue', + detail = detail, + } +end + --- Create the file tree buffer with appropriate settings. function M.create_buf() local buf = vim.api.nvim_create_buf(false, true) @@ -273,7 +337,8 @@ function M.create_buf() end --- Render the file list with section headers into the buffer. -function M.render(files) +function M.render(files, opts) + opts = opts or {} files = files or {} files = { conflicts = files.conflicts or {}, @@ -283,13 +348,14 @@ function M.render(files) } M.files = files + M.operation_context = opts.operation_context M.line_map = {} M.selected_line = nil local lines = {} local highlights = {} -- { line, col_start, col_end, hl_group } if filetree_config().show_legend then - add_legend(lines, highlights) + add_legend(lines, highlights, opts.operation_context) end local function add_section(title, file_list) @@ -330,8 +396,16 @@ function M.render(files) -- Handle empty state if #lines == legend_line_count() then - lines[#lines + 1] = ' No changes found' - add_highlight(highlights, #lines, 0, 20, 'Comment') + local ready = operation_ready_message(opts.operation_context) + if ready then + lines[#lines + 1] = ready.title + add_highlight(highlights, #lines, 0, #ready.title, 'GlanceSectionHeader') + lines[#lines + 1] = ready.detail + add_highlight(highlights, #lines, 0, #ready.detail, 'Comment') + else + lines[#lines + 1] = ' No changes found' + add_highlight(highlights, #lines, 0, 20, 'Comment') + end end -- Write to buffer @@ -511,7 +585,10 @@ function M.apply_status_snapshot(snapshot) M.repo_head_oid = snapshot.head_oid M.repo_snapshot_key = snapshot.key or '' M.repo_status_output = snapshot.output or '' - M.render(snapshot.files) + local operation_context = snapshot.operation_context or require('glance.git').get_operation_context() + M.render(snapshot.files, { + operation_context = operation_context, + }) restore_selection(saved_line) end @@ -731,16 +808,6 @@ local function refresh_after_discard(active_file) end end -local function operation_label(context) - local labels = { - merge = 'merge', - rebase = 'rebase', - cherry_pick = 'cherry-pick', - revert = 'revert', - } - return labels[context and context.kind] or 'operation' -end - local function notify_operation_continue_error(context, err) vim.notify( 'glance: failed to continue ' .. operation_label(context) .. ': ' .. tostring(err), diff --git a/lua/glance/merge/init.lua b/lua/glance/merge/init.lua index b465d89..67b335c 100644 --- a/lua/glance/merge/init.lua +++ b/lua/glance/merge/init.lua @@ -203,7 +203,7 @@ local function notify_post_complete(context, files) end if context and context.kind == 'merge' then - vim.notify('glance: all merge conflicts are resolved; press c to commit staged changes', vim.log.levels.INFO) + vim.notify('glance: all merge conflicts are resolved; press c to commit the merge', vim.log.levels.INFO) return end diff --git a/tests/helpers/state.lua b/tests/helpers/state.lua index 7d9c244..9bd5839 100644 --- a/tests/helpers/state.lua +++ b/tests/helpers/state.lua @@ -119,6 +119,7 @@ function M.reset() filetree.repo_head_oid = nil filetree.repo_snapshot_key = '' filetree.repo_status_output = '' + filetree.operation_context = nil end if commit_editor then diff --git a/tests/integration/diffview_spec.lua b/tests/integration/diffview_spec.lua index 69c7e38..6e61759 100644 --- a/tests/integration/diffview_spec.lua +++ b/tests/integration/diffview_spec.lua @@ -775,6 +775,42 @@ return { end) end, }, + { + name = 'accept all ours can complete a merge with no visible staged diff', + run = function() + N.with_repo('repo_conflict_multi', function() + require('glance').start() + local git = require('glance.git') + local ui = require('glance.ui') + local filetree = require('glance.filetree') + local messages, restore_notify = N.capture_notifications() + + ui.open_file(filetree.files.conflicts[1]) + N.press('\\ao') + N.press('\\c') + restore_notify() + + local changed = git.get_changed_files() + A.falsy(ui.diff_open) + A.equal(git.get_operation_context().kind, 'merge') + A.equal(#(changed.conflicts or {}), 0) + A.equal(#(changed.staged or {}), 0) + A.equal(#(changed.changes or {}), 0) + local lines = vim.api.nvim_buf_get_lines(filetree.buf, 0, -1, false) + A.equal(lines[#lines - 1], ' Merge ready to complete') + A.equal(lines[#lines], ' Press c to commit the merge') + + local notified = false + for _, entry in ipairs(messages) do + if entry.msg == 'glance: all merge conflicts are resolved; press c to commit the merge' then + notified = true + break + end + end + A.truthy(notified) + end) + end, + }, { name = 'complete merge stages the resolved file and returns to the filetree', run = function() diff --git a/tests/unit/filetree_spec.lua b/tests/unit/filetree_spec.lua index a7f1eda..f3ea3e0 100644 --- a/tests/unit/filetree_spec.lua +++ b/tests/unit/filetree_spec.lua @@ -164,6 +164,34 @@ return { A.same(vim.api.nvim_win_get_cursor(filetree.win), { 8, 4 }) end, }, + { + name = 'render shows merge-ready state when a merge has no visible changes', + run = function() + local filetree = setup_filetree() + filetree.render({ + conflicts = {}, + staged = {}, + changes = {}, + untracked = {}, + }, { + operation_context = { kind = 'merge' }, + }) + + A.same(vim.api.nvim_buf_get_lines(filetree.buf, 0, -1, false), { + ' actions', + ' [s] stage [S] stage all', + ' [u] unstage [U] unstage all', + ' [d] discard [D] discard all', + ' [c] commit merge', + ' [L] browse commit history', + '', + ' Merge ready to complete', + ' Press c to commit the merge', + }) + A.equal(filetree.get_selected_file(), nil) + A.equal(vim.api.nvim_get_option_value('cursorline', { win = filetree.win }), false) + end, + }, { name = 'render restores cursorline when files return', run = function() From ea69fc9b02a33e12500886e12542493fa7bc8b62 Mon Sep 17 00:00:00 2001 From: Rohan Bansal Date: Sat, 25 Apr 2026 23:12:15 -0400 Subject: [PATCH 4/5] add \C to filetree on operations --- lua/glance/filetree.lua | 63 ++++++++++++++------------- tests/unit/filetree_spec.lua | 82 ++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 30 deletions(-) diff --git a/lua/glance/filetree.lua b/lua/glance/filetree.lua index 977569e..230729d 100644 --- a/lua/glance/filetree.lua +++ b/lua/glance/filetree.lua @@ -80,13 +80,6 @@ local function add_legend_key_highlights(highlights, line, text) end end -local function legend_line_count() - if filetree_config().show_legend then - return 7 - end - return 0 -end - local function files_empty(files) files = files or {} return #(files.conflicts or {}) == 0 @@ -243,36 +236,44 @@ end local function add_legend(lines, highlights, operation_context) local km = config.options.keymaps + local merge_km = config.options.merge.keymaps or {} local title = ' actions' local commit_action = 'commit staged changes' if operation_context and operation_context.kind == 'merge' then commit_action = 'commit merge' end local commit_line = ' [' .. km.commit .. '] ' .. commit_action + local continue_line = nil + if operation_context and operation_context.kind and operation_context.kind ~= 'merge' then + local continue_key = display_key(merge_km.continue_operation) + if continue_key ~= '' then + continue_line = ' [' .. continue_key .. '] continue ' .. operation_label(operation_context) + end + end local log_line = ' [' .. km.log .. '] browse commit history' - local stage_line = ' [' .. km.stage_file .. '] stage [' .. km.stage_all .. '] stage all' - local unstage_line = ' [' .. km.unstage_file .. '] unstage [' .. km.unstage_all .. '] unstage all' - local discard_line = ' [' .. km.discard_file .. '] discard [' .. km.discard_all .. '] discard all' + local action_lines = { + ' [' .. km.stage_file .. '] stage [' .. km.stage_all .. '] stage all', + ' [' .. km.unstage_file .. '] unstage [' .. km.unstage_all .. '] unstage all', + ' [' .. km.discard_file .. '] discard [' .. km.discard_all .. '] discard all', + commit_line, + } + if continue_line then + action_lines[#action_lines + 1] = continue_line + end + action_lines[#action_lines + 1] = log_line lines[#lines + 1] = title - lines[#lines + 1] = stage_line - lines[#lines + 1] = unstage_line - lines[#lines + 1] = discard_line - lines[#lines + 1] = commit_line - lines[#lines + 1] = log_line + for _, line in ipairs(action_lines) do + lines[#lines + 1] = line + end lines[#lines + 1] = '' add_highlight(highlights, 1, 2, #title, 'GlanceLegendTitle') - add_highlight(highlights, 2, 0, #stage_line, 'GlanceLegendText') - add_highlight(highlights, 3, 0, #unstage_line, 'GlanceLegendText') - add_highlight(highlights, 4, 0, #discard_line, 'GlanceLegendText') - add_highlight(highlights, 5, 0, #commit_line, 'GlanceLegendText') - add_highlight(highlights, 6, 0, #log_line, 'GlanceLegendText') - add_legend_key_highlights(highlights, 2, stage_line) - add_legend_key_highlights(highlights, 3, unstage_line) - add_legend_key_highlights(highlights, 4, discard_line) - add_legend_key_highlights(highlights, 5, commit_line) - add_legend_key_highlights(highlights, 6, log_line) + for index, line in ipairs(action_lines) do + local line_number = index + 1 + add_highlight(highlights, line_number, 0, #line, 'GlanceLegendText') + add_legend_key_highlights(highlights, line_number, line) + end end local function merge_ready_message() @@ -357,6 +358,7 @@ function M.render(files, opts) if filetree_config().show_legend then add_legend(lines, highlights, opts.operation_context) end + local legend_lines = #lines local function add_section(title, file_list) if #file_list == 0 then @@ -364,7 +366,7 @@ function M.render(files, opts) end -- Blank line before section (except at the very start) - if #lines > legend_line_count() then + if #lines > legend_lines then table.insert(lines, '') -- line_map entry is nil by default (no action needed) end @@ -395,7 +397,7 @@ function M.render(files, opts) add_section('Untracked', files.untracked) -- Handle empty state - if #lines == legend_line_count() then + if #lines == legend_lines then local ready = operation_ready_message(opts.operation_context) if ready then lines[#lines + 1] = ready.title @@ -710,8 +712,9 @@ function M.toggle() end end -local function confirm_action(message, accept_label) - return vim.fn.confirm(message, '&' .. accept_label .. '\n&Cancel', 2) == 1 +local function confirm_action(message, accept_label, cancel_label) + cancel_label = cancel_label or '&Cancel' + return vim.fn.confirm(message, '&' .. accept_label .. '\n' .. cancel_label, 2) == 1 end local function active_diff_matches_file(file) @@ -1060,7 +1063,7 @@ function M.continue_operation() return true end - if not confirm_action('Continue ' .. operation_label(context) .. '?', 'Continue') then + if not confirm_action('Continue ' .. operation_label(context) .. '?', 'Continue', 'Ca&ncel') then return false end diff --git a/tests/unit/filetree_spec.lua b/tests/unit/filetree_spec.lua index f3ea3e0..4e388f2 100644 --- a/tests/unit/filetree_spec.lua +++ b/tests/unit/filetree_spec.lua @@ -192,6 +192,35 @@ return { A.equal(vim.api.nvim_get_option_value('cursorline', { win = filetree.win }), false) end, }, + { + name = 'render shows continue action for operation-ready state', + run = function() + local filetree = setup_filetree() + filetree.render({ + conflicts = {}, + staged = {}, + changes = {}, + untracked = {}, + }, { + operation_context = { kind = 'rebase' }, + }) + + A.same(vim.api.nvim_buf_get_lines(filetree.buf, 0, -1, false), { + ' actions', + ' [s] stage [S] stage all', + ' [u] unstage [U] unstage all', + ' [d] discard [D] discard all', + ' [c] commit staged changes', + ' [\\C] continue rebase', + ' [L] browse commit history', + '', + ' Rebase ready to continue', + ' Press \\C to continue', + }) + A.equal(filetree.get_selected_file(), nil) + A.equal(vim.api.nvim_get_option_value('cursorline', { win = filetree.win }), false) + end, + }, { name = 'render restores cursorline when files return', run = function() @@ -1047,6 +1076,59 @@ return { diffview.new_buf = nil ui.diff_open = false + if not ok then + error(err) + end + end, + }, + { + name = 'continue operation confirmation uses distinct shortcuts', + run = function() + local git = require('glance.git') + local filetree = setup_filetree() + local original_context = git.get_operation_context + local original_snapshot = git.get_status_snapshot + local original_continue = git.continue_operation + local original_refresh = filetree.refresh + local original_confirm = vim.fn.confirm + local confirm_message + local confirm_choices + local confirm_default + local continued = false + + local ok, err = xpcall(function() + git.get_operation_context = function() + return { kind = 'rebase' } + end + git.get_status_snapshot = function() + return snapshot() + end + git.continue_operation = function() + continued = true + return true + end + filetree.refresh = function() + end + vim.fn.confirm = function(message, choices, default) + confirm_message = message + confirm_choices = choices + confirm_default = default + return 2 + end + + A.equal(filetree.continue_operation(), false) + A.equal(confirm_message, 'Continue rebase?') + A.equal(confirm_choices, '&Continue\nCa&ncel') + A.equal(confirm_default, 2) + A.falsy(continued) + end, debug.traceback) + + git.get_operation_context = original_context + git.get_status_snapshot = original_snapshot + git.continue_operation = original_continue + filetree.refresh = original_refresh + vim.fn.confirm = original_confirm + if not ok then error(err) end From fd7b2aa17bacd8d77252202599c1897d852d97d7 Mon Sep 17 00:00:00 2001 From: Rohan Bansal Date: Sun, 26 Apr 2026 20:52:09 -0400 Subject: [PATCH 5/5] trigger ci again