From 7ccbc3c711aa7dd955505fba4ce2e0faa6ee134a Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Fri, 19 Jun 2026 21:31:12 +0200 Subject: [PATCH] fix(file-history): preserve cursor and folds across refresh --- .../views/file_history/file_history_panel.lua | 111 +++++++- .../functional/file_history_render_spec.lua | 236 ++++++++++++++++++ 2 files changed, 346 insertions(+), 1 deletion(-) diff --git a/lua/diffview/scene/views/file_history/file_history_panel.lua b/lua/diffview/scene/views/file_history/file_history_panel.lua index dc2e7269..cf28fa62 100644 --- a/lua/diffview/scene/views/file_history/file_history_panel.lua +++ b/lua/diffview/scene/views/file_history/file_history_panel.lua @@ -194,12 +194,113 @@ function FileHistoryPanel:update_components() self.constrain_cursor = renderer.create_cursor_constraint({ self.components.log.entries.comp }) end +---@class FileHistoryPanel.StateSnapshot +---@field unfolded table Set of unfolded entries, keyed by commit hash. +---@field cur? { hash: string, path: string } The focused file, identified by commit and path. + +---Snapshot the fold and cursor state so it can be restored after a rebuild. +---Keyed by commit hash, so the synthetic working-tree entry (`nil` hash) is +---excluded; `cur` stays nil when nothing is focused. +---@return FileHistoryPanel.StateSnapshot +function FileHistoryPanel:_snapshot_state() + local unfolded = {} + + for _, entry in ipairs(self.entries) do + if not entry.folded and entry.commit and entry.commit.hash then + unfolded[entry.commit.hash] = true + end + end + + local cur + local cur_entry, cur_file = self.cur_item[1], self.cur_item[2] + + if cur_entry and cur_file and cur_entry.commit and cur_entry.commit.hash then + cur = { hash = cur_entry.commit.hash, path = cur_file.path } + end + + return { unfolded = unfolded, cur = cur } +end + +---Re-apply the snapshotted fold state to the rebuilt entries and return the +---`FileEntry` to re-focus (the caller reloads its diff via `set_file`). Entries +---absent from the snapshot are left folded (the default for a fresh entry). +--- +---Returns nil on an empty snapshot (first open): the streaming bootstrap has +---already focused, unfolded, and loaded the first entry, so re-folding and +---reloading it would just flash the diff. Otherwise a file is always returned +---(the previous file, else its commit's first file, else the first entry's +---first file); the bootstrap is skipped when there's state to restore, so this +---is the only thing that re-establishes the cursor and loads its diff. +--- +---`pin_local` mode is left untouched: the bootstrap in `set_file_by_offset` +---already re-targets the pinned file there, and a pinned commit's focused file +---may be a transient overlay that isn't in `entry.files`. +---@param prev_state FileHistoryPanel.StateSnapshot +---@return FileEntry? +function FileHistoryPanel:_restore_state(prev_state) + if self.parent.pin_local then + return + end + + -- First open: nothing to restore; keep the streaming bootstrap's result. + if not prev_state.cur and next(prev_state.unfolded) == nil then + return + end + + if not self.single_file then + for _, entry in ipairs(self.entries) do + local hash = entry.commit and entry.commit.hash + entry.folded = not (hash and prev_state.unfolded[hash]) + end + end + + -- Set the cursor to `file` and return it for the caller to re-focus. + local function focus(entry, file) + self:set_cur_item({ entry, file }) + return file + end + + local cur = prev_state.cur + + if cur then + for _, entry in ipairs(self.entries) do + if entry.commit and entry.commit.hash == cur.hash then + -- Match on path alone: paths are unique within a commit, and rename + -- detection (`oldpath`) can resolve differently across a refresh, + -- which would spuriously drop the match. + for _, file in ipairs(entry.files) do + if file.path == cur.path then + return focus(entry, file) + end + end + + -- File gone but commit survived: fall back to its first file. + if entry.files[1] then + return focus(entry, entry.files[1]) + end + end + end + end + + -- Focused commit gone (or never set): fall back to the first entry so the + -- cursor stays consistent with the re-applied folds. + local first = self.entries[1] + if first and first.files[1] then + return focus(first, first.files[1]) + end +end + ---@param self FileHistoryPanel ---@param callback function FileHistoryPanel.update_entries = async.wrap(function(self, callback) perf_update:reset() local checkout = self.work_pool:check_in() + -- Snapshot fold/cursor state before the rebuild so a refresh that doesn't + -- change the history (`R`, `FugitiveChanged`) keeps the user's expanded + -- entries and cursor instead of collapsing and snapping to the top. + local prev_state = self:_snapshot_state() + for _, entry in ipairs(self.entries) do entry:destroy() end @@ -272,7 +373,9 @@ FileHistoryPanel.update_entries = async.wrap(function(self, callback) end local bootstrap_file - if not self:cur_file() and self:num_items() > 0 then + -- Skip the bootstrap diff when a file will be restored at completion: + -- loading the first entry here would just flash before the restore. + if not prev_state.cur and not self:cur_file() and self:num_items() > 0 then bootstrap_file = self:next_file() end @@ -328,8 +431,14 @@ FileHistoryPanel.update_entries = async.wrap(function(self, callback) self.updating = false if not self.shutdown:check() then + -- Restore the pre-refresh folds and focused file. `set_file` loads the + -- diff; on first open restore is a no-op and the bootstrap's diff stands. + local restore_file = self:_restore_state(prev_state) self:sync() self.option_panel:sync() + if restore_file then + self.parent:set_file(restore_file) + end vim.cmd("redraw") end diff --git a/lua/diffview/tests/functional/file_history_render_spec.lua b/lua/diffview/tests/functional/file_history_render_spec.lua index 2daeec98..ad302019 100644 --- a/lua/diffview/tests/functional/file_history_render_spec.lua +++ b/lua/diffview/tests/functional/file_history_render_spec.lua @@ -592,3 +592,239 @@ describe("file_history_render", function() end) end) end) + +-- ========================================================================= +-- State preservation across refresh (_snapshot_state / _restore_state) +-- ========================================================================= + +---Create a stub file entry identified by path (and optional oldpath). +---@param path string +---@param oldpath? string +---@return table +local function fh_file(path, oldpath) + return { path = path, oldpath = oldpath } +end + +---Create a stub log entry with a commit hash, fold state, and files. +---@param hash string? +---@param folded boolean +---@param files table[] +---@return table +local function fh_entry(hash, folded, files) + return { commit = { hash = hash }, folded = folded, files = files } +end + +---Build a minimal FileHistoryPanel-shaped table with the state helpers bound. +---@param entries table[] +---@param opts? { single_file?: boolean, pin_local?: boolean, cur_item?: table } +---@return table +local function state_panel(entries, opts) + opts = opts or {} + + local FileHistoryPanel = + require("diffview.scene.views.file_history.file_history_panel").FileHistoryPanel + + local panel = { + entries = entries, + single_file = opts.single_file or false, + parent = { pin_local = opts.pin_local or false }, + cur_item = opts.cur_item or {}, + } + + panel._snapshot_state = FileHistoryPanel._snapshot_state + panel._restore_state = FileHistoryPanel._restore_state + panel.set_cur_item = function(self, new_item) + self.cur_item = new_item + end + + return panel +end + +describe("file_history state preservation", function() + describe("_snapshot_state", function() + it("records unfolded entries by commit hash and the focused file", function() + local f1 = fh_file("a.txt") + local panel = state_panel({ + fh_entry("aaa", false, { f1 }), + fh_entry("bbb", true, { fh_file("b.txt") }), + }) + panel.cur_item = { panel.entries[1], f1 } + + local snap = panel:_snapshot_state() + + eq(true, snap.unfolded["aaa"]) + eq(nil, snap.unfolded["bbb"]) + eq("aaa", snap.cur.hash) + eq("a.txt", snap.cur.path) + end) + + it("excludes the synthetic working-tree entry (nil hash)", function() + local panel = state_panel({ fh_entry(nil, false, { fh_file("x.txt") }) }) + local snap = panel:_snapshot_state() + eq(nil, next(snap.unfolded)) + end) + + it("leaves cur nil when nothing is focused", function() + local panel = state_panel({ fh_entry("aaa", true, { fh_file("a.txt") }) }) + eq(nil, panel:_snapshot_state().cur) + end) + end) + + describe("_restore_state", function() + it("re-opens previously unfolded entries and folds the rest", function() + -- Rebuilt entries default to folded. + local r1 = fh_entry("aaa", true, { fh_file("a.txt") }) + local r2 = fh_entry("bbb", true, { fh_file("b.txt") }) + local panel = state_panel({ r1, r2 }) + + panel:_restore_state({ unfolded = { aaa = true }, cur = nil }) + + eq(false, r1.folded) + eq(true, r2.folded) + end) + + it("restores the cursor to the matching file by hash and path", function() + local f = fh_file("b.txt") + local r2 = fh_entry("bbb", true, { f }) + local panel = state_panel({ fh_entry("aaa", true, { fh_file("a.txt") }), r2 }) + + local restored = panel:_restore_state({ + unfolded = { bbb = true }, + cur = { hash = "bbb", path = "b.txt" }, + }) + + eq(f, restored) + eq(r2, panel.cur_item[1]) + eq(f, panel.cur_item[2]) + end) + + it("matches on path even when oldpath differs (rename re-detection)", function() + -- The snapshot only records path; a row whose rename status changed + -- across the refresh (oldpath set vs nil) must still match on path. + local renamed = fh_file("new.txt", "old.txt") + local panel = state_panel({ fh_entry("aaa", true, { renamed }) }) + + local restored = panel:_restore_state({ + unfolded = {}, + cur = { hash = "aaa", path = "new.txt" }, + }) + + eq(renamed, restored) + end) + + it("falls back to the commit's first file when the focused file is gone", function() + local first = fh_file("a.txt") + local panel = state_panel({ fh_entry("aaa", true, { first, fh_file("b.txt") }) }) + + local restored = panel:_restore_state({ + unfolded = {}, + cur = { hash = "aaa", path = "gone.txt" }, + }) + + eq(first, restored) + end) + + it("falls back to the first entry when the focused commit no longer exists", function() + -- The cursor must be re-established (not left nil), otherwise the caller + -- skips set_file and the cursor is stranded on a re-folded entry. + local first = fh_file("a.txt") + local panel = state_panel({ + fh_entry("aaa", true, { first }), + fh_entry("bbb", true, { fh_file("b.txt") }), + }) + + local restored = panel:_restore_state({ + unfolded = {}, + cur = { hash = "zzz", path = "z.txt" }, + }) + + eq(first, restored) + eq(first, panel.cur_item[2]) + end) + + it("returns nil on an empty snapshot, leaving the bootstrap's first-open state", function() + -- First open: the bootstrap already handled focus/load, so _restore_state + -- does nothing and reports no file to reload. + local first = fh_file("a.txt") + local r1 = fh_entry("aaa", true, { first }) + local panel = state_panel({ r1 }) + + local restored = panel:_restore_state({ unfolded = {}, cur = nil }) + + eq(nil, restored) + eq(true, r1.folded) + eq(nil, panel.cur_item[1]) + end) + + it("returns nil for an empty history", function() + -- Non-empty snapshot so the empty-history fallback runs, not the early return. + local panel = state_panel({}) + eq(nil, panel:_restore_state({ unfolded = {}, cur = { hash = "aaa", path = "a.txt" } })) + end) + + it("leaves fold and cursor state untouched in pin_local mode", function() + -- pin_local owns its own restoration via the bootstrap, so _restore_state + -- is a no-op there. + local r1 = fh_entry("aaa", true, { fh_file("a.txt") }) + local panel = state_panel({ r1, fh_entry("bbb", true, { fh_file("b.txt") }) }, { + pin_local = true, + }) + local sentinel = { "untouched" } + panel.cur_item = sentinel + + local restored = panel:_restore_state({ + unfolded = { aaa = true }, + cur = { hash = "bbb", path = "b.txt" }, + }) + + eq(nil, restored) + eq(true, r1.folded) + eq(sentinel, panel.cur_item) + end) + + it("restores the cursor in single_file mode without altering folds", function() + local f = fh_file("a.txt") + local r1 = fh_entry("aaa", true, { f }) + local panel = state_panel({ r1 }, { single_file = true }) + + local restored = panel:_restore_state({ + unfolded = { aaa = true }, + cur = { hash = "aaa", path = "a.txt" }, + }) + + eq(f, restored) + -- The fold loop is skipped in single_file mode. + eq(true, r1.folded) + end) + end) + + it("preserves fold and cursor across a snapshot/rebuild cycle", function() + -- State before the refresh: commit "bbb" expanded, cursor on its 2nd file. + local before = { + fh_entry("aaa", true, { fh_file("a.txt") }), + fh_entry("bbb", false, { fh_file("b.txt"), fh_file("c.txt") }), + fh_entry("ccc", true, { fh_file("d.txt") }), + } + local panel = state_panel(before, { cur_item = { before[2], before[2].files[2] } }) + + local snap = panel:_snapshot_state() + + -- The rebuild creates brand-new objects with the same hashes and paths, + -- all folded by default, and clears cur_item (as `update_entries` does). + local after = { + fh_entry("aaa", true, { fh_file("a.txt") }), + fh_entry("bbb", true, { fh_file("b.txt"), fh_file("c.txt") }), + fh_entry("ccc", true, { fh_file("d.txt") }), + } + panel.entries = after + panel.cur_item = {} + + local restored = panel:_restore_state(snap) + + eq(true, after[1].folded) + eq(false, after[2].folded) + eq(true, after[3].folded) + eq(after[2].files[2], restored) + eq("c.txt", panel.cur_item[2].path) + end) +end)