Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 19 additions & 9 deletions lua/codediff/ui/explorer/render.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
}
Expand All @@ -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", {
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
114 changes: 114 additions & 0 deletions tests/ui/explorer/stale_same_path_selection_spec.lua
Original file line number Diff line number Diff line change
@@ -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)