From c4ea9d5fc3d6bd20df7c41b473a2b8d1ba5e3a8a Mon Sep 17 00:00:00 2001 From: Cesar Enrique Ramirez Date: Sun, 2 Aug 2026 20:48:57 +0200 Subject: [PATCH] fix(explorer): ignore stale same-path selections --- lua/codediff/ui/explorer/render.lua | 28 +++-- .../stale_same_path_selection_spec.lua | 114 ++++++++++++++++++ 2 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 tests/ui/explorer/stale_same_path_selection_spec.lua diff --git a/lua/codediff/ui/explorer/render.lua b/lua/codediff/ui/explorer/render.lua index 8bd6cc39..6b8d6f96 100644 --- a/lua/codediff/ui/explorer/render.lua +++ b/lua/codediff/ui/explorer/render.lua @@ -188,6 +188,7 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target current_file_path = nil, -- Track currently selected file current_file_group = nil, -- Track currently selected file's group (staged/unstaged) current_selection = nil, -- Full file selection used to replay current state + selection_generation = 0, -- Reject async work superseded by a newer selection of the same path is_hidden = explorer_config.hidden, -- Track visibility state visible_groups = vim.deepcopy(explorer_config.visible_groups or { staged = true, unstaged = true, conflicts = true }), } @@ -203,6 +204,11 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target local old_path = file_data.old_path -- For renames: path in original revision local group = file_data.group or "unstaged" local jump = not opts.no_jump and config.options.diff.jump_to_first_change + local selection_generation = explorer.selection_generation + + local function selection_is_current() + return explorer.selection_generation == selection_generation and explorer.current_file_path == file_path + end -- Emit CodeDiffFileSelect User autocmd vim.api.nvim_exec_autocmds("User", { @@ -234,7 +240,7 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target end vim.schedule(function() - if explorer.current_file_path ~= file_path then + if not selection_is_current() then return end ---@type SessionConfig @@ -256,7 +262,7 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target -- Handle untracked files: show file without diff if file_data.status == "??" then vim.schedule(function() - if explorer.current_file_path ~= file_data.path then + if not selection_is_current() then return end local sess = lifecycle.get_session(tabpage) @@ -274,7 +280,7 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target -- Handle added files: only one side has the file if file_data.status == "A" then vim.schedule(function() - if explorer.current_file_path ~= file_data.path then + if not selection_is_current() then return end local sess = lifecycle.get_session(tabpage) @@ -318,7 +324,7 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target -- Handle deleted files: show old content without diff if file_data.status == "D" then vim.schedule(function() - if explorer.current_file_path ~= file_data.path then + if not selection_is_current() then return end local sess = lifecycle.get_session(tabpage) @@ -409,7 +415,7 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target -- Two revision mode: Compare base vs target vim.schedule(function() -- Ignore stale async: see comment on the base_revision branch below. - if explorer.current_file_path ~= file_path then + if not selection_is_current() then return end ---@type SessionConfig @@ -429,6 +435,9 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target -- Use base_revision if provided, otherwise default to HEAD local target_revision_single = base_revision or "HEAD" git.resolve_revision(target_revision_single, git_root, function(err_resolve, commit_hash) + if not selection_is_current() then + return + end if err_resolve then vim.schedule(function() vim.notify(err_resolve, vim.log.levels.ERROR) @@ -443,7 +452,7 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target -- scheduled callback ran, `view.update` on the old target would -- clobber the newer selection (buffers get swapped, virtual buffers -- with `bufhidden=wipe` get destroyed mid-load, single_pane resets). - if explorer.current_file_path ~= file_path then + if not selection_is_current() then return end ---@type SessionConfig @@ -462,7 +471,7 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target -- Position controlled by config.diff.conflict_ours_position (absolute screen position) vim.schedule(function() -- Ignore stale async: see comment on the base_revision branch above. - if explorer.current_file_path ~= file_path then + if not selection_is_current() then return end -- Determine conflict buffer positions based on config @@ -499,7 +508,7 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target -- No pre-fetching needed, virtual files will load via BufReadCmd vim.schedule(function() -- Ignore stale async: see comment on the base_revision branch above. - if explorer.current_file_path ~= file_path then + if not selection_is_current() then return end ---@type SessionConfig @@ -533,7 +542,7 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target -- Ignore stale async: if a newer selection superseded us before this -- scheduled callback ran, `view.update` on the old target would -- clobber the newer one (resetting single_pane, layout.arrange). - if explorer.current_file_path ~= file_path then + if not selection_is_current() then return end ---@type SessionConfig @@ -553,6 +562,7 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target -- Wrap on_file_select to track current file and group explorer.on_file_select = function(file_data, opts) + explorer.selection_generation = explorer.selection_generation + 1 explorer.current_file_path = file_data.path explorer.current_file_group = file_data.group explorer.current_selection = vim.deepcopy(file_data) diff --git a/tests/ui/explorer/stale_same_path_selection_spec.lua b/tests/ui/explorer/stale_same_path_selection_spec.lua new file mode 100644 index 00000000..f5f81693 --- /dev/null +++ b/tests/ui/explorer/stale_same_path_selection_spec.lua @@ -0,0 +1,114 @@ +-- Regression: async work for an older selection must not overwrite a newer +-- staged/unstaged selection of the same path. A path-only stale check cannot +-- distinguish those two explorer entries. + +local h = dofile("tests/helpers.lua") +h.ensure_plugin_loaded() + +local lifecycle = require("codediff.ui.lifecycle") + +local function setup_command() + local commands = require("codediff.commands") + vim.api.nvim_create_user_command("CodeDiff", function(opts) + commands.vscode_diff(opts) + end, { nargs = "*", bang = true }) +end + +describe("explorer stale same-path selections", function() + local repo + local original_cwd + local git + local view + local real_resolve_revision + local real_update + + before_each(function() + require("codediff").setup({ explorer = { auto_refresh = false } }) + setup_command() + original_cwd = vim.fn.getcwd() + repo = h.create_temp_git_repo() + + repo.write_file("race.lua", { "local value = 1", "return value" }) + repo.git("add race.lua") + repo.git("commit -m initial") + repo.write_file("race.lua", { "local value = 2", "return value" }) + repo.git("add race.lua") + repo.write_file("race.lua", { "local value = 2", "return value + 1" }) + + git = require("codediff.core.git") + view = require("codediff.ui.view") + real_resolve_revision = git.resolve_revision + real_update = view.update + end) + + after_each(function() + git.resolve_revision = real_resolve_revision + view.update = real_update + pcall(function() + vim.cmd("tabnew") + vim.cmd("tabonly") + end) + vim.fn.chdir(original_cwd) + if repo then + repo.cleanup() + end + end) + + it("ignores an older callback when the same path changes group", function() + vim.fn.chdir(repo.dir) + vim.cmd("edit " .. repo.path("race.lua")) + vim.cmd("CodeDiff") + + local explorer + assert.is_true( + vim.wait(10000, function() + for _, tabpage in ipairs(vim.api.nvim_list_tabpages()) do + local session = lifecycle.get_session(tabpage) + if + session + and session.explorer + and session.explorer.current_file_path == "race.lua" + and session.original_revision == ":0" + and session.modified_revision == nil + and session.stored_diff_result + and session.stored_diff_result.changes + then + explorer = session.explorer + return true + end + end + return false + end, 20), + "CodeDiff explorer did not become ready" + ) + + local callbacks = {} + git.resolve_revision = function(_, _, callback) + callbacks[#callbacks + 1] = callback + end + + local updates = {} + view.update = function(_, session_config) + updates[#updates + 1] = session_config + return true + end + + local common = { path = "race.lua", status = "M", git_root = explorer.git_root } + explorer.on_file_select(vim.tbl_extend("force", common, { group = "unstaged" }), { force = true }) + explorer.on_file_select(vim.tbl_extend("force", common, { group = "staged" }), { force = true }) + assert.equal(2, #callbacks) + + local head = vim.trim(repo.git("rev-parse HEAD")) + callbacks[2](nil, head) -- Newer staged selection completes first. + callbacks[1](nil, head) -- Older unstaged selection completes last. + + assert.is_true( + vim.wait(1000, function() + return #updates > 0 + end, 10), + "the current selection did not update the view" + ) + assert.equal(1, #updates, "stale same-path callback also updated the view") + assert.equal(":0", updates[1].modified_revision, "the staged selection should be the only update") + end) +end)